Compare commits

..

21 Commits

Author SHA1 Message Date
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
214632604d feat(settings): added snap to grid slider to settings (#2504)
* feat(settings): added snap to grid slider to settings

* ack PR comments

* ack PR comment
2025-12-20 16:54:40 -08:00
Vikhyath Mondreti
1ddbac1d2e fix(code): cmd-z after refocus should not clear subblock (#2503) 2025-12-20 16:26:30 -08:00
Waleed
35a57bfad4 feat(audit): added audit log for billing line items (#2500)
* feat(audit): added audit log for billing line items

* remove migration

* reran migrations after resolving merge conflict

* ack PR comment
2025-12-20 14:10:01 -08:00
Waleed
f8678b179a fix(migrations): remove duplicate indexes (#2501) 2025-12-20 13:55:26 -08:00
Siddharth Ganesan
0ebb45b2db feat(copilot): show inline prompt to increase usage limit or upgrade plan (#2465)
* Add limit v1

* fix ui for copilot upgrade limit inline

* open settings modal

* Upgrade plan button

* Remove comments

* Ishosted check

* Fix hardcoded bumps

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-20 13:46:06 -08:00
Waleed
6247f421bc improvement(queries): add workspaceId to execution logs, added missing indexes based on query insights (#2471)
* improvement(queries): added missing indexes

* add workspaceId to execution logs

* remove migration to prep merge

* regen migration

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-20 13:33:10 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Waleed
6385d82b85 improvement(ui): updated kb tag component to match existing table (#2498)
* improvement(ui): updated kb tag component to match existing table

* fix selection

* fix more ui

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-19 22:26:24 -08:00
Waleed
f91beb324e fix(condition): fixed deactivated edges when if and else if conditions connected to same destination block, added 100+ unit tests (#2497) 2025-12-19 21:12:49 -08:00
Priyanshu Solanki
4f69b171f2 feat(kb): Adding support for more tags to the KB (#2433)
* creating boolean, number and date tags with different equality matchings

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

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

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

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

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

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

* fix: handle non-text columns in getTagUsage query

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

* refactor: use EMCN components for KB UI

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

* fix: layout for delete button next to date picker

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

* fix: clear value when switching tag field type

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

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

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

* fixing tags import issue

* fix rm file

* reduced number to 3 and date to 2

* fixing lint

* fixed the prop size issue

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

* fixed number the sql stuff

* progress

* fix document tag and kb tag modals

* update datepicker emcn component

* fix ui

* progress on KB block tags UI

* fix issues with date filters

* fix execution parsing of types for KB tags

* remove migration before merge

* regen migrations

* fix tests and types

* address greptile comments

* fix more greptile comments

* fix filtering logic for multiple of same row

* fix tests

---------

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-12-19 21:00:35 -08:00
Waleed
a1a189f328 fix(condition): remove dead code from condition handler, defer resolution to function execute tool like the function block (#2496) 2025-12-19 20:18:42 -08:00
Waleed
7dc48510dc fix(tool-input): allow multiple instances of workflow block or kb tools as agent tools (#2495)
* fix(tool-input): allow multiple instances of workflow block or kb tools as agent tools

* ack PR comments
2025-12-19 19:19:42 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
93fe68785e fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter (#2489)
* fix(subflow): prevent auto-connect across subflow edges with keyboard shortcut block additions, make positioning for auto-drop smarter

* stronger typing
2025-12-19 18:31:29 -08:00
Vikhyath Mondreti
50c1c6775b fix(logs): always capture cost, logging size failures (#2487)
* fix(logs): truncate strings in tracespans crashing insertion

* add depth check to not crash

* custom serialization to not break tracepsans

* log costs even in log creation failure

* code cleanup?

* fix typing

* remove null bytes

* increase char limit

* reduce char limit
2025-12-19 17:39:18 -08:00
Waleed
df5f823d1c fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace (#2482)
* fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace

* cleanup

* ack PR comments

* fix failing test
2025-12-19 15:29:01 -08:00
Waleed
094f87fa1f fix(edges): prevent autoconnect outgoing edges from response block (#2479) 2025-12-19 13:19:53 -08:00
Waleed
65efa039da fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs (#2478)
* fix(redaction): consolidate redaction utils, apply them to inputs and outputs before persisting logs

* added testing utils
2025-12-19 13:17:51 -08:00
Waleed
6b15a50311 improvement(ui): updated subscription and team settings modals to emcn (#2477) 2025-12-19 11:41:47 -08:00
Waleed
65787d7cc3 fix(api-keys): remove billed account check during api key generation (#2476) 2025-12-19 11:33:00 -08:00
120 changed files with 41540 additions and 2199 deletions

View File

@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SSO-Register')
@@ -236,13 +237,13 @@ export async function POST(request: NextRequest) {
oidcConfig: providerConfig.oidcConfig
? {
...providerConfig.oidcConfig,
clientSecret: '[REDACTED]',
clientSecret: REDACTED_MARKER,
}
: undefined,
samlConfig: providerConfig.samlConfig
? {
...providerConfig.samlConfig,
cert: '[REDACTED]',
cert: REDACTED_MARKER,
}
: undefined,
},

View File

@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { logModelUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -14,6 +15,9 @@ const logger = createLogger('BillingUpdateCostAPI')
const UpdateCostSchema = z.object({
userId: z.string().min(1, 'User ID is required'),
cost: z.number().min(0, 'Cost must be a non-negative number'),
model: z.string().min(1, 'Model is required'),
inputTokens: z.number().min(0).default(0),
outputTokens: z.number().min(0).default(0),
})
/**
@@ -71,11 +75,12 @@ export async function POST(req: NextRequest) {
)
}
const { userId, cost } = validation.data
const { userId, cost, model, inputTokens, outputTokens } = validation.data
logger.info(`[${requestId}] Processing cost update`, {
userId,
cost,
model,
})
// Check if user stats record exists (same as ExecutionLogger)
@@ -107,6 +112,16 @@ export async function POST(req: NextRequest) {
addedCost: cost,
})
// Log usage for complete audit trail
await logModelUsage({
userId,
source: 'copilot',
model,
inputTokens,
outputTokens,
cost,
})
// Check if user has hit overage threshold and bill incrementally
await checkAndBillOverageThreshold(userId)

View File

@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { chat, workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -94,6 +94,21 @@ export async function POST(
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
const [workflowRecord] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, deployment.workflowId))
.limit(1)
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`)
return addCorsHeaders(
createErrorResponse('This chat is currently unavailable', 403),
request
)
}
const executionId = randomUUID()
const loggingSession = new LoggingSession(
deployment.workflowId,
@@ -104,7 +119,7 @@ export async function POST(
await loggingSession.safeStart({
userId: deployment.userId,
workspaceId: '', // Will be resolved if needed
workspaceId,
variables: {},
})
@@ -169,7 +184,14 @@ export async function POST(
const { actorUserId, workflowRecord } = preprocessResult
const workspaceOwnerId = actorUserId!
const workspaceId = workflowRecord?.workspaceId || ''
const workspaceId = workflowRecord?.workspaceId
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
return addCorsHeaders(
createErrorResponse('Workflow has no associated workspace', 500),
request
)
}
try {
const selectedOutputs: string[] = []

View File

@@ -141,6 +141,23 @@ export async function DELETE(
)
}
// Check if deleting this folder would delete the last workflow(s) in the workspace
const workflowsInFolder = await countWorkflowsInFolderRecursively(
id,
existingFolder.workspaceId
)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return NextResponse.json(
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
{ status: 400 }
)
}
// Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
return stats
}
/**
* Counts the number of workflows in a folder and all its subfolders recursively.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
// Helper function to check for circular references
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
let currentParentId: string | null = parentId

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
workflowName: workflow.name,
}
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
let conditions: SQL | undefined = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
if (params.level && params.level !== 'all') {
const levels = params.level.split(',').filter(Boolean)
@@ -134,7 +134,7 @@ export async function GET(request: NextRequest) {
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, userId)
)
)

View File

@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
deploymentVersionName: sql<null>`NULL`,
}
const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
const baseQuery = db
.select(selectColumns)
.from(workflowExecutionLogs)
@@ -141,18 +143,12 @@ export async function GET(request: NextRequest) {
workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
)
.innerJoin(
workflow,
and(
eq(workflowExecutionLogs.workflowId, workflow.id),
eq(workflow.workspaceId, params.workspaceId)
)
)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, userId)
)
)
@@ -300,7 +296,7 @@ export async function GET(request: NextRequest) {
}
const logs = await baseQuery
.where(conditions)
.where(and(workspaceFilter, conditions))
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(params.limit)
.offset(params.offset)
@@ -312,22 +308,16 @@ export async function GET(request: NextRequest) {
pausedExecutions,
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
)
.innerJoin(
workflow,
and(
eq(workflowExecutionLogs.workflowId, workflow.id),
eq(workflow.workspaceId, params.workspaceId)
)
)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, userId)
)
)
.where(conditions)
.where(and(eq(workflowExecutionLogs.workspaceId, params.workspaceId), conditions))
const countResult = await countQuery

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { permissions, workflowExecutionLogs } from '@sim/db/schema'
import { and, eq, isNotNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -42,23 +42,17 @@ export async function GET(request: NextRequest) {
trigger: workflowExecutionLogs.trigger,
})
.from(workflowExecutionLogs)
.innerJoin(
workflow,
and(
eq(workflowExecutionLogs.workflowId, workflow.id),
eq(workflow.workspaceId, params.workspaceId)
)
)
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, userId)
)
)
.where(
and(
eq(workflowExecutionLogs.workspaceId, params.workspaceId),
isNotNull(workflowExecutionLogs.trigger),
sql`${workflowExecutionLogs.trigger} NOT IN ('api', 'manual', 'webhook', 'chat', 'schedule')`
)

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/core/config/env'
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
import { createLogger } from '@/lib/logs/console/logger'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -188,7 +189,7 @@ export async function POST(request: NextRequest) {
if (variablesObject && Object.keys(variablesObject).length > 0) {
const safeVarKeys = Object.keys(variablesObject).map((key) => {
return key.toLowerCase().includes('password') ? `${key}: [REDACTED]` : key
return isSensitiveKey(key) ? `${key}: ${REDACTED_MARKER}` : key
})
logger.info('Variables available for task', { variables: safeVarKeys })
}

View File

@@ -26,9 +26,9 @@ const SettingsSchema = z.object({
showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
})
// Default settings values
const defaultSettings = {
theme: 'system',
autoConnect: true,
@@ -38,6 +38,7 @@ const defaultSettings = {
showTrainingControls: false,
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
}
export async function GET() {
@@ -46,7 +47,6 @@ export async function GET() {
try {
const session = await getSession()
// Return default settings for unauthenticated users instead of 401 error
if (!session?.user?.id) {
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
return NextResponse.json({ data: defaultSettings }, { status: 200 })
@@ -72,13 +72,13 @@ export async function GET() {
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Settings fetch error`, error)
// Return default settings on error instead of error response
return NextResponse.json({ data: defaultSettings }, { status: 200 })
}
}
@@ -89,7 +89,6 @@ export async function PATCH(request: Request) {
try {
const session = await getSession()
// Return success for unauthenticated users instead of error
if (!session?.user?.id) {
logger.info(
`[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving`
@@ -103,7 +102,6 @@ export async function PATCH(request: Request) {
try {
const validatedData = SettingsSchema.parse(body)
// Store the settings
await db
.insert(settings)
.values({
@@ -135,7 +133,6 @@ export async function PATCH(request: Request) {
}
} catch (error: any) {
logger.error(`[${requestId}] Settings update error`, error)
// Return success on error instead of error response
return NextResponse.json({ success: true }, { status: 200 })
}
}

View File

@@ -0,0 +1,105 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('UsageLogsAPI')
const QuerySchema = z.object({
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
workspaceId: z.string().optional(),
period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
/**
* GET /api/users/me/usage-logs
* Get usage logs for the authenticated user
*/
export async function GET(req: NextRequest) {
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const { searchParams } = new URL(req.url)
const queryParams = {
source: searchParams.get('source') || undefined,
workspaceId: searchParams.get('workspaceId') || undefined,
period: searchParams.get('period') || '30d',
limit: searchParams.get('limit') || '50',
cursor: searchParams.get('cursor') || undefined,
}
const validation = QuerySchema.safeParse(queryParams)
if (!validation.success) {
return NextResponse.json(
{
error: 'Invalid query parameters',
details: validation.error.issues,
},
{ status: 400 }
)
}
const { source, workspaceId, period, limit, cursor } = validation.data
let startDate: Date | undefined
const endDate = new Date()
if (period !== 'all') {
startDate = new Date()
switch (period) {
case '1d':
startDate.setDate(startDate.getDate() - 1)
break
case '7d':
startDate.setDate(startDate.getDate() - 7)
break
case '30d':
startDate.setDate(startDate.getDate() - 30)
break
}
}
const result = await getUserUsageLogs(userId, {
source: source as UsageLogSource | undefined,
workspaceId,
startDate,
endDate,
limit,
cursor,
})
logger.debug('Retrieved usage logs', {
userId,
source,
period,
logCount: result.logs.length,
hasMore: result.pagination.hasMore,
})
return NextResponse.json({
success: true,
...result,
})
} catch (error) {
logger.error('Failed to get usage logs', {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json(
{
error: 'Failed to retrieve usage logs',
},
{ status: 500 }
)
}
}

View File

@@ -25,8 +25,7 @@ export interface LogFilters {
export function buildLogFilters(filters: LogFilters): SQL<unknown> {
const conditions: SQL<unknown>[] = []
// Required: workspace and permissions check
conditions.push(eq(workflow.workspaceId, filters.workspaceId))
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
// Cursor-based pagination
if (filters.cursor) {

View File

@@ -105,7 +105,6 @@ export async function GET(request: NextRequest) {
const conditions = buildLogFilters(filters)
const orderBy = getOrderBy(params.order)
// Build and execute query
const baseQuery = db
.select({
id: workflowExecutionLogs.id,
@@ -124,13 +123,7 @@ export async function GET(request: NextRequest) {
workflowDescription: workflow.description,
})
.from(workflowExecutionLogs)
.innerJoin(
workflow,
and(
eq(workflowExecutionLogs.workflowId, workflow.id),
eq(workflow.workspaceId, params.workspaceId)
)
)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
@@ -197,11 +190,8 @@ export async function GET(request: NextRequest) {
return result
})
// Get user's workflow execution limits and usage
const limits = await getUserLimits(userId)
// Create response with limits information
// The rateLimit object from checkRateLimit is for THIS API endpoint's rate limits
const response = createApiResponse(
{
data: formattedLogs,

View File

@@ -4,6 +4,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import OpenAI, { AzureOpenAI } from 'openai'
import { getSession } from '@/lib/auth'
import { logModelUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -88,7 +89,7 @@ async function updateUserStatsForWand(
try {
const [workflowRecord] = await db
.select({ userId: workflow.userId })
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -101,6 +102,7 @@ async function updateUserStatsForWand(
}
const userId = workflowRecord.userId
const workspaceId = workflowRecord.workspaceId
const totalTokens = usage.total_tokens || 0
const promptTokens = usage.prompt_tokens || 0
const completionTokens = usage.completion_tokens || 0
@@ -137,6 +139,17 @@ async function updateUserStatsForWand(
costAdded: costToStore,
})
await logModelUsage({
userId,
source: 'wand',
model: modelName,
inputTokens: promptTokens,
outputTokens: completionTokens,
cost: costToStore,
workspaceId: workspaceId ?? undefined,
workflowId,
})
await checkAndBillOverageThreshold(userId)
} catch (error) {
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)

View File

@@ -409,10 +409,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const actorUserId = preprocessResult.actorUserId!
const workflow = preprocessResult.workflowRecord!
if (!workflow.workspaceId) {
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
}
const workspaceId = workflow.workspaceId
logger.info(`[${requestId}] Preprocessing passed`, {
workflowId,
actorUserId,
workspaceId: workflow.workspaceId,
workspaceId,
})
if (isAsyncMode) {
@@ -460,7 +466,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
const executionContext = {
workspaceId: workflow.workspaceId || '',
workspaceId,
workflowId,
executionId,
}
@@ -478,7 +484,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
await loggingSession.safeStart({
userId: actorUserId,
workspaceId: workflow.workspaceId || '',
workspaceId,
variables: {},
})
@@ -507,7 +513,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
executionId,
workflowId,
workspaceId: workflow.workspaceId ?? undefined,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
@@ -589,7 +595,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflow: {
id: workflow.id,
userId: actorUserId,
workspaceId: workflow.workspaceId,
workspaceId,
isDeployed: workflow.isDeployed,
variables: (workflow as any).variables,
},
@@ -775,7 +781,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
executionId,
workflowId,
workspaceId: workflow.workspaceId ?? undefined,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,

View File

@@ -70,7 +70,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
const userId = accessValidation.workflow.userId
const workspaceId = accessValidation.workflow.workspaceId || ''
const workspaceId = accessValidation.workflow.workspaceId
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
return createErrorResponse('Workflow has no associated workspace', 500)
}
await loggingSession.safeStart({
userId,

View File

@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
const mockGetWorkflowAccessContext = vi.fn()
const mockDbDelete = vi.fn()
const mockDbUpdate = vi.fn()
const mockDbSelect = vi.fn()
vi.mock('@/lib/auth', () => ({
getSession: () => mockGetSession(),
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
db: {
delete: () => mockDbDelete(),
update: () => mockDbUpdate(),
select: () => mockDbSelect(),
},
workflow: {},
}))
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
isWorkspaceOwner: false,
})
// Mock db.select() to return multiple workflows so deletion is allowed
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
}),
})
mockDbDelete.mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
})
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
expect(data.success).toBe(true)
})
it('should prevent deletion of the last workflow in workspace', async () => {
const mockWorkflow = {
id: 'workflow-123',
userId: 'user-123',
name: 'Test Workflow',
workspaceId: 'workspace-456',
}
mockGetSession.mockResolvedValue({
user: { id: 'user-123' },
})
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
mockGetWorkflowAccessContext.mockResolvedValue({
workflow: mockWorkflow,
workspaceOwnerId: 'workspace-456',
workspacePermission: 'admin',
isOwner: true,
isWorkspaceOwner: false,
})
// Mock db.select() to return only 1 workflow (the one being deleted)
mockDbSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
}),
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE',
})
const params = Promise.resolve({ id: 'workflow-123' })
const response = await DELETE(req, { params })
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
})
it.concurrent('should deny deletion for non-admin users', async () => {
const mockWorkflow = {
id: 'workflow-123',

View File

@@ -228,6 +228,21 @@ export async function DELETE(
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check if this is the last workflow in the workspace
if (workflowData.workspaceId) {
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workflowData.workspaceId))
if (totalWorkflowsInWorkspace.length <= 1) {
return NextResponse.json(
{ error: 'Cannot delete the only workflow in the workspace' },
{ status: 400 }
)
}
}
// Check if workflow has published templates before deletion
const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'

View File

@@ -98,23 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const workspaceRows = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRows.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (workspaceRows[0].billedAccountUserId !== userId) {
return NextResponse.json(
{ error: 'Only the workspace billing account can create workspace API keys' },
{ status: 403 }
)
}
const body = await request.json()
const { name } = CreateKeySchema.parse(body)
@@ -202,23 +185,6 @@ export async function DELETE(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const workspaceRows = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRows.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (workspaceRows[0].billedAccountUserId !== userId) {
return NextResponse.json(
{ error: 'Only the workspace billing account can delete workspace API keys' },
{ status: 403 }
)
}
const body = await request.json()
const { keys } = DeleteKeysSchema.parse(body)

View File

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

View File

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

View File

@@ -339,12 +339,31 @@ export function CreateBaseModal({
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='name'>Name</Label>
<Label htmlFor='kb-name'>Name</Label>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<Input
id='name'
id='kb-name'
placeholder='Enter knowledge base name'
{...register('name')}
className={cn(errors.name && 'border-[var(--text-error)]')}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>

View File

@@ -2,3 +2,4 @@ export * from './file-display'
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
export * from './smooth-streaming'
export * from './thinking-block'
export * from './usage-limit-actions'

View File

@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/emcn'
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
import { isHosted } from '@/lib/core/config/feature-flags'
import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription'
import { useCopilotStore } from '@/stores/panel/copilot/store'
const LIMIT_INCREMENTS = [0, 50, 100] as const
function roundUpToNearest50(value: number): number {
return Math.ceil(value / 50) * 50
}
export function UsageLimitActions() {
const { data: subscriptionData } = useSubscriptionData()
const updateUsageLimitMutation = useUpdateUsageLimit()
const subscription = subscriptionData?.data
const canEdit = subscription ? canEditUsageLimit(subscription) : false
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
const [isHidden, setIsHidden] = useState(false)
const currentLimit = subscription?.usage_limit ?? 0
const baseLimit = roundUpToNearest50(currentLimit) || 50
const limitOptions = LIMIT_INCREMENTS.map((increment) => baseLimit + increment)
const handleUpdateLimit = async (newLimit: number) => {
setSelectedAmount(newLimit)
try {
await updateUsageLimitMutation.mutateAsync({ limit: newLimit })
setIsHidden(true)
const { messages, sendMessage } = useCopilotStore.getState()
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
if (lastUserMessage) {
const filteredMessages = messages.filter(
(m) => !(m.role === 'assistant' && m.errorType === 'usage_limit')
)
useCopilotStore.setState({ messages: filteredMessages })
await sendMessage(lastUserMessage.content, {
fileAttachments: lastUserMessage.fileAttachments,
contexts: lastUserMessage.contexts,
messageId: lastUserMessage.id,
})
}
} catch {
setIsHidden(false)
} finally {
setSelectedAmount(null)
}
}
const handleNavigateToUpgrade = () => {
if (isHosted) {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
} else {
window.open('https://www.sim.ai', '_blank')
}
}
if (isHidden) {
return null
}
if (!isHosted || !canEdit) {
return (
<Button onClick={handleNavigateToUpgrade} variant='default'>
Upgrade
</Button>
)
}
return (
<>
{limitOptions.map((limit) => {
const isLoading = updateUsageLimitMutation.isPending && selectedAmount === limit
const isDisabled = updateUsageLimitMutation.isPending
return (
<Button
key={limit}
onClick={() => handleUpdateLimit(limit)}
disabled={isDisabled}
variant='default'
>
{isLoading ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}${limit}
</Button>
)
})}
</>
)
}

View File

@@ -9,6 +9,7 @@ import {
SmoothStreamingText,
StreamingIndicator,
ThinkingBlock,
UsageLimitActions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import {
@@ -458,6 +459,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
<StreamingIndicator />
)}
{message.errorType === 'usage_limit' && (
<div className='mt-3 flex gap-1.5'>
<UsageLimitActions />
</div>
)}
{/* Action buttons for completed messages */}
{!isStreaming && cleanTextContent && (
<div className='flex items-center gap-[8px] pt-[8px]'>

View File

@@ -214,6 +214,7 @@ export function Code({
const handleStreamStartRef = useRef<() => void>(() => {})
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
const hasEditedSinceFocusRef = useRef(false)
// Custom hooks
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -504,6 +505,7 @@ export function Code({
setCode(newValue)
setStoreValue(newValue)
hasEditedSinceFocusRef.current = true
const newCursorPosition = dropPosition + 1
setCursorPosition(newCursorPosition)
@@ -533,6 +535,7 @@ export function Code({
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true
}
setShowTags(false)
setActiveSourceBlockId(null)
@@ -550,6 +553,7 @@ export function Code({
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true
}
setShowEnvVars(false)
@@ -741,6 +745,7 @@ export function Code({
value={code}
onValueChange={(newCode) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
@@ -769,6 +774,12 @@ export function Code({
if (isAiStreaming) {
e.preventDefault()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
}
}}
onFocus={() => {
hasEditedSinceFocusRef.current = false
}}
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}

View File

@@ -1,12 +1,10 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { useMemo, useRef } from 'react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
@@ -20,7 +18,8 @@ interface DocumentTagRow {
id: string
cells: {
tagName: string
type: string
tagSlot?: string
fieldType: string
value: string
}
}
@@ -66,17 +65,11 @@ export function DocumentTagEntry({
const emitTagSelection = useTagSelection(blockId, subBlock.id)
// State for dropdown visibility - one for each row
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
// State for type dropdown visibility - one for each row
const [typeDropdownStates, setTypeDropdownStates] = useState<Record<number, boolean>>({})
// Use preview value when in preview mode, otherwise use store value
const currentValue = isPreview ? previewValue : storeValue
// Transform stored JSON string to table format for display
const rows = useMemo(() => {
// If we have stored data, use it
if (currentValue) {
try {
const tagData = JSON.parse(currentValue)
@@ -85,7 +78,8 @@ export function DocumentTagEntry({
id: tag.id || `tag-${index}`,
cells: {
tagName: tag.tagName || '',
type: tag.fieldType || 'text',
tagSlot: tag.tagSlot,
fieldType: tag.fieldType || 'text',
value: tag.value || '',
},
}))
@@ -99,137 +93,109 @@ export function DocumentTagEntry({
return [
{
id: 'empty-row-0',
cells: { tagName: '', type: 'text', value: '' },
cells: { tagName: '', tagSlot: undefined, fieldType: 'text', value: '' },
},
]
}, [currentValue])
// Get available tag names and check for case-insensitive duplicates
const usedTagNames = new Set(
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
)
// Get tag names already used in rows (case-insensitive)
const usedTagNames = useMemo(() => {
return new Set(
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
)
}, [rows])
const availableTagDefinitions = tagDefinitions.filter(
(def) => !usedTagNames.has(def.displayName.toLowerCase())
)
// Filter available tags (exclude already used ones)
const availableTagDefinitions = useMemo(() => {
return tagDefinitions.filter((def) => !usedTagNames.has(def.displayName.toLowerCase()))
}, [tagDefinitions, usedTagNames])
// Check if we can add more tags based on MAX_TAG_SLOTS
const newTagsBeingCreated = rows.filter(
(row) =>
row.cells.tagName?.trim() &&
!tagDefinitions.some(
(def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase()
)
).length
const canAddMoreTags = tagDefinitions.length + newTagsBeingCreated < MAX_TAG_SLOTS
// Function to pre-fill existing tags
const handlePreFillTags = () => {
if (isPreview || disabled) return
const existingTagRows = tagDefinitions.map((tagDef, index) => ({
id: `prefill-${tagDef.id}-${index}`,
tagName: tagDef.displayName,
fieldType: tagDef.fieldType,
value: '',
}))
const jsonString = existingTagRows.length > 0 ? JSON.stringify(existingTagRows) : ''
setStoreValue(jsonString)
}
// Can add more tags if there are available tag definitions
const canAddMoreTags = availableTagDefinitions.length > 0
// Shared helper function for updating rows and generating JSON
const updateRowsAndGenerateJson = (rowIndex: number, column: string, value: string) => {
const updateRowsAndGenerateJson = (
rowIndex: number,
column: string,
value: string,
tagDef?: { tagSlot: string; fieldType: string }
) => {
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
const newCells = { ...row.cells, [column]: value }
// Auto-select type when existing tag is selected
if (column === 'tagName' && value) {
const tagDef = tagDefinitions.find(
(def) => def.displayName.toLowerCase() === value.toLowerCase()
)
if (tagDef) {
newCells.type = tagDef.fieldType
// When selecting a tag, also set the tagSlot and fieldType
if (column === 'tagName' && tagDef) {
newCells.tagSlot = tagDef.tagSlot
newCells.fieldType = tagDef.fieldType
// Clear value when tag changes
if (row.cells.tagName !== value) {
newCells.value = ''
}
}
return {
...row,
cells: newCells,
}
return { ...row, cells: newCells }
}
return row
})
// Store all rows including empty ones - don't auto-remove
const dataToStore = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
value: row.cells.value || '',
}))
return dataToStore.length > 0 ? JSON.stringify(dataToStore) : ''
}
const handleCellChange = (rowIndex: number, column: string, value: string) => {
const handleTagSelection = (rowIndex: number, tagName: string) => {
if (isPreview || disabled) return
// Check if this is a new tag name that would exceed the limit
if (column === 'tagName' && value.trim()) {
const isExistingTag = tagDefinitions.some(
(def) => def.displayName.toLowerCase() === value.toLowerCase()
)
if (!isExistingTag) {
// Count current new tags being created (excluding the current row)
const currentNewTags = rows.filter(
(row, idx) =>
idx !== rowIndex &&
row.cells.tagName?.trim() &&
!tagDefinitions.some(
(def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase()
)
).length
if (tagDefinitions.length + currentNewTags >= MAX_TAG_SLOTS) {
// Don't allow creating new tags if we've reached the limit
return
}
}
}
const jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
const jsonString = updateRowsAndGenerateJson(rowIndex, 'tagName', tagName, tagDef)
setStoreValue(jsonString)
}
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
const handleValueChange = (rowIndex: number, value: string) => {
if (isPreview || disabled) return
const jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
setStoreValue(jsonString)
}
const handleTagDropdownSelection = (rowIndex: number, value: string) => {
if (isPreview || disabled) return
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
emitTagSelection(jsonString)
}
const handleAddRow = () => {
if (isPreview || disabled) return
if (isPreview || disabled || !canAddMoreTags) return
// Get current data and add a new empty row
const currentData = currentValue ? JSON.parse(currentValue) : []
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).substr(2, 9)}`
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).slice(2, 11)}`
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
setStoreValue(JSON.stringify(newData))
}
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled || rows.length <= 1) return
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
if (isPreview || disabled) return
// Store all remaining rows including empty ones - don't auto-remove
if (rows.length <= 1) {
// Clear the single row instead of deleting
setStoreValue('')
return
}
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
const tableDataForStorage = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
value: row.cells.value || '',
}))
@@ -237,15 +203,15 @@ export function DocumentTagEntry({
setStoreValue(jsonString)
}
// Check for duplicate tag names (case-insensitive)
const getDuplicateStatus = (rowIndex: number, tagName: string) => {
if (!tagName.trim()) return false
const lowerTagName = tagName.toLowerCase()
return rows.some(
(row, idx) =>
idx !== rowIndex &&
row.cells.tagName?.toLowerCase() === lowerTagName &&
row.cells.tagName.trim()
if (isPreview) {
const tagCount = rows.filter((r) => r.cells.tagName?.trim()).length
return (
<div className='space-y-1'>
<Label className='font-medium text-muted-foreground text-xs'>Document Tags</Label>
<div className='text-muted-foreground text-sm'>
{tagCount > 0 ? `${tagCount} tag(s) configured` : 'No tags'}
</div>
</div>
)
}
@@ -253,209 +219,82 @@ export function DocumentTagEntry({
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
}
if (tagDefinitions.length === 0) {
return (
<div className='flex h-32 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
No tags defined for this knowledge base
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
Define tags at the knowledge base level first
</p>
</div>
</div>
)
}
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='w-1/5 border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
<thead className='bg-transparent'>
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
<th className='w-[50%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Tag
</th>
<th className='w-[50%] min-w-0 bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
)
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
const showDropdown = dropdownStates[rowIndex] || false
const setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
// Show tags that are either available OR currently selected for this row
const selectableTags = tagDefinitions.filter(
(def) => def.displayName === cellValue || !usedTagNames.has(def.displayName.toLowerCase())
)
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
if (!showDropdown) {
setShowDropdown(true)
}
}
}
const handleFocus = () => {
if (!disabled) {
setShowDropdown(true)
}
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowDropdown(false), 150)
}
const tagOptions: ComboboxOption[] = selectableTags.map((tag) => ({
value: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
}))
return (
<td className='relative border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
autoComplete='off'
className={cn(
'w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
isDuplicate && 'border-red-500 bg-red-50'
)}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{showDropdown && availableTagDefinitions.length > 0 && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{availableTagDefinitions
.filter((tagDef) =>
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
)
.map((tagDef) => (
<div
key={tagDef.id}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
setShowDropdown(false)
}}
>
<span className='flex-1 truncate'>{tagDef.displayName}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</td>
)
}
const renderTypeCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.type || 'text'
const tagName = row.cells.tagName || ''
// Check if this is an existing tag (should be read-only)
const existingTag = tagDefinitions.find(
(def) => def.displayName.toLowerCase() === tagName.toLowerCase()
)
const isReadOnly = !!existingTag
const showTypeDropdown = typeDropdownStates[rowIndex] || false
const setShowTypeDropdown = (show: boolean) => {
setTypeDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleTypeDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isReadOnly) {
if (!showTypeDropdown) {
setShowTypeDropdown(true)
}
}
}
const handleTypeFocus = () => {
if (!disabled && !isReadOnly) {
setShowTypeDropdown(true)
}
}
const handleTypeBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowTypeDropdown(false), 150)
}
const typeOptions = [{ value: 'text', label: 'Text' }]
return (
<td className='border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
readOnly
disabled={disabled || isReadOnly}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleTypeDropdownClick}
onFocus={handleTypeFocus}
onBlur={handleTypeBlur}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre text-muted-foreground'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{showTypeDropdown && !isReadOnly && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{typeOptions.map((option) => (
<div
key={option.value}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'type', option.value)
setShowTypeDropdown(false)
}}
>
<span className='flex-1 truncate'>{option.label}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
<Combobox
options={tagOptions}
value={cellValue}
onChange={(value) => handleTagSelection(rowIndex, value)}
disabled={disabled || isLoading}
placeholder='Select tag'
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
/>
</td>
)
}
const renderValueCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.value || ''
const fieldType = row.cells.fieldType || 'text'
const cellKey = `value-${rowIndex}`
const placeholder = getPlaceholderForFieldType(fieldType)
const isTagSelected = !!row.cells.tagName?.trim()
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
const handlers = inputController.fieldHelpers.createFieldHandlers(
cellKey,
cellValue,
(newValue) => handleCellChange(rowIndex, 'value', newValue)
(newValue) => handleValueChange(rowIndex, newValue)
)
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
cellKey,
cellValue,
(newValue) => handleTagDropdownSelection(rowIndex, 'value', newValue)
(newValue) => handleTagDropdownSelection(rowIndex, newValue)
)
return (
<td className='p-1'>
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
<div className='relative w-full'>
<Input
ref={(el) => {
@@ -466,12 +305,13 @@ export function DocumentTagEntry({
onKeyDown={handlers.onKeyDown}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
disabled={disabled}
disabled={disabled || !isTagSelected}
autoComplete='off'
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
placeholder={isTagSelected ? placeholder : 'Select a tag first'}
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
@@ -500,49 +340,33 @@ export function DocumentTagEntry({
}
const renderDeleteButton = (rowIndex: number) => {
// Allow deletion of any row
const canDelete = !isPreview && !disabled
if (isPreview || disabled) return null
return canDelete ? (
return (
<td className='w-0 p-0'>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash className='h-4 w-4 text-muted-foreground' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</td>
) : null
)
}
// Show pre-fill button if there are available tags and only empty rows
const showPreFillButton =
tagDefinitions.length > 0 &&
rows.length === 1 &&
!rows[0].cells.tagName &&
!rows[0].cells.value &&
!isPreview &&
!disabled
return (
<div className='relative'>
{showPreFillButton && (
<div className='mb-2'>
<Button variant='outline' size='sm' onClick={handlePreFillTags}>
Prefill Existing Tags
</Button>
</div>
)}
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
<div className='relative w-full'>
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
<table className='w-full table-fixed bg-transparent'>
{renderHeader()}
<tbody>
<tbody className='bg-transparent'>
{rows.map((row, rowIndex) => (
<tr key={row.id} className='group relative border-t'>
<tr
key={row.id}
className='group relative border-[var(--border-strong)] border-t bg-transparent'
>
{renderTagNameCell(row, rowIndex)}
{renderTypeCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
@@ -551,24 +375,13 @@ export function DocumentTagEntry({
</table>
</div>
{/* Add Row Button and Tag slots usage indicator */}
{/* Add Tag Button */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<Button
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={!canAddMoreTags}
className='h-7 px-2 text-xs'
>
<div className='mt-3'>
<Button onClick={handleAddRow} disabled={!canAddMoreTags} className='h-7 px-2 text-xs'>
<Plus className='mr-1 h-2.5 w-2.5' />
Add Tag
</Button>
{/* Tag slots usage indicator */}
<div className='text-muted-foreground text-xs'>
{tagDefinitions.length + newTagsBeingCreated} of {MAX_TAG_SLOTS} tag slots used
</div>
</div>
)}
</div>

View File

@@ -2,10 +2,10 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
@@ -20,14 +20,22 @@ import { useSubBlockValue } from '../../hooks/use-sub-block-value'
interface TagFilter {
id: string
tagName: string
tagSlot?: string
fieldType: FilterFieldType
operator: string
tagValue: string
valueTo?: string // For 'between' operator
}
interface TagFilterRow {
id: string
cells: {
tagName: string
tagSlot?: string
fieldType: FilterFieldType
operator: string
value: string
valueTo?: string
}
}
@@ -47,21 +55,15 @@ export function KnowledgeTagFilters({
previewValue,
}: KnowledgeTagFiltersProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
// Hook for immediate tag/dropdown selections
const emitTagSelection = useTagSelection(blockId, subBlock.id)
// Get the knowledge base ID from other sub-blocks
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
// Use KB tag definitions hook to get available tags
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
// Get accessible prefixes for variable highlighting
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// State for managing tag dropdown
const [activeTagDropdown, setActiveTagDropdown] = useState<{
rowIndex: number
showTags: boolean
@@ -70,14 +72,15 @@ export function KnowledgeTagFilters({
element?: HTMLElement | null
} | null>(null)
// State for dropdown visibility - one for each row
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
// Parse the current value to extract filters
const parseFilters = (filterValue: string | null): TagFilter[] => {
if (!filterValue) return []
try {
return JSON.parse(filterValue)
const parsed = JSON.parse(filterValue)
return parsed.map((f: TagFilter) => ({
...f,
fieldType: f.fieldType || 'text',
operator: f.operator || 'eq',
}))
} catch {
return []
}
@@ -86,20 +89,23 @@ export function KnowledgeTagFilters({
const currentValue = isPreview ? previewValue : storeValue
const filters = parseFilters(currentValue || null)
// Transform filters to table format for display
const rows: TagFilterRow[] =
filters.length > 0
? filters.map((filter) => ({
id: filter.id,
cells: {
tagName: filter.tagName || '',
tagSlot: filter.tagSlot,
fieldType: filter.fieldType || 'text',
operator: filter.operator || 'eq',
value: filter.tagValue || '',
valueTo: filter.valueTo,
},
}))
: [
{
id: 'empty-row-0',
cells: { tagName: '', value: '' },
cells: { tagName: '', fieldType: 'text', operator: '', value: '' },
},
]
@@ -109,27 +115,72 @@ export function KnowledgeTagFilters({
setStoreValue(value)
}
const handleCellChange = (rowIndex: number, column: string, value: string) => {
const rowsToFilters = (rowsToConvert: TagFilterRow[]): TagFilter[] => {
return rowsToConvert
.filter((row) => row.cells.tagName?.trim())
.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
operator: row.cells.operator || 'eq',
tagValue: row.cells.value || '',
valueTo: row.cells.valueTo,
}))
}
const handleCellChange = (rowIndex: number, column: string, value: string | FilterFieldType) => {
if (isPreview || disabled) return
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
const newCells = { ...row.cells, [column]: value }
if (column === 'fieldType') {
const operators = getOperatorsForFieldType(value as FilterFieldType)
newCells.operator = operators[0]?.value || 'eq'
newCells.value = ''
newCells.valueTo = undefined
}
if (column === 'operator' && value !== 'between') {
newCells.valueTo = undefined
}
return { ...row, cells: newCells }
}
return row
})
updateFilters(rowsToFilters(updatedRows))
}
const handleTagNameSelection = (rowIndex: number, tagName: string) => {
if (isPreview || disabled) return
const tagDef = tagDefinitions.find((t) => t.displayName === tagName)
const fieldType = (tagDef?.fieldType || 'text') as FilterFieldType
const operators = getOperatorsForFieldType(fieldType)
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
return {
...row,
cells: { ...row.cells, [column]: value },
cells: {
...row.cells,
tagName,
tagSlot: tagDef?.tagSlot,
fieldType,
operator: operators[0]?.value || 'eq',
value: '',
valueTo: undefined,
},
}
}
return row
})
// Convert back to TagFilter format - keep all rows, even empty ones
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
updateFilters(rowsToFilters(updatedRows))
}
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
@@ -145,36 +196,36 @@ export function KnowledgeTagFilters({
return row
})
// Convert back to TagFilter format - keep all rows, even empty ones
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null
const jsonValue =
rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null
emitTagSelection(jsonValue)
}
const handleAddRow = () => {
if (isPreview || disabled) return
const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
updateFilters(newFilters)
const newRowId = `filter-${filters.length}-${Math.random().toString(36).slice(2, 11)}`
const newFilter: TagFilter = {
id: newRowId,
tagName: '',
fieldType: 'text',
operator: 'eq',
tagValue: '',
}
updateFilters([...filters, newFilter])
}
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled || rows.length <= 1) return
if (isPreview || disabled) return
if (rows.length <= 1) {
// Clear the single row instead of deleting
setStoreValue(null)
return
}
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
updateFilters(rowsToFilters(updatedRows))
}
if (isPreview) {
@@ -191,108 +242,88 @@ export function KnowledgeTagFilters({
}
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
<thead className='bg-transparent'>
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
<th className='w-[35%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Tag
</th>
<th className='w-[35%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Operator
</th>
<th className='w-[30%] min-w-0 bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
)
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const showDropdown = dropdownStates[rowIndex] || false
const setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isLoading) {
if (!showDropdown) {
setShowDropdown(true)
}
}
}
const handleFocus = () => {
if (!disabled && !isLoading) {
setShowDropdown(true)
}
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowDropdown(false), 150)
}
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
value: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
}))
return (
<td className='relative border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
readOnly
disabled={disabled || isLoading}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleDropdownClick}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue || 'Select tag', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{showDropdown && tagDefinitions.length > 0 && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{tagDefinitions.map((tag) => (
<div
key={tag.id}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'tagName', tag.displayName)
setShowDropdown(false)
}}
>
<span className='flex-1 truncate'>{tag.displayName}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
<Combobox
options={tagOptions}
value={cellValue}
onChange={(value) => handleTagNameSelection(rowIndex, value)}
disabled={disabled || isLoading}
placeholder='Select tag'
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
/>
</td>
)
}
const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => {
const fieldType = row.cells.fieldType || 'text'
const operator = row.cells.operator || ''
const operators = getOperatorsForFieldType(fieldType)
const isOperatorDisabled = disabled || !row.cells.tagName
const operatorOptions: ComboboxOption[] = operators.map((op) => ({
value: op.value,
label: op.label,
}))
return (
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
<Combobox
options={operatorOptions}
value={operator}
onChange={(value) => handleCellChange(rowIndex, 'operator', value)}
disabled={isOperatorDisabled}
placeholder='Select operator'
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
/>
</td>
)
}
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.value || ''
const fieldType = row.cells.fieldType || 'text'
const operator = row.cells.operator || 'eq'
const isBetween = operator === 'between'
const valueTo = row.cells.valueTo || ''
const isDisabled = disabled || !row.cells.tagName
const placeholder = getPlaceholderForFieldType(fieldType)
return (
<td className='p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => {
const newValue = e.target.value
const cursorPosition = e.target.selectionStart ?? 0
const renderInput = (value: string, column: 'value' | 'valueTo') => (
<div className='relative w-full'>
<Input
value={value}
onChange={(e) => {
const newValue = e.target.value
const cursorPosition = e.target.selectionStart ?? 0
handleCellChange(rowIndex, 'value', newValue)
handleCellChange(rowIndex, column, newValue)
// Check for tag trigger
if (column === 'value') {
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
setActiveTagDropdown({
@@ -302,58 +333,78 @@ export function KnowledgeTagFilters({
activeSourceBlockId: null,
element: e.target,
})
}}
onFocus={(e) => {
if (!disabled) {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
}
}}
onFocus={(e) => {
if (!isDisabled && column === 'value') {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
if (column === 'value') {
setTimeout(() => setActiveTagDropdown(null), 200)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setActiveTagDropdown(null)
}
}}
disabled={disabled}
autoComplete='off'
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setActiveTagDropdown(null)
}
}}
disabled={isDisabled}
autoComplete='off'
placeholder={placeholder}
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</div>
)
if (isBetween) {
return (
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
<div className='flex items-center gap-1 px-[10px]'>
{renderInput(cellValue, 'value')}
<span className='flex-shrink-0 text-muted-foreground text-xs'>to</span>
{renderInput(valueTo, 'valueTo')}
</div>
</td>
)
}
return (
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
{renderInput(cellValue, 'value')}
</td>
)
}
const renderDeleteButton = (rowIndex: number) => {
const canDelete = !isPreview && !disabled
if (isPreview || disabled) return null
return canDelete ? (
return (
<td className='w-0 p-0'>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash className='h-4 w-4 text-muted-foreground' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</td>
) : null
)
}
if (isLoading) {
@@ -361,14 +412,18 @@ export function KnowledgeTagFilters({
}
return (
<div className='relative'>
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
<div className='relative w-full'>
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
<table className='w-full table-fixed bg-transparent'>
{renderHeader()}
<tbody>
<tbody className='bg-transparent'>
{rows.map((row, rowIndex) => (
<tr key={row.id} className='group relative border-t'>
<tr
key={row.id}
className='group relative border-[var(--border-strong)] border-t bg-transparent'
>
{renderTagNameCell(row, rowIndex)}
{renderOperatorCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
@@ -400,7 +455,7 @@ export function KnowledgeTagFilters({
{/* Add Filter Button */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<Button variant='outline' size='sm' onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Button onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Plus className='mr-1 h-2.5 w-2.5' />
Add Filter
</Button>

View File

@@ -982,6 +982,11 @@ export function ToolInput({
if (hasMultipleOperations(blockType)) {
return false
}
// Allow multiple instances for workflow and knowledge blocks
// Each instance can target a different workflow/knowledge base
if (blockType === 'workflow' || blockType === 'knowledge') {
return false
}
return selectedTools.some((tool) => tool.toolId === toolId)
}

View File

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

View File

@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthProvider } from '@/lib/oauth'
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -32,6 +33,7 @@ import {
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
@@ -90,7 +92,6 @@ const edgeTypes: EdgeTypes = {
/** ReactFlow configuration constants. */
const defaultEdgeOptions = { type: 'custom' }
const snapGrid: [number, number] = [20, 20]
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -158,6 +159,14 @@ const WorkflowContent = React.memo(() => {
// Training modal state
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
// Snap to grid settings
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
const snapToGrid = snapToGridSize > 0
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
)
// Handle copilot stream cleanup on page unload and component unmount
useStreamCleanup(copilotCleanup)
@@ -523,7 +532,7 @@ const WorkflowContent = React.memo(() => {
useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => {
const customEvent = event as CustomEvent<{ blockId: string }>
const { blockId } = customEvent.detail || ({} as any)
const blockId = customEvent.detail?.blockId
if (!blockId) return
try {
@@ -555,6 +564,7 @@ const WorkflowContent = React.memo(() => {
const candidates = Object.entries(blocks)
.filter(([id, block]) => {
if (!block.enabled) return false
if (block.type === 'response') return false
const node = nodeIndex.get(id)
if (!node) return false
@@ -601,6 +611,152 @@ const WorkflowContent = React.memo(() => {
return 'source'
}, [])
/** Creates a standardized edge object for workflow connections. */
const createEdgeObject = useCallback(
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
id: crypto.randomUUID(),
source: sourceId,
target: targetId,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}),
[]
)
/** Gets the appropriate start handle for a container node (loop or parallel). */
const getContainerStartHandle = useCallback(
(containerId: string): string => {
const containerNode = getNodes().find((n) => n.id === containerId)
return (containerNode?.data as SubflowNodeData)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
},
[getNodes]
)
/** Finds the closest non-response block to a position within a set of blocks. */
const findClosestBlockInSet = useCallback(
(
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
targetPosition: { x: number; y: number }
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
return candidateBlocks
.filter((b) => b.type !== 'response')
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
},
[]
)
/**
* Attempts to create an auto-connect edge for a new block being added.
* Returns the edge object if auto-connect should occur, or undefined otherwise.
*
* @param position - The position where the new block will be placed
* @param targetBlockId - The ID of the new block being added
* @param options - Configuration for auto-connect behavior
*/
const tryCreateAutoConnectEdge = useCallback(
(
position: { x: number; y: number },
targetBlockId: string,
options: {
blockType: string
enableTriggerMode?: boolean
targetParentId?: string | null
existingChildBlocks?: { id: string; type: string; position: { x: number; y: number } }[]
containerId?: string
}
): Edge | undefined => {
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (!isAutoConnectEnabled) return undefined
// Don't auto-connect starter or annotation-only blocks
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
return undefined
}
// Check if target is a trigger block
const targetBlockConfig = getBlock(options.blockType)
const isTargetTrigger =
options.enableTriggerMode || targetBlockConfig?.category === 'triggers'
if (isTargetTrigger) return undefined
// Case 1: Adding block inside a container with existing children
if (options.existingChildBlocks && options.existingChildBlocks.length > 0) {
const closestBlock = findClosestBlockInSet(options.existingChildBlocks, position)
if (closestBlock) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
}
return undefined
}
// Case 2: Adding block inside an empty container - connect from container start
if (
options.containerId &&
(!options.existingChildBlocks || options.existingChildBlocks.length === 0)
) {
const startHandle = getContainerStartHandle(options.containerId)
return createEdgeObject(options.containerId, targetBlockId, startHandle)
}
// Case 3: Adding block at root level - use findClosestOutput
const closestBlock = findClosestOutput(position)
if (!closestBlock) return undefined
// Don't create cross-container edges
const closestBlockParentId = blocks[closestBlock.id]?.data?.parentId
if (closestBlockParentId && !options.targetParentId) {
return undefined
}
const sourceHandle = determineSourceHandle(closestBlock)
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
},
[
blocks,
findClosestOutput,
determineSourceHandle,
createEdgeObject,
getContainerStartHandle,
findClosestBlockInSet,
]
)
/**
* Checks if adding a trigger block would violate constraints and shows notification if so.
* @returns true if validation failed (caller should return early), false if ok to proceed
*/
const checkTriggerConstraints = useCallback(
(blockType: string): boolean => {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (issue) {
const message =
issue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return true
}
return false
},
[blocks, addNotification, activeWorkflowId]
)
/**
* Shared handler for drops of toolbar items onto the workflow canvas.
*
@@ -629,21 +785,10 @@ const WorkflowContent = React.memo(() => {
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle: determineSourceHandle(closestBlock),
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
blockType: data.type,
targetParentId: null,
})
addBlock(
id,
@@ -651,8 +796,8 @@ const WorkflowContent = React.memo(() => {
name,
position,
{
width: 500,
height: 300,
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
type: 'subflowNode',
},
undefined,
@@ -674,12 +819,7 @@ const WorkflowContent = React.memo(() => {
const id = crypto.randomUUID()
// Prefer semantic default names for triggers; then ensure unique numbering centrally
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
const baseName =
data.type === 'loop'
? 'Loop'
: data.type === 'parallel'
? 'Parallel'
: defaultTriggerNameDrop || blockConfig!.name
const baseName = defaultTriggerNameDrop || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
if (containerInfo) {
@@ -711,70 +851,18 @@ const WorkflowContent = React.memo(() => {
estimateBlockDimensions(data.type)
)
// Capture existing child blocks before adding the new one
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === containerInfo.loopId
)
// Capture existing child blocks for auto-connect
const existingChildBlocks = Object.values(blocks)
.filter((b) => b.data?.parentId === containerInfo.loopId)
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
// Auto-connect logic for blocks inside containers
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (
isAutoConnectEnabled &&
data.type !== 'starter' &&
!isAnnotationOnlyBlock(data.type)
) {
if (existingChildBlocks.length > 0) {
// Connect to the nearest existing child block within the container
const closestBlock = existingChildBlocks
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - relativePosition.x) ** 2 +
(b.position.y - relativePosition.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
if (closestBlock) {
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
} else {
// No existing children: connect from the container's start handle to the moved node
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
autoConnectEdge = {
id: crypto.randomUUID(),
source: containerInfo.loopId,
target: id,
sourceHandle: startSourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
blockType: data.type,
enableTriggerMode: data.enableTriggerMode,
targetParentId: containerInfo.loopId,
existingChildBlocks,
containerId: containerInfo.loopId,
})
// Add block with parent info AND autoConnectEdge (atomic operation)
addBlock(
@@ -796,49 +884,13 @@ const WorkflowContent = React.memo(() => {
resizeLoopNodesWrapper()
} else {
// Centralized trigger constraints
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type)
if (dropIssue) {
const message =
dropIssue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
if (checkTriggerConstraints(data.type)) return
// Regular auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (
isAutoConnectEnabled &&
data.type !== 'starter' &&
!isAnnotationOnlyBlock(data.type)
) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = getBlock(data.type)
const isTargetTrigger =
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
}
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
blockType: data.type,
enableTriggerMode: data.enableTriggerMode,
targetParentId: null,
})
// Regular canvas drop with auto-connect edge
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
@@ -861,14 +913,13 @@ const WorkflowContent = React.memo(() => {
},
[
blocks,
getNodes,
findClosestOutput,
determineSourceHandle,
isPointInLoopNode,
resizeLoopNodesWrapper,
addBlock,
addNotification,
activeWorkflowId,
tryCreateAutoConnectEdge,
checkTriggerConstraints,
]
)
@@ -885,44 +936,73 @@ const WorkflowContent = React.memo(() => {
if (!type) return
if (type === 'connectionBlock') return
// Calculate smart position - to the right of existing root-level blocks
const calculateSmartPosition = (): { x: number; y: number } => {
// Get all root-level blocks (no parentId)
const rootBlocks = Object.values(blocks).filter((b) => !b.data?.parentId)
if (rootBlocks.length === 0) {
// No blocks yet, use viewport center
return screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
}
// Find the rightmost block
let maxRight = Number.NEGATIVE_INFINITY
let rightmostBlockY = 0
for (const block of rootBlocks) {
const blockWidth =
block.type === 'loop' || block.type === 'parallel'
? block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
: BLOCK_DIMENSIONS.FIXED_WIDTH
const blockRight = block.position.x + blockWidth
if (blockRight > maxRight) {
maxRight = blockRight
rightmostBlockY = block.position.y
}
}
// Position to the right with autolayout spacing
const position = {
x: maxRight + DEFAULT_HORIZONTAL_SPACING,
y: rightmostBlockY,
}
// Ensure position doesn't overlap any container
let container = isPointInLoopNode(position)
while (container) {
position.x =
container.loopPosition.x + container.dimensions.width + DEFAULT_HORIZONTAL_SPACING
container = isPointInLoopNode(position)
}
return position
}
const basePosition = calculateSmartPosition()
// Special handling for container nodes (loop or parallel)
if (type === 'loop' || type === 'parallel') {
const id = crypto.randomUUID()
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const centerPosition = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
targetParentId: null,
})
// Auto-connect logic for container nodes
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(centerPosition)
if (closestBlock) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
// Add the container node with default dimensions and auto-connect edge
addBlock(
id,
type,
name,
centerPosition,
basePosition,
{
width: 500,
height: 300,
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
type: 'subflowNode',
},
undefined,
@@ -939,11 +1019,8 @@ const WorkflowContent = React.memo(() => {
return
}
// Calculate the center position of the viewport
const centerPosition = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
// Check trigger constraints first
if (checkTriggerConstraints(type)) return
// Create a new block with a unique ID
const id = crypto.randomUUID()
@@ -952,51 +1029,11 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
const closestBlock = findClosestOutput(centerPosition)
logger.info('Closest block found:', closestBlock)
if (closestBlock) {
// Don't create edges into trigger blocks or annotation blocks
const targetBlockConfig = blockConfig
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
if (!isTargetTrigger) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
logger.info('Auto-connect edge created:', autoConnectEdge)
} else {
logger.info('Skipping auto-connect into trigger block', {
target: type,
})
}
}
}
// Centralized trigger constraints
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
if (additionIssue) {
const message =
additionIssue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
workflowId: activeWorkflowId || undefined,
})
return
}
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
enableTriggerMode,
targetParentId: null,
})
// Add the block to the workflow with auto-connect edge
// Enable trigger mode if this is a trigger-capable block from the triggers tab
@@ -1004,7 +1041,7 @@ const WorkflowContent = React.memo(() => {
id,
type,
name,
centerPosition,
basePosition,
undefined,
undefined,
undefined,
@@ -1025,11 +1062,12 @@ const WorkflowContent = React.memo(() => {
screenToFlowPosition,
blocks,
addBlock,
findClosestOutput,
determineSourceHandle,
tryCreateAutoConnectEdge,
isPointInLoopNode,
effectivePermissions.canEdit,
addNotification,
activeWorkflowId,
checkTriggerConstraints,
])
/**
@@ -1220,12 +1258,12 @@ const WorkflowContent = React.memo(() => {
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'loop'
(containerNode.data as SubflowNodeData)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'parallel'
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
@@ -1424,8 +1462,8 @@ const WorkflowContent = React.memo(() => {
data: {
...block.data,
name: block.name,
width: block.data?.width || 500,
height: block.data?.height || 300,
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
kind: block.type === 'loop' ? 'loop' : 'parallel',
},
})
@@ -1484,8 +1522,8 @@ const WorkflowContent = React.memo(() => {
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
width: 250, // Standard width for both block types
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
})
})
@@ -1572,7 +1610,7 @@ const WorkflowContent = React.memo(() => {
/**
* Effect to resize loops when nodes change (add/remove/position change).
* Runs on structural changes only - not during drag (position-only changes).
* Skips during loading to avoid unnecessary work.
* Skips during loading.
*/
useEffect(() => {
// Skip during initial render when nodes aren't loaded yet or workflow not ready
@@ -1794,12 +1832,15 @@ const WorkflowContent = React.memo(() => {
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
// Get dimensions based on node type (must match actual rendered dimensions)
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
const nodeWidth =
node.type === 'subflowNode'
? node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
: BLOCK_DIMENSIONS.FIXED_WIDTH
const nodeHeight =
node.type === 'subflowNode'
? node.data?.height || 300
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
// Check intersection using absolute coordinates
const nodeRect = {
@@ -1811,9 +1852,10 @@ const WorkflowContent = React.memo(() => {
const containerRect = {
left: containerAbsolutePos.x,
right: containerAbsolutePos.x + (n.data?.width || 500),
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
top: containerAbsolutePos.y,
bottom: containerAbsolutePos.y + (n.data?.height || 300),
bottom:
containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
}
// Check intersection with absolute coordinates for accurate detection
@@ -1829,7 +1871,9 @@ const WorkflowContent = React.memo(() => {
container: n,
depth: getNodeDepth(n.id),
// Calculate size for secondary sorting
size: (n.data?.width || 500) * (n.data?.height || 300),
size:
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
}))
// Update potential parent if there's at least one intersecting container node
@@ -1857,12 +1901,12 @@ const WorkflowContent = React.memo(() => {
// Apply appropriate class based on container type
if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'loop'
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'parallel'
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
@@ -2034,62 +2078,19 @@ const WorkflowContent = React.memo(() => {
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
}
// Prepare edges that will be added when moving into the container
const edgesToAdd: any[] = []
// Auto-connect when moving an existing block into a container
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
// Don't auto-connect annotation blocks (like note blocks)
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
// Existing children in the target container (excluding the moved node)
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
)
const existingChildBlocks = Object.values(blocks)
.filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
if (existingChildBlocks.length > 0) {
// Connect from nearest existing child inside the container
const closestBlock = existingChildBlocks
.map((b) => ({
block: b,
distance: Math.sqrt(
(b.position.x - relativePositionBefore.x) ** 2 +
(b.position.y - relativePositionBefore.y) ** 2
),
}))
.sort((a, b) => a.distance - b.distance)[0]?.block
const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
blockType: node.data?.type || '',
targetParentId: potentialParentId,
existingChildBlocks,
containerId: potentialParentId,
})
if (closestBlock) {
const sourceHandle = determineSourceHandle({
id: closestBlock.id,
type: closestBlock.type,
})
edgesToAdd.push({
id: crypto.randomUUID(),
source: closestBlock.id,
target: node.id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
} else {
// No children: connect from the container's start handle to the moved node
const containerNode = getNodes().find((n) => n.id === potentialParentId)
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
edgesToAdd.push({
id: crypto.randomUUID(),
source: potentialParentId,
target: node.id,
sourceHandle: startSourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
// Skip recording these edges separately since they're part of the parent update
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
@@ -2114,7 +2115,7 @@ const WorkflowContent = React.memo(() => {
updateNodeParent,
collaborativeUpdateBlockPosition,
addEdge,
determineSourceHandle,
tryCreateAutoConnectEdge,
blocks,
edgesForDisplay,
removeEdgesForNode,
@@ -2317,7 +2318,7 @@ const WorkflowContent = React.memo(() => {
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
snapToGrid={false}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}

View File

@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
Enter a name for your API key to help you identify it later.
</p>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<EmcnInput
value={newKeyName}
onChange={(e) => {
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
name='api_key_label'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>

View File

@@ -12,6 +12,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Slider,
Switch,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
@@ -76,6 +77,9 @@ export function General({ onOpenChange }: GeneralProps) {
const [uploadError, setUploadError] = useState<string | null>(null)
const [localSnapValue, setLocalSnapValue] = useState<number | null>(null)
const snapToGridValue = localSnapValue ?? settings?.snapToGridSize ?? 0
useEffect(() => {
if (profile?.name) {
setName(profile.name)
@@ -234,6 +238,18 @@ export function General({ onOpenChange }: GeneralProps) {
}
}
const handleSnapToGridChange = (value: number[]) => {
setLocalSnapValue(value[0])
}
const handleSnapToGridCommit = async (value: number[]) => {
const newValue = value[0]
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
}
setLocalSnapValue(null)
}
const handleTrainingControlsChange = async (checked: boolean) => {
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
@@ -393,7 +409,6 @@ export function General({ onOpenChange }: GeneralProps) {
dropdownWidth={140}
value={settings?.theme}
onChange={handleThemeChange}
disabled={updateSetting.isPending}
placeholder='Select theme'
options={[
{ label: 'System', value: 'system' },
@@ -410,17 +425,34 @@ export function General({ onOpenChange }: GeneralProps) {
id='auto-connect'
checked={settings?.autoConnect ?? true}
onCheckedChange={handleAutoConnectChange}
disabled={updateSetting.isPending}
/>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
<div className='flex items-center gap-[12px]'>
<span className='w-[32px] text-right text-[12px] text-[var(--text-tertiary)]'>
{snapToGridValue === 0 ? 'Off' : `${snapToGridValue}px`}
</span>
<Slider
id='snap-to-grid'
value={[snapToGridValue]}
onValueChange={handleSnapToGridChange}
onValueCommit={handleSnapToGridCommit}
min={0}
max={50}
step={1}
className='w-[100px]'
/>
</div>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='error-notifications'>Run error notifications</Label>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}
onCheckedChange={handleErrorNotificationsChange}
disabled={updateSetting.isPending}
/>
</div>
@@ -430,7 +462,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='telemetry'
checked={settings?.telemetryEnabled ?? true}
onCheckedChange={handleTelemetryToggle}
disabled={updateSetting.isPending}
/>
</div>
@@ -446,7 +477,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='training-controls'
checked={settings?.showTrainingControls ?? false}
onCheckedChange={handleTrainingControlsChange}
disabled={updateSetting.isPending}
/>
</div>
)}
@@ -458,7 +488,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? true}
onCheckedChange={handleSuperUserModeToggle}
disabled={updateSetting.isPending}
/>
</div>
)}
@@ -534,6 +563,15 @@ function GeneralSkeleton() {
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Snap to grid row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-24' />
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-3 w-[32px]' />
<Skeleton className='h-[6px] w-[100px] rounded-[20px]' />
</div>
</div>
{/* Error notifications row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-40' />

View File

@@ -2,7 +2,15 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { useSession, useSubscription } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
import { cn } from '@/lib/core/utils/cn'
@@ -68,7 +76,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
if (subscriptionStatus.isTeam && activeOrgId) {
referenceId = activeOrgId
// Get subscription ID for team/enterprise
subscriptionId = subData?.data?.id
}
@@ -132,14 +139,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
referenceId = activeOrgId
subscriptionId = subData?.data?.id
} else {
// For personal subscriptions, use user ID and let better-auth find the subscription
referenceId = session.user.id
subscriptionId = undefined
}
logger.info('Restoring subscription', { referenceId, subscriptionId })
// Build restore params - only include subscriptionId if we have one (team/enterprise)
const restoreParams: any = { referenceId }
if (subscriptionId) {
restoreParams.subscriptionId = subscriptionId
@@ -150,7 +155,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
logger.info('Subscription restored successfully', result)
}
// Invalidate queries to refresh data
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
if (activeOrgId) {
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) })
@@ -175,10 +179,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
if (!date) return 'end of current billing period'
try {
// Ensure we have a valid Date object
const dateObj = date instanceof Date ? date : new Date(date)
// Check if the date is valid
if (Number.isNaN(dateObj.getTime())) {
return 'end of current billing period'
}
@@ -196,20 +198,17 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const periodEndDate = getPeriodEndDate()
// Check if subscription is set to cancel at period end
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
return (
<>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-[13px]'>
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
</span>
<div className='flex flex-col gap-[2px]'>
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
{isCancelAtPeriodEnd && (
<p className='mt-1 text-[var(--text-muted)] text-xs'>
<span className='text-[12px] text-[var(--text-muted)]'>
You'll keep access until {formatDate(periodEndDate)}
</p>
</span>
)}
</div>
<Button
@@ -217,7 +216,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
className={cn(
'h-8 rounded-[8px] font-medium text-xs',
'h-8 rounded-[8px] text-[13px]',
error && 'border-[var(--text-error)] text-[var(--text-error)]'
)}
>
@@ -231,7 +230,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
<p className='text-[12px] text-[var(--text-muted)]'>
{isCancelAtPeriodEnd
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
@@ -244,8 +243,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
{!isCancelAtPeriodEnd && (
<div className='mt-3'>
<div className='rounded-[8px] bg-[var(--surface-3)] p-3 text-sm'>
<ul className='space-y-1 text-[var(--text-muted)] text-xs'>
<div className='rounded-[8px] bg-[var(--surface-5)] p-3'>
<ul className='space-y-1 text-[12px] text-[var(--text-muted)]'>
<li>• Keep all features until {formatDate(periodEndDate)}</li>
<li>• No more charges</li>
<li>• Data preserved</li>

View File

@@ -4,7 +4,9 @@ import { useState } from 'react'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalClose,
ModalContent,
ModalFooter,
@@ -90,7 +92,6 @@ export function CreditBalance({
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (open) {
// Generate new requestId when modal opens - same ID used for entire session
setRequestId(crypto.randomUUID())
} else {
setAmount('')
@@ -102,72 +103,66 @@ export function CreditBalance({
return (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>Credit Balance</span>
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
<div className='flex items-center gap-[8px]'>
<Label>Credit Balance</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
{isLoading ? '...' : `$${balance.toFixed(2)}`}
</span>
</div>
{canPurchase && (
<Modal open={isOpen} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant='outline'>Add Credits</Button>
<Button variant='outline' className='h-8 rounded-[8px] text-[13px]'>
Add Credits
</Button>
</ModalTrigger>
<ModalContent>
<ModalContent size='sm'>
<ModalHeader>Add Credits</ModalHeader>
<div className='px-4'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Credits are used before overage charges. Min $10, max $1,000.
</p>
</div>
{success ? (
<div className='py-4 text-center'>
<p className='text-[14px] text-[var(--text-primary)]'>
<ModalBody>
{success ? (
<p className='text-center text-[13px] text-[var(--text-primary)]'>
Credits added successfully!
</p>
</div>
) : (
<div className='flex flex-col gap-3 py-2'>
<div className='flex flex-col gap-1'>
<label
htmlFor='credit-amount'
className='text-[12px] text-[var(--text-secondary)]'
>
Amount (USD)
</label>
<div className='relative'>
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[var(--text-secondary)]'>
$
</span>
<Input
id='credit-amount'
type='text'
inputMode='numeric'
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder='50'
className='pl-7'
disabled={isPurchasing}
/>
</div>
{error && <span className='text-[11px] text-red-500'>{error}</span>}
</div>
<div className='rounded-[4px] bg-[var(--surface-5)] p-2'>
<p className='text-[11px] text-[var(--text-tertiary)]'>
Credits are non-refundable and don't expire. They'll be applied automatically to
your {entityType === 'organization' ? 'team' : ''} usage.
) : (
<>
<p className='text-[12px] text-[var(--text-muted)]'>
Credits are used before overage charges. Min $10, max $1,000.
</p>
</div>
</div>
)}
<div className='mt-4 flex flex-col gap-[4px]'>
<Label htmlFor='credit-amount'>Amount (USD)</Label>
<div className='relative'>
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[13px] text-[var(--text-secondary)]'>
$
</span>
<Input
id='credit-amount'
type='text'
inputMode='numeric'
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder='50'
className='pl-7'
disabled={isPurchasing}
/>
</div>
{error && <span className='text-[12px] text-[var(--text-error)]'>{error}</span>}
</div>
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
<p className='text-[12px] text-[var(--text-muted)]'>
Credits are non-refundable and don't expire. They'll be applied automatically
to your {entityType === 'organization' ? 'team' : ''} usage.
</p>
</div>
</>
)}
</ModalBody>
{!success && (
<ModalFooter>
<ModalClose asChild>
<Button variant='ghost' disabled={isPurchasing}>
Cancel
</Button>
<Button disabled={isPurchasing}>Cancel</Button>
</ModalClose>
<Button
variant='primary'

View File

@@ -45,9 +45,9 @@ export function PlanCard({
if (typeof price === 'string') {
return (
<>
<span className='font-semibold text-xl'>{price}</span>
<span className='font-semibold text-[20px]'>{price}</span>
{priceSubtext && (
<span className='ml-1 text-[var(--text-muted)] text-xs'>{priceSubtext}</span>
<span className='ml-1 text-[12px] text-[var(--text-muted)]'>{priceSubtext}</span>
)}
</>
)
@@ -58,13 +58,13 @@ export function PlanCard({
const renderFeatures = () => {
if (isHorizontal) {
return (
<div className='mt-3 flex flex-wrap items-center gap-4'>
<div className='mt-3 flex flex-wrap items-center gap-3'>
{features.map((feature, index) => (
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-muted)]' />
<span className='text-[var(--text-muted)]'>{feature.text}</span>
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-[12px]'>
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]' />
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
{index < features.length - 1 && (
<div className='ml-4 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
<div className='ml-3 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
)}
</div>
))}
@@ -75,12 +75,12 @@ export function PlanCard({
return (
<ul className='mb-4 flex-1 space-y-2'>
{features.map((feature, index) => (
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-xs'>
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-[12px]'>
<feature.icon
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-muted)]'
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]'
aria-hidden='true'
/>
<span className='text-[var(--text-muted)]'>{feature.text}</span>
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
</li>
))}
</ul>
@@ -91,24 +91,24 @@ export function PlanCard({
<article
className={cn(
'relative flex rounded-[8px] border p-4 transition-colors hover:border-[var(--border-hover)]',
isHorizontal ? 'flex-row items-center justify-between' : 'flex-col',
isHorizontal ? 'flex-row items-center justify-between gap-6' : 'flex-col',
className
)}
>
<header className={isHorizontal ? undefined : 'mb-4'}>
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
<header className={isHorizontal ? 'flex-1' : 'mb-4'}>
<h3 className='mb-2 font-semibold text-[14px]'>{name}</h3>
<div className='flex items-baseline'>{renderPrice()}</div>
{isHorizontal && renderFeatures()}
</header>
{!isHorizontal && renderFeatures()}
<div className={isHorizontal ? 'ml-auto' : undefined}>
<div className={isHorizontal ? 'flex-shrink-0' : undefined}>
<Button
onClick={onButtonClick}
className={cn(
'h-9 rounded-[8px] text-xs',
isHorizontal ? 'px-4' : 'w-full',
'h-9 rounded-[8px] text-[13px]',
isHorizontal ? 'min-w-[100px] px-6' : 'w-full',
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
)}
variant='outline'

View File

@@ -1,17 +1,17 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Switch } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
Label,
Popover,
PopoverContent,
PopoverItem,
PopoverSection,
PopoverTrigger,
Switch,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
import { cn } from '@/lib/core/utils/cn'
@@ -270,7 +270,6 @@ export function Subscription() {
}
)
// UI state computed values
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
@@ -333,7 +332,7 @@ export function Subscription() {
<PlanCard
key='enterprise'
name='Enterprise'
price={<span className='font-semibold text-xl'>Custom</span>}
price={<span className='font-semibold text-[20px]'>Custom</span>}
priceSubtext={
layout === 'horizontal'
? 'Custom solutions tailored to your enterprise needs'
@@ -458,7 +457,7 @@ export function Subscription() {
{/* Enterprise Usage Limit Notice */}
{subscription.isEnterprise && (
<div className='text-center'>
<p className='text-[var(--text-muted)] text-xs'>
<p className='text-[12px] text-[var(--text-muted)]'>
Contact enterprise for support usage limit changes
</p>
</div>
@@ -467,7 +466,7 @@ export function Subscription() {
{/* Team Member Notice */}
{permissions.showTeamMemberView && (
<div className='text-center'>
<p className='text-[var(--text-muted)] text-xs'>
<p className='text-[12px] text-[var(--text-muted)]'>
Contact your team admin to increase limits
</p>
</div>
@@ -534,72 +533,78 @@ export function Subscription() {
{/* Next Billing Date */}
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px]'>Next Billing Date</span>
<span className='text-[13px] text-[var(--text-muted)]'>
<Label>Next Billing Date</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
</span>
</div>
)}
{/* Billing usage notifications toggle */}
{/* Usage notifications */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{/* Cancel Subscription */}
{permissions.canCancelSubscription && (
<div className='mt-[8px]'>
<CancelSubscription
subscription={{
plan: subscription.plan,
status: subscription.status,
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.data?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
}}
/>
</div>
<CancelSubscription
subscription={{
plan: subscription.plan,
status: subscription.status,
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.data?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
}}
/>
)}
{/* Workspace API Billing Settings */}
{/* Billed Account for Workspace */}
{canManageWorkspaceKeys && (
<div className='mt-[24px] flex items-center justify-between'>
<span className='font-medium text-[13px]'>Billed Account for Workspace</span>
<div className='flex items-center justify-between'>
<Label>Billed Account for Workspace</Label>
{isWorkspaceLoading ? (
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
) : workspaceAdmins.length === 0 ? (
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[var(--text-muted)] text-xs'>
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[12px] text-[var(--text-muted)]'>
No admin members available
</div>
) : (
<Select
value={billedAccountUserId ?? ''}
onValueChange={async (value) => {
if (value === billedAccountUserId) return
try {
await updateWorkspaceSettings({ billedAccountUserId: value })
} catch (error) {
// Error is already logged in updateWorkspaceSettings
}
}}
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
>
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
<SelectValue placeholder='Select admin' />
</SelectTrigger>
<SelectContent align='start' className='z-[10000050]'>
<SelectGroup>
<SelectLabel className='px-3 py-1 text-[11px] text-[var(--text-muted)] uppercase'>
Workspace admins
</SelectLabel>
{workspaceAdmins.map((admin: any) => (
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
{admin.email}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<button
className='flex h-8 w-[200px] items-center justify-between gap-2 rounded-[6px] border border-[var(--border)] bg-transparent px-3 text-left text-[13px] transition-colors hover:bg-[var(--surface-3)] disabled:pointer-events-none disabled:opacity-50'
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{billedAccountUserId
? workspaceAdmins.find((admin: any) => admin.userId === billedAccountUserId)
?.email || 'Select admin'
: 'Select admin'}
</span>
<ChevronDown className='h-3 w-3 shrink-0 text-[var(--text-secondary)]' />
</button>
</PopoverTrigger>
<PopoverContent align='end' minWidth={200} border>
<PopoverSection>Workspace admins</PopoverSection>
{workspaceAdmins.map((admin: any) => (
<PopoverItem
key={admin.userId}
active={billedAccountUserId === admin.userId}
showCheck
onClick={async () => {
if (admin.userId === billedAccountUserId) return
try {
await updateWorkspaceSettings({ billedAccountUserId: admin.userId })
} catch (error) {
// Error is already logged in updateWorkspaceSettings
}
}}
>
<span className='flex-1 truncate'>{admin.email}</span>
</PopoverItem>
))}
</PopoverContent>
</Popover>
)}
</div>
)}
@@ -614,11 +619,14 @@ function BillingUsageNotificationsToggle() {
return (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<span className='font-medium text-[13px]'>Usage notifications</span>
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>
<div className='flex flex-col gap-[2px]'>
<Label htmlFor='usage-notifications'>Usage notifications</Label>
<span className='text-[12px] text-[var(--text-muted)]'>
Email me when I reach 80% usage
</span>
</div>
<Switch
id='usage-notifications'
checked={!!enabled}
disabled={isLoading}
onCheckedChange={(v: boolean) => {

View File

@@ -141,12 +141,37 @@ export function MemberInvitationCard({
{/* Main invitation input */}
<div className='flex items-start gap-2'>
<div className='flex-1'>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<Input
placeholder='Enter email address'
value={inviteEmail}
onChange={handleEmailChange}
disabled={isInviting || !hasAvailableSeats}
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
name='member_invite_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
{emailError && (
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>

View File

@@ -55,16 +55,31 @@ export function NoOrganizationView({
{/* Form fields - clean layout without card */}
<div className='space-y-4'>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<div>
<Label htmlFor='orgName' className='font-medium text-[13px]'>
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
Team Name
</Label>
<Input
id='orgName'
id='team-name-field'
value={orgName}
onChange={onOrgNameChange}
placeholder='My Team'
className='mt-1'
name='team_name_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
@@ -116,31 +131,52 @@ export function NoOrganizationView({
</ModalHeader>
<div className='space-y-4'>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<div>
<Label htmlFor='org-name' className='font-medium text-[13px]'>
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
Organization Name
</Label>
<Input
id='org-name'
id='org-name-field'
placeholder='Enter organization name'
value={orgName}
onChange={onOrgNameChange}
disabled={isCreatingOrg}
className='mt-1'
name='org_name_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
<div>
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
Organization Slug
</Label>
<Input
id='org-slug'
id='org-slug-field'
placeholder='organization-slug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
disabled={isCreatingOrg}
className='mt-1'
name='org_slug_field'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
</div>

View File

@@ -154,7 +154,7 @@ export function TeamMembers({
<div className='space-y-4'>
{teamItems.map((item) => (
<div key={item.id} className='flex items-center justify-between'>
{/* Member info */}
{/* Left section: Avatar + Name/Role + Action buttons */}
<div className='flex flex-1 items-center gap-3'>
{/* Avatar */}
<UserAvatar
@@ -165,7 +165,7 @@ export function TeamMembers({
/>
{/* Name and email */}
<div className='min-w-0 flex-1'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='truncate font-medium text-sm'>{item.name}</span>
{item.type === 'member' && (
@@ -188,51 +188,50 @@ export function TeamMembers({
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
</div>
{/* Usage stats - matching subscription layout */}
{/* Action buttons */}
{isAdminOrOwner && (
<div className='hidden items-center text-xs tabular-nums sm:flex'>
<div className='text-center'>
<div className='text-[var(--text-muted)]'>Usage</div>
<div className='font-medium'>
{isLoadingUsage && item.type === 'member' ? (
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
) : (
item.usage
)}
</div>
</div>
<>
{/* Admin/Owner can remove other members */}
{item.type === 'member' &&
item.role !== 'owner' &&
item.email !== currentUserEmail && (
<Button
variant='ghost'
onClick={() => onRemoveMember(item.member)}
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
Remove
</Button>
)}
{/* Admin can cancel invitations */}
{item.type === 'invitation' && (
<Button
variant='ghost'
onClick={() => handleCancelInvitation(item.invitation.id)}
disabled={cancellingInvitations.has(item.invitation.id)}
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
</Button>
)}
</>
)}
</div>
{/* Right section: Usage column (right-aligned) */}
{isAdminOrOwner && (
<div className='ml-4 flex flex-col items-end'>
<div className='text-[var(--text-muted)] text-xs'>Usage</div>
<div className='font-medium text-xs tabular-nums'>
{isLoadingUsage && item.type === 'member' ? (
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
) : (
item.usage
)}
</div>
)}
</div>
{/* Actions */}
<div className='ml-4 flex gap-1'>
{/* Admin/Owner can remove other members */}
{isAdminOrOwner &&
item.type === 'member' &&
item.role !== 'owner' &&
item.email !== currentUserEmail && (
<Button
variant='ghost'
onClick={() => onRemoveMember(item.member)}
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
Remove
</Button>
)}
{/* Admin can cancel invitations */}
{isAdminOrOwner && item.type === 'invitation' && (
<Button
variant='ghost'
onClick={() => handleCancelInvitation(item.invitation.id)}
disabled={cancellingInvitations.has(item.invitation.id)}
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
</Button>
)}
</div>
</div>
)}
</div>
))}
</div>

View File

@@ -5,11 +5,10 @@ import {
type ComboboxOption,
Label,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Tooltip,
} from '@/components/emcn'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
@@ -55,50 +54,53 @@ export function TeamSeats({
const totalMonthlyCost = selectedSeats * costPerSeat
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
const handleConfirm = async () => {
await onConfirm(selectedSeats)
}
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
value: num.toString(),
label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`,
label: `${num} ${num === 1 ? 'seat' : 'seats'}`,
}))
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>
<ModalTitle>{title}</ModalTitle>
<ModalDescription>{description}</ModalDescription>
</ModalHeader>
<ModalContent size='sm'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
<div className='py-4'>
<Label htmlFor='seats'>Number of seats</Label>
<Combobox
options={seatOptions}
value={selectedSeats.toString()}
onChange={(value) => setSelectedSeats(Number.parseInt(value))}
placeholder='Select number of seats'
/>
<div className='mt-4 flex flex-col gap-[4px]'>
<Label htmlFor='seats'>Number of seats</Label>
<Combobox
options={seatOptions}
value={selectedSeats > 0 ? selectedSeats.toString() : ''}
onChange={(value) => {
const num = Number.parseInt(value, 10)
if (!Number.isNaN(num) && num > 0) {
setSelectedSeats(num)
}
}}
placeholder='Select or enter number of seats'
editable
disabled={isLoading}
/>
</div>
<p className='mt-2 text-[var(--text-muted)] text-sm'>
<p className='mt-3 text-[12px] text-[var(--text-muted)]'>
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
total of ${totalMonthlyCost} inference credits per month.
</p>
{showCostBreakdown && currentSeats !== undefined && (
<div className='mt-3 rounded-[8px] bg-[var(--surface-3)] p-3'>
<div className='flex justify-between text-sm'>
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
<div className='flex justify-between text-[12px]'>
<span className='text-[var(--text-muted)]'>Current seats:</span>
<span>{currentSeats}</span>
<span className='text-[var(--text-primary)]'>{currentSeats}</span>
</div>
<div className='flex justify-between text-sm'>
<div className='mt-2 flex justify-between text-[12px]'>
<span className='text-[var(--text-muted)]'>New seats:</span>
<span>{selectedSeats}</span>
<span className='text-[var(--text-primary)]'>{selectedSeats}</span>
</div>
<div className='mt-2 flex justify-between border-t pt-2 font-medium text-sm'>
<span className='text-[var(--text-muted)]'>Monthly cost change:</span>
<span>
<div className='mt-3 flex justify-between border-[var(--border)] border-t pt-3 text-[12px]'>
<span className='font-medium text-[var(--text-primary)]'>Monthly cost change:</span>
<span className='font-medium text-[var(--text-primary)]'>
{costChange > 0 ? '+' : ''}${costChange}
</span>
</div>
@@ -106,19 +108,14 @@ export function TeamSeats({
)}
{error && (
<p className='mt-3 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
<p className='mt-3 text-[12px] text-[var(--text-error)]'>
{error instanceof Error && error.message ? error.message : String(error)}
</p>
)}
</div>
</ModalBody>
<ModalFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isLoading}
className='h-[32px] px-[12px]'
>
<Button onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
@@ -127,22 +124,15 @@ export function TeamSeats({
<span>
<Button
variant='primary'
onClick={handleConfirm}
onClick={() => onConfirm(selectedSeats)}
disabled={
isLoading ||
selectedSeats < 1 ||
(showCostBreakdown && selectedSeats === currentSeats) ||
isCancelledAtPeriodEnd
}
className='h-[32px] px-[12px]'
>
{isLoading ? (
<div className='flex items-center space-x-2'>
<div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' />
<span>Loading...</span>
</div>
) : (
<span>{confirmButtonText}</span>
)}
{isLoading ? 'Updating...' : confirmButtonText}
</Button>
</span>
</Tooltip.Trigger>

View File

@@ -390,11 +390,26 @@ export function TemplateProfile() {
disabled={isUploadingProfilePicture}
/>
</div>
{/* Hidden decoy field to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<Input
placeholder='Name'
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
className='h-9 flex-1'
name='profile_display_name'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
@@ -15,7 +15,11 @@ import {
useItemRename,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
import {
useCanDelete,
useDeleteFolder,
useDuplicateFolder,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/store'
@@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const createFolderMutation = useCreateFolder()
const userPermissions = useUserPermissionsContext()
const { canDeleteFolder } = useCanDelete({ workspaceId })
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
@@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
disableDuplicate={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDelete}
/>
{/* Delete Modal */}

View File

@@ -14,6 +14,7 @@ import {
useItemRename,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
useCanDelete,
useDeleteWorkflow,
useDuplicateWorkflow,
useExportWorkflow,
@@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
// Can delete check hook
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
// Presence avatars state
const [hasAvatars, setHasAvatars] = useState(false)
@@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
}
// Check if the captured selection can be deleted
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
// If already selected with multiple selections, keep all selections
handleContextMenuBase(e)
},
[workflow.id, workflows, handleContextMenuBase]
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
)
// Rename hook
@@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
disableRename={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit}
disableExport={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
/>
{/* Delete Confirmation Modal */}

View File

@@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
<ModalContent className='w-[500px]'>
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
<form ref={formRef} onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<form
ref={formRef}
onSubmit={handleSubmit}
className='flex min-h-0 flex-1 flex-col'
autoComplete='off'
>
<ModalBody>
<div className='space-y-[12px]'>
<div>
<Label
htmlFor='emails'
htmlFor='invite-field'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Email Addresses
</Label>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[6px] py-[4px] focus-within:outline-none dark:bg-[var(--surface-9)]'>
{invalidEmails.map((email, index) => (
<EmailTag
@@ -706,7 +738,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
/>
))}
<Input
id='emails'
id='invite-field'
name='invite_search_field'
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
@@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
)}
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
data-lpignore='true'
data-form-type='other'
aria-autocomplete='none'
/>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export { useCanDelete } from './use-can-delete'
export { useDeleteFolder } from './use-delete-folder'
export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'

View File

@@ -0,0 +1,130 @@
import { useCallback, useMemo } from 'react'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface UseCanDeleteProps {
/**
* Current workspace ID
*/
workspaceId: string
}
interface UseCanDeleteReturn {
/**
* Checks if the given workflow IDs can be deleted.
* Returns false if deleting them would leave no workflows in the workspace.
*/
canDeleteWorkflows: (workflowIds: string[]) => boolean
/**
* Checks if the given folder can be deleted.
* Returns false if deleting it would leave no workflows in the workspace.
*/
canDeleteFolder: (folderId: string) => boolean
/**
* Total number of workflows in the workspace.
*/
totalWorkflows: number
}
/**
* Hook for checking if workflows or folders can be deleted.
* Prevents deletion if it would leave the workspace with no workflows.
*
* Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls.
*
* @param props - Hook configuration
* @returns Functions to check deletion eligibility
*/
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
const { workflows } = useWorkflowRegistry()
const { folders } = useFolderStore()
/**
* Pre-computed data structures for efficient lookups
*/
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
useMemo(() => {
const workspaceWorkflows = Object.values(workflows).filter(
(w) => w.workspaceId === workspaceId
)
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
const byFolderId = new Map<string, number>()
for (const w of workspaceWorkflows) {
if (w.folderId) {
byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1)
}
}
const childrenByParent = new Map<string, string[]>()
for (const folder of Object.values(folders)) {
if (folder.workspaceId === workspaceId && folder.parentId) {
const children = childrenByParent.get(folder.parentId) || []
children.push(folder.id)
childrenByParent.set(folder.parentId, children)
}
}
return {
totalWorkflows: workspaceWorkflows.length,
workflowIdSet: idSet,
workflowsByFolderId: byFolderId,
childFoldersByParentId: childrenByParent,
}
}, [workflows, folders, workspaceId])
/**
* Count workflows in a folder and all its subfolders recursively.
* Uses pre-computed maps for efficient lookups.
*/
const countWorkflowsInFolder = useCallback(
(folderId: string): number => {
let count = workflowsByFolderId.get(folderId) || 0
const childFolders = childFoldersByParentId.get(folderId)
if (childFolders) {
for (const childId of childFolders) {
count += countWorkflowsInFolder(childId)
}
}
return count
},
[workflowsByFolderId, childFoldersByParentId]
)
/**
* Check if the given workflow IDs can be deleted.
* Returns false if deleting would remove all workflows from the workspace.
*/
const canDeleteWorkflows = useCallback(
(workflowIds: string[]): boolean => {
const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length
// Must have at least one workflow remaining after deletion
return totalWorkflows > 0 && workflowsToDelete < totalWorkflows
},
[totalWorkflows, workflowIdSet]
)
/**
* Check if the given folder can be deleted.
* Empty folders are always deletable. Folders containing all workspace workflows are not.
*/
const canDeleteFolder = useCallback(
(folderId: string): boolean => {
const workflowsInFolder = countWorkflowsInFolder(folderId)
if (workflowsInFolder === 0) return true
return workflowsInFolder < totalWorkflows
},
[totalWorkflows, countWorkflowsInFolder]
)
return {
canDeleteWorkflows,
canDeleteFolder,
totalWorkflows,
}
}

View File

@@ -209,11 +209,16 @@ async function runWorkflowExecution({
const mergedStates = mergeSubblockState(blocks)
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const personalEnvUserId = workflowRecord.userId
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
personalEnvUserId,
workflowRecord.workspaceId || undefined
workspaceId
)
const variables = EnvVarsSchema.parse({
@@ -232,7 +237,7 @@ async function runWorkflowExecution({
await loggingSession.safeStart({
userId: actorUserId,
workspaceId: workflowRecord.workspaceId || '',
workspaceId,
variables: variables || {},
deploymentVersionId,
})
@@ -241,7 +246,7 @@ async function runWorkflowExecution({
requestId,
executionId,
workflowId: payload.workflowId,
workspaceId: workflowRecord.workspaceId || '',
workspaceId,
userId: actorUserId,
sessionUserId: undefined,
workflowUserId: workflowRecord.userId,

View File

@@ -164,7 +164,10 @@ async function executeWebhookJobInternal(
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId || undefined
const workspaceId = wfRows[0]?.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
// Merge subblock states (matching workflow-execution pattern)
@@ -298,7 +301,7 @@ async function executeWebhookJobInternal(
// Start logging session so the complete call has a log entry to update
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: workspaceId || '',
workspaceId,
variables: {},
triggerData: {
isTest: payload.testMode === true,
@@ -356,7 +359,7 @@ async function executeWebhookJobInternal(
// Start logging session so the complete call has a log entry to update
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: workspaceId || '',
workspaceId,
variables: {},
triggerData: {
isTest: payload.testMode === true,
@@ -398,7 +401,7 @@ async function executeWebhookJobInternal(
if (triggerConfig.outputs) {
logger.debug(`[${requestId}] Processing trigger ${resolvedTriggerId} file outputs`)
const processedInput = await processTriggerFileOutputs(input, triggerConfig.outputs, {
workspaceId: workspaceId || '',
workspaceId,
workflowId: payload.workflowId,
executionId,
requestId,
@@ -431,7 +434,7 @@ async function executeWebhookJobInternal(
if (fileFields.length > 0 && typeof input === 'object' && input !== null) {
const executionContext = {
workspaceId: workspaceId || '',
workspaceId,
workflowId: payload.workflowId,
executionId,
}
@@ -542,9 +545,23 @@ async function executeWebhookJobInternal(
})
try {
const wfRow = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
const errorWorkspaceId = wfRow[0]?.workspaceId
if (!errorWorkspaceId) {
logger.warn(
`[${requestId}] Cannot log error: workflow ${payload.workflowId} has no workspace`
)
throw error
}
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: '', // May not be available for early errors
workspaceId: errorWorkspaceId,
variables: {},
triggerData: {
isTest: payload.testMode === true,

View File

@@ -59,7 +59,10 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
}
const actorUserId = preprocessResult.actorUserId!
const workspaceId = preprocessResult.workflowRecord?.workspaceId || undefined
const workspaceId = preprocessResult.workflowRecord?.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${workflowId} has no associated workspace`)
}
logger.info(`[${requestId}] Preprocessing passed. Using actor: ${actorUserId}`)

View File

@@ -57,7 +57,6 @@ export const KnowledgeBlock: BlockConfig = {
type: 'knowledge-tag-filters',
placeholder: 'Add tag filters',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'documentId',

View File

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

View File

@@ -10,7 +10,8 @@ export {
languages,
} from './code/code'
export { Combobox, type ComboboxOption } from './combobox/combobox'
export { Input } from './input/input'
export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker'
export { Input, type InputProps, inputVariants } from './input/input'
export { Label } from './label/label'
export {
MODAL_SIZES,
@@ -66,6 +67,7 @@ export {
SModalSidebarSectionTitle,
SModalTrigger,
} from './s-modal/s-modal'
export { Slider, type SliderProps } from './slider/slider'
export { Switch } from './switch/switch'
export { Textarea } from './textarea/textarea'
export { Tooltip } from './tooltip/tooltip'

View File

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

View File

@@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn'
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
*/
const POPOVER_ITEM_BASE_CLASSES =
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75'
/**
* Variant-specific active state styles for popover items.
@@ -247,6 +247,11 @@ export interface PopoverContentProps
* @default false
*/
border?: boolean
/**
* When true, the popover will flip to avoid collisions with viewport edges
* @default true
*/
avoidCollisions?: boolean
}
/**
@@ -279,6 +284,7 @@ const PopoverContent = React.forwardRef<
sideOffset,
collisionPadding = 8,
border = false,
avoidCollisions = true,
...restProps
},
ref
@@ -328,7 +334,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={effectiveSideOffset}
collisionPadding={collisionPadding}
avoidCollisions={true}
avoidCollisions={avoidCollisions}
sticky='partial'
onWheel={handleWheel}
{...restProps}
@@ -425,7 +431,10 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
* ```
*/
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => {
(
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
ref
) => {
// Try to get context - if not available, we're outside Popover (shouldn't happen)
const context = React.useContext(PopoverContext)
const variant = context?.variant || 'default'
@@ -435,18 +444,28 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
return null
}
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled) {
e.stopPropagation()
return
}
onClick?.(e)
}
return (
<div
className={cn(
POPOVER_ITEM_BASE_CLASSES,
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
!disabled &&
(active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant]),
disabled && 'cursor-default opacity-50',
className
)}
ref={ref}
role='menuitem'
aria-selected={active}
aria-disabled={disabled}
onClick={handleClick}
{...props}
>
{children}
@@ -707,8 +726,10 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
}
React.useEffect(() => {
setSearchQuery('')
onValueChange?.('')
inputRef.current?.focus()
}, [])
}, [setSearchQuery, onValueChange])
return (
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>

View File

@@ -0,0 +1,39 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/core/utils/cn'
export interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {}
/**
* EMCN Slider component built on Radix UI Slider primitive.
* Styled to match the Switch component with thin track design.
*
* @example
* ```tsx
* <Slider value={[50]} onValueChange={setValue} min={0} max={100} step={10} />
* ```
*/
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track className='relative h-[6px] w-full grow overflow-hidden rounded-[20px] bg-[var(--surface-12)] transition-colors'>
<SliderPrimitive.Range className='absolute h-full bg-[var(--surface-12)]' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='block h-[14px] w-[14px] cursor-pointer rounded-full bg-[var(--text-primary)] shadow-sm transition-colors focus-visible:outline-none' />
</SliderPrimitive.Root>
)
)
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,567 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { SerializedBlock, SerializedLoop, SerializedWorkflow } from '@/serializer/types'
import { EdgeConstructor } from './edges'
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
}))
function createMockBlock(id: string, type = 'function', config: any = {}): SerializedBlock {
return {
id,
metadata: { id: type, name: `Block ${id}` },
position: { x: 0, y: 0 },
config: { tool: type, params: config },
inputs: {},
outputs: {},
enabled: true,
}
}
function createMockNode(id: string): DAGNode {
return {
id,
block: createMockBlock(id),
outgoingEdges: new Map(),
incomingEdges: new Set(),
metadata: {},
}
}
function createMockDAG(nodeIds: string[]): DAG {
const nodes = new Map<string, DAGNode>()
for (const id of nodeIds) {
nodes.set(id, createMockNode(id))
}
return {
nodes,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
function createMockWorkflow(
blocks: SerializedBlock[],
connections: Array<{
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>,
loops: Record<string, SerializedLoop> = {},
parallels: Record<string, any> = {}
): SerializedWorkflow {
return {
version: '1',
blocks,
connections,
loops,
parallels,
}
}
describe('EdgeConstructor', () => {
let edgeConstructor: EdgeConstructor
beforeEach(() => {
edgeConstructor = new EdgeConstructor()
})
describe('Edge ID generation (bug fix verification)', () => {
it('should generate unique edge IDs for multiple edges to same target with different handles', () => {
const conditionId = 'condition-1'
const targetId = 'target-1'
const conditionBlock = createMockBlock(conditionId, 'condition', {
conditions: JSON.stringify([
{ id: 'if-id', label: 'if', condition: 'true' },
{ id: 'else-id', label: 'else', condition: '' },
]),
})
const workflow = createMockWorkflow(
[conditionBlock, createMockBlock(targetId)],
[
{ source: conditionId, target: targetId, sourceHandle: 'condition-if-id' },
{ source: conditionId, target: targetId, sourceHandle: 'condition-else-id' },
]
)
const dag = createMockDAG([conditionId, targetId])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([conditionId, targetId]),
new Map()
)
const conditionNode = dag.nodes.get(conditionId)!
// Should have 2 edges, not 1 (the bug was that they would overwrite each other)
expect(conditionNode.outgoingEdges.size).toBe(2)
// Verify edge IDs are unique and include the sourceHandle
const edgeIds = Array.from(conditionNode.outgoingEdges.keys())
expect(edgeIds).toContain(`${conditionId}${targetId}-condition-if-id`)
expect(edgeIds).toContain(`${conditionId}${targetId}-condition-else-id`)
})
it('should generate edge ID without handle suffix when no sourceHandle', () => {
const sourceId = 'source-1'
const targetId = 'target-1'
const workflow = createMockWorkflow(
[createMockBlock(sourceId), createMockBlock(targetId)],
[{ source: sourceId, target: targetId }]
)
const dag = createMockDAG([sourceId, targetId])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([sourceId, targetId]),
new Map()
)
const sourceNode = dag.nodes.get(sourceId)!
const edgeIds = Array.from(sourceNode.outgoingEdges.keys())
expect(edgeIds).toContain(`${sourceId}${targetId}`)
})
})
describe('Condition block edge wiring', () => {
it('should wire condition block edges with proper condition prefixes', () => {
const conditionId = 'condition-1'
const target1Id = 'target-1'
const target2Id = 'target-2'
const conditionBlock = createMockBlock(conditionId, 'condition', {
conditions: JSON.stringify([
{ id: 'cond-if', label: 'if', condition: 'x > 5' },
{ id: 'cond-else', label: 'else', condition: '' },
]),
})
const workflow = createMockWorkflow(
[conditionBlock, createMockBlock(target1Id), createMockBlock(target2Id)],
[
{ source: conditionId, target: target1Id, sourceHandle: 'condition-cond-if' },
{ source: conditionId, target: target2Id, sourceHandle: 'condition-cond-else' },
]
)
const dag = createMockDAG([conditionId, target1Id, target2Id])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([conditionId, target1Id, target2Id]),
new Map()
)
const conditionNode = dag.nodes.get(conditionId)!
expect(conditionNode.outgoingEdges.size).toBe(2)
// Verify edges have correct targets and handles
const edges = Array.from(conditionNode.outgoingEdges.values())
const ifEdge = edges.find((e) => e.sourceHandle === 'condition-cond-if')
const elseEdge = edges.find((e) => e.sourceHandle === 'condition-cond-else')
expect(ifEdge?.target).toBe(target1Id)
expect(elseEdge?.target).toBe(target2Id)
})
it('should handle condition block with if→A, elseif→B, else→A pattern', () => {
const conditionId = 'condition-1'
const targetAId = 'target-a'
const targetBId = 'target-b'
const conditionBlock = createMockBlock(conditionId, 'condition', {
conditions: JSON.stringify([
{ id: 'if-id', label: 'if', condition: 'x == 1' },
{ id: 'elseif-id', label: 'else if', condition: 'x == 2' },
{ id: 'else-id', label: 'else', condition: '' },
]),
})
const workflow = createMockWorkflow(
[conditionBlock, createMockBlock(targetAId), createMockBlock(targetBId)],
[
{ source: conditionId, target: targetAId, sourceHandle: 'condition-if-id' },
{ source: conditionId, target: targetBId, sourceHandle: 'condition-elseif-id' },
{ source: conditionId, target: targetAId, sourceHandle: 'condition-else-id' },
]
)
const dag = createMockDAG([conditionId, targetAId, targetBId])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([conditionId, targetAId, targetBId]),
new Map()
)
const conditionNode = dag.nodes.get(conditionId)!
// Should have 3 edges (if→A, elseif→B, else→A)
expect(conditionNode.outgoingEdges.size).toBe(3)
// Target A should have 2 incoming edges (from if and else)
const targetANode = dag.nodes.get(targetAId)!
expect(targetANode.incomingEdges.has(conditionId)).toBe(true)
// Target B should have 1 incoming edge (from elseif)
const targetBNode = dag.nodes.get(targetBId)!
expect(targetBNode.incomingEdges.has(conditionId)).toBe(true)
})
})
describe('Router block edge wiring', () => {
it('should wire router block edges with router prefix', () => {
const routerId = 'router-1'
const target1Id = 'target-1'
const target2Id = 'target-2'
const routerBlock = createMockBlock(routerId, 'router')
const workflow = createMockWorkflow(
[routerBlock, createMockBlock(target1Id), createMockBlock(target2Id)],
[
{ source: routerId, target: target1Id },
{ source: routerId, target: target2Id },
]
)
const dag = createMockDAG([routerId, target1Id, target2Id])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([routerId, target1Id, target2Id]),
new Map()
)
const routerNode = dag.nodes.get(routerId)!
const edges = Array.from(routerNode.outgoingEdges.values())
// Router edges should have router- prefix with target ID
expect(edges[0].sourceHandle).toBe(`router-${target1Id}`)
expect(edges[1].sourceHandle).toBe(`router-${target2Id}`)
})
})
describe('Simple linear workflow', () => {
it('should wire linear workflow correctly', () => {
const block1Id = 'block-1'
const block2Id = 'block-2'
const block3Id = 'block-3'
const workflow = createMockWorkflow(
[createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(block3Id)],
[
{ source: block1Id, target: block2Id },
{ source: block2Id, target: block3Id },
]
)
const dag = createMockDAG([block1Id, block2Id, block3Id])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([block1Id, block2Id, block3Id]),
new Map()
)
// Block 1 → Block 2
const block1Node = dag.nodes.get(block1Id)!
expect(block1Node.outgoingEdges.size).toBe(1)
expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id)
// Block 2 → Block 3
const block2Node = dag.nodes.get(block2Id)!
expect(block2Node.outgoingEdges.size).toBe(1)
expect(Array.from(block2Node.outgoingEdges.values())[0].target).toBe(block3Id)
expect(block2Node.incomingEdges.has(block1Id)).toBe(true)
// Block 3 has incoming from Block 2
const block3Node = dag.nodes.get(block3Id)!
expect(block3Node.incomingEdges.has(block2Id)).toBe(true)
})
})
describe('Edge reachability', () => {
it('should not wire edges to blocks not in DAG nodes', () => {
const block1Id = 'block-1'
const block2Id = 'block-2'
const unreachableId = 'unreachable'
const workflow = createMockWorkflow(
[createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(unreachableId)],
[
{ source: block1Id, target: block2Id },
{ source: block1Id, target: unreachableId },
]
)
// Only create DAG nodes for block1 and block2 (not unreachable)
const dag = createMockDAG([block1Id, block2Id])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([block1Id, block2Id]),
new Map()
)
const block1Node = dag.nodes.get(block1Id)!
// Should only have edge to block2, not unreachable (not in DAG)
expect(block1Node.outgoingEdges.size).toBe(1)
expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id)
})
it('should check both reachableBlocks and dag.nodes for edge validity', () => {
const block1Id = 'block-1'
const block2Id = 'block-2'
const workflow = createMockWorkflow(
[createMockBlock(block1Id), createMockBlock(block2Id)],
[{ source: block1Id, target: block2Id }]
)
const dag = createMockDAG([block1Id, block2Id])
// Block2 exists in DAG but not in reachableBlocks - edge should still be wired
// because isEdgeReachable checks: reachableBlocks.has(target) || dag.nodes.has(target)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([block1Id]), // Only block1 is "reachable" but block2 exists in DAG
new Map()
)
const block1Node = dag.nodes.get(block1Id)!
expect(block1Node.outgoingEdges.size).toBe(1)
})
})
describe('Error edge handling', () => {
it('should preserve error sourceHandle', () => {
const sourceId = 'source-1'
const successTargetId = 'success-target'
const errorTargetId = 'error-target'
const workflow = createMockWorkflow(
[
createMockBlock(sourceId),
createMockBlock(successTargetId),
createMockBlock(errorTargetId),
],
[
{ source: sourceId, target: successTargetId, sourceHandle: 'source' },
{ source: sourceId, target: errorTargetId, sourceHandle: 'error' },
]
)
const dag = createMockDAG([sourceId, successTargetId, errorTargetId])
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set(),
new Set([sourceId, successTargetId, errorTargetId]),
new Map()
)
const sourceNode = dag.nodes.get(sourceId)!
const edges = Array.from(sourceNode.outgoingEdges.values())
const successEdge = edges.find((e) => e.target === successTargetId)
const errorEdge = edges.find((e) => e.target === errorTargetId)
expect(successEdge?.sourceHandle).toBe('source')
expect(errorEdge?.sourceHandle).toBe('error')
})
})
describe('Loop sentinel wiring', () => {
it('should wire loop sentinels to nodes with no incoming edges from within loop', () => {
const loopId = 'loop-1'
const nodeInLoopId = 'node-in-loop'
const sentinelStartId = `loop-${loopId}-sentinel-start`
const sentinelEndId = `loop-${loopId}-sentinel-end`
// Create DAG with sentinels - nodeInLoop has no incoming edges from loop nodes
// so it will be identified as a start node
const dag = createMockDAG([nodeInLoopId, sentinelStartId, sentinelEndId])
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [nodeInLoopId],
iterations: 5,
loopType: 'for',
} as SerializedLoop)
const workflow = createMockWorkflow([createMockBlock(nodeInLoopId)], [], {
[loopId]: {
id: loopId,
nodes: [nodeInLoopId],
iterations: 5,
loopType: 'for',
} as SerializedLoop,
})
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([nodeInLoopId]),
new Set([nodeInLoopId, sentinelStartId, sentinelEndId]),
new Map()
)
// Sentinel start should have edge to node in loop (it's a start node - no incoming from loop)
const sentinelStartNode = dag.nodes.get(sentinelStartId)!
expect(sentinelStartNode.outgoingEdges.size).toBe(1)
const startEdge = Array.from(sentinelStartNode.outgoingEdges.values())[0]
expect(startEdge.target).toBe(nodeInLoopId)
// Node in loop should have edge to sentinel end (it's a terminal node - no outgoing to loop)
const nodeInLoopNode = dag.nodes.get(nodeInLoopId)!
const hasEdgeToEnd = Array.from(nodeInLoopNode.outgoingEdges.values()).some(
(e) => e.target === sentinelEndId
)
expect(hasEdgeToEnd).toBe(true)
// Sentinel end should have loop_continue edge back to start
const sentinelEndNode = dag.nodes.get(sentinelEndId)!
const continueEdge = Array.from(sentinelEndNode.outgoingEdges.values()).find(
(e) => e.sourceHandle === 'loop_continue'
)
expect(continueEdge?.target).toBe(sentinelStartId)
})
it('should identify multiple start and terminal nodes in loop', () => {
const loopId = 'loop-1'
const node1Id = 'node-1'
const node2Id = 'node-2'
const sentinelStartId = `loop-${loopId}-sentinel-start`
const sentinelEndId = `loop-${loopId}-sentinel-end`
// Create DAG with two nodes in loop - both are start and terminal (no edges between them)
const dag = createMockDAG([node1Id, node2Id, sentinelStartId, sentinelEndId])
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [node1Id, node2Id],
iterations: 3,
loopType: 'for',
} as SerializedLoop)
const workflow = createMockWorkflow(
[createMockBlock(node1Id), createMockBlock(node2Id)],
[],
{
[loopId]: {
id: loopId,
nodes: [node1Id, node2Id],
iterations: 3,
loopType: 'for',
} as SerializedLoop,
}
)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([node1Id, node2Id]),
new Set([node1Id, node2Id, sentinelStartId, sentinelEndId]),
new Map()
)
// Sentinel start should have edges to both nodes (both are start nodes)
const sentinelStartNode = dag.nodes.get(sentinelStartId)!
expect(sentinelStartNode.outgoingEdges.size).toBe(2)
// Both nodes should have edges to sentinel end (both are terminal nodes)
const node1 = dag.nodes.get(node1Id)!
const node2 = dag.nodes.get(node2Id)!
expect(Array.from(node1.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe(
true
)
expect(Array.from(node2.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe(
true
)
})
})
describe('Cross-loop boundary detection', () => {
it('should not wire edges that cross loop boundaries', () => {
const outsideId = 'outside'
const insideId = 'inside'
const loopId = 'loop-1'
const workflow = createMockWorkflow(
[createMockBlock(outsideId), createMockBlock(insideId)],
[{ source: outsideId, target: insideId }],
{
[loopId]: {
id: loopId,
nodes: [insideId],
iterations: 5,
loopType: 'for',
} as SerializedLoop,
}
)
const dag = createMockDAG([outsideId, insideId])
dag.loopConfigs.set(loopId, {
id: loopId,
nodes: [insideId],
iterations: 5,
loopType: 'for',
} as SerializedLoop)
edgeConstructor.execute(
workflow,
dag,
new Set(),
new Set([insideId]),
new Set([outsideId, insideId]),
new Map()
)
// Edge should not be wired because it crosses loop boundary
const outsideNode = dag.nodes.get(outsideId)!
expect(outsideNode.outgoingEdges.size).toBe(0)
})
})
})

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,10 @@ export class EdgeManager {
): string[] {
const readyNodes: string[] = []
const activatedTargets: string[] = []
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
// First pass: categorize edges as activating or deactivating
// Don't modify incomingEdges yet - we need the original state for deactivation checks
for (const [edgeId, edge] of node.outgoingEdges) {
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
continue
@@ -32,23 +35,31 @@ export class EdgeManager {
edge.sourceHandle === EDGE.LOOP_EXIT
if (!isLoopEdge) {
this.deactivateEdgeAndDescendants(node.id, edge.target, edge.sourceHandle)
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
}
continue
}
const targetNode = this.dag.nodes.get(edge.target)
if (!targetNode) {
logger.warn('Target node not found', { target: edge.target })
continue
}
targetNode.incomingEdges.delete(node.id)
activatedTargets.push(edge.target)
}
// Check readiness after all edges processed to ensure cascade deactivations are complete
// Second pass: process deactivations while incomingEdges is still intact
// This ensures hasActiveIncomingEdges can find all potential sources
for (const { target, handle } of edgesToDeactivate) {
this.deactivateEdgeAndDescendants(node.id, target, handle)
}
// Third pass: update incomingEdges for activated targets
for (const targetId of activatedTargets) {
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) {
logger.warn('Target node not found', { target: targetId })
continue
}
targetNode.incomingEdges.delete(node.id)
}
// Fourth pass: check readiness after all edge processing is complete
for (const targetId of activatedTargets) {
const targetNode = this.dag.nodes.get(targetId)
if (targetNode && this.isNodeReady(targetNode)) {
@@ -162,7 +173,10 @@ export class EdgeManager {
const targetNode = this.dag.nodes.get(targetId)
if (!targetNode) return
const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, sourceId)
// Check if target has other active incoming edges
// Pass the specific edge key being deactivated, not just source ID,
// to handle multiple edges from same source to same target (e.g., condition branches)
const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, edgeKey)
if (!hasOtherActiveIncoming) {
for (const [_, outgoingEdge] of targetNode.outgoingEdges) {
this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle)
@@ -170,10 +184,13 @@ export class EdgeManager {
}
}
private hasActiveIncomingEdges(node: DAGNode, excludeSourceId: string): boolean {
/**
* Checks if a node has any active incoming edges besides the one being excluded.
* This properly handles the case where multiple edges from the same source go to
* the same target (e.g., multiple condition branches pointing to one block).
*/
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
for (const incomingSourceId of node.incomingEdges) {
if (incomingSourceId === excludeSourceId) continue
const incomingNode = this.dag.nodes.get(incomingSourceId)
if (!incomingNode) continue
@@ -184,6 +201,8 @@ export class EdgeManager {
node.id,
incomingEdge.sourceHandle
)
// Skip the specific edge being excluded, but check other edges from same source
if (incomingEdgeKey === excludeEdgeKey) continue
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
return true
}

View File

@@ -94,12 +94,19 @@ export function serializePauseSnapshot(
dagIncomingEdges,
}
const workspaceId = metadataFromContext?.workspaceId ?? context.workspaceId
if (!workspaceId) {
throw new Error(
`Cannot serialize pause snapshot: missing workspaceId for workflow ${context.workflowId}`
)
}
const executionMetadata: ExecutionMetadata = {
requestId:
metadataFromContext?.requestId ?? context.executionId ?? context.workflowId ?? 'unknown',
executionId: context.executionId ?? 'unknown',
workflowId: context.workflowId,
workspaceId: context.workspaceId,
workspaceId,
userId: metadataFromContext?.userId ?? '',
sessionUserId: metadataFromContext?.sessionUserId,
workflowUserId: metadataFromContext?.workflowUserId,

View File

@@ -5,7 +5,7 @@ export interface ExecutionMetadata {
requestId: string
executionId: string
workflowId: string
workspaceId?: string
workspaceId: string
userId: string
sessionUserId?: string
workflowUserId?: string

View File

@@ -21,9 +21,18 @@ vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
vi.mock('@/executor/utils/block-data', () => ({
collectBlockData: vi.fn(() => ({
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { 'Source Block': 'source-block-1' },
})),
}))
import { collectBlockData } from '@/executor/utils/block-data'
import { executeTool } from '@/tools'
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
const mockCollectBlockData = collectBlockData as ReturnType<typeof vi.fn>
/**
* Simulates what the function_execute tool does when evaluating condition code
@@ -34,8 +43,6 @@ function simulateConditionExecution(code: string): {
error?: string
} {
try {
// The code is in format: "const context = {...};\nreturn Boolean(...)"
// We need to execute it and return the result
const fn = new Function(code)
const result = fn()
return { success: true, output: { result } }
@@ -55,8 +62,6 @@ describe('ConditionBlockHandler', () => {
let mockSourceBlock: SerializedBlock
let mockTargetBlock1: SerializedBlock
let mockTargetBlock2: SerializedBlock
let mockResolver: any
let mockPathTracker: any
beforeEach(() => {
mockSourceBlock = {
@@ -113,18 +118,11 @@ describe('ConditionBlockHandler', () => {
],
}
mockResolver = {
resolveVariableReferences: vi.fn((expr) => expr),
resolveBlockReferences: vi.fn((expr) => expr),
resolveEnvVariables: vi.fn((expr) => expr),
}
mockPathTracker = {}
handler = new ConditionBlockHandler(mockPathTracker, mockResolver)
handler = new ConditionBlockHandler()
mockContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
blockStates: new Map<string, BlockState>([
[
mockSourceBlock.id,
@@ -137,7 +135,8 @@ describe('ConditionBlockHandler', () => {
]),
blockLogs: [],
metadata: { duration: 0 },
environmentVariables: {},
environmentVariables: { API_KEY: 'test-key' },
workflowVariables: { userName: { name: 'userName', value: 'john', type: 'plain' } },
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set([mockSourceBlock.id]),
@@ -178,26 +177,41 @@ describe('ConditionBlockHandler', () => {
selectedOption: 'cond1',
}
mockResolver.resolveVariableReferences.mockReturnValue('context.value > 5')
mockResolver.resolveBlockReferences.mockReturnValue('context.value > 5')
mockResolver.resolveEnvVariables.mockReturnValue('context.value > 5')
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
'context.value > 5',
mockBlock
)
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
'context.value > 5',
mockContext,
mockBlock
)
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value > 5')
expect(result).toEqual(expectedOutput)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should pass correct parameters to function_execute tool', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: expect.stringContaining('context.value > 5'),
timeout: 5000,
envVars: mockContext.environmentVariables,
workflowVariables: mockContext.workflowVariables,
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { 'Source Block': 'source-block-1' },
_context: {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
},
}),
false,
false,
mockContext
)
})
it('should select the else path if other conditions fail', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value < 0' }, // Should fail (10 < 0 is false)
@@ -217,22 +231,8 @@ describe('ConditionBlockHandler', () => {
selectedOption: 'else1',
}
mockResolver.resolveVariableReferences.mockReturnValue('context.value < 0')
mockResolver.resolveBlockReferences.mockReturnValue('context.value < 0')
mockResolver.resolveEnvVariables.mockReturnValue('context.value < 0')
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
'context.value < 0',
mockBlock
)
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
'context.value < 0',
mockContext,
mockBlock
)
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value < 0')
expect(result).toEqual(expectedOutput)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
@@ -245,101 +245,6 @@ describe('ConditionBlockHandler', () => {
)
})
it('should resolve references in conditions before evaluation', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: '{{source-block-1.value}} > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockResolver.resolveVariableReferences.mockReturnValue('{{source-block-1.value}} > 5')
mockResolver.resolveBlockReferences.mockReturnValue('10 > 5')
mockResolver.resolveEnvVariables.mockReturnValue('10 > 5')
await handler.execute(mockContext, mockBlock, inputs)
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
'{{source-block-1.value}} > 5',
mockBlock
)
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
'{{source-block-1.value}} > 5',
mockContext,
mockBlock
)
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('10 > 5')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should resolve variable references in conditions', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: '<variable.userName> !== null' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockResolver.resolveVariableReferences.mockReturnValue('"john" !== null')
mockResolver.resolveBlockReferences.mockReturnValue('"john" !== null')
mockResolver.resolveEnvVariables.mockReturnValue('"john" !== null')
await handler.execute(mockContext, mockBlock, inputs)
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
'<variable.userName> !== null',
mockBlock
)
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
'"john" !== null',
mockContext,
mockBlock
)
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('"john" !== null')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should resolve environment variables in conditions', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: '{{POOP}} === "hi"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockResolver.resolveVariableReferences.mockReturnValue('{{POOP}} === "hi"')
mockResolver.resolveBlockReferences.mockReturnValue('{{POOP}} === "hi"')
mockResolver.resolveEnvVariables.mockReturnValue('"hi" === "hi"')
await handler.execute(mockContext, mockBlock, inputs)
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
'{{POOP}} === "hi"',
mockBlock
)
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
'{{POOP}} === "hi"',
mockContext,
mockBlock
)
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('{{POOP}} === "hi"')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should throw error if reference resolution fails', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: '{{invalid-ref}}' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const resolutionError = new Error('Could not resolve reference: invalid-ref')
mockResolver.resolveVariableReferences.mockImplementation(() => {
throw resolutionError
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Failed to resolve references in condition: Could not resolve reference: invalid-ref'
)
})
it('should handle evaluation errors gracefully', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' },
@@ -347,12 +252,6 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
mockResolver.resolveVariableReferences.mockReturnValue(
'context.nonExistentProperty.doSomething()'
)
mockResolver.resolveBlockReferences.mockReturnValue('context.nonExistentProperty.doSomething()')
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Evaluation error in condition "if".*doSomething/
)
@@ -367,10 +266,6 @@ describe('ConditionBlockHandler', () => {
blockStates: new Map<string, BlockState>(),
}
mockResolver.resolveVariableReferences.mockReturnValue('true')
mockResolver.resolveBlockReferences.mockReturnValue('true')
mockResolver.resolveEnvVariables.mockReturnValue('true')
const result = await handler.execute(contextWithoutSource, mockBlock, inputs)
expect(result).toHaveProperty('conditionResult', true)
@@ -383,10 +278,6 @@ describe('ConditionBlockHandler', () => {
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
mockResolver.resolveVariableReferences.mockReturnValue('true')
mockResolver.resolveBlockReferences.mockReturnValue('true')
mockResolver.resolveEnvVariables.mockReturnValue('true')
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
`Target block ${mockTargetBlock1.id} not found`
)
@@ -408,16 +299,6 @@ describe('ConditionBlockHandler', () => {
},
]
mockResolver.resolveVariableReferences
.mockReturnValueOnce('false')
.mockReturnValueOnce('context.value === 99')
mockResolver.resolveBlockReferences
.mockReturnValueOnce('false')
.mockReturnValueOnce('context.value === 99')
mockResolver.resolveEnvVariables
.mockReturnValueOnce('false')
.mockReturnValueOnce('context.value === 99')
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(false)
@@ -433,13 +314,317 @@ describe('ConditionBlockHandler', () => {
]
const inputs = { conditions: JSON.stringify(conditions) }
mockResolver.resolveVariableReferences.mockReturnValue('context.item === "apple"')
mockResolver.resolveBlockReferences.mockReturnValue('context.item === "apple"')
mockResolver.resolveEnvVariables.mockReturnValue('context.item === "apple"')
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
expect((result as any).selectedOption).toBe('else1')
})
it('should use collectBlockData to gather block state', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
})
it('should handle function_execute tool failure', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockExecuteTool.mockResolvedValueOnce({
success: false,
error: 'Execution timeout',
})
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
/Evaluation error in condition "if".*Execution timeout/
)
})
describe('Multiple branches to same target', () => {
it('should handle if and else pointing to same target', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
// Both branches point to the same target
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
})
})
it('should select else branch to same target when if fails', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
// Both branches point to the same target
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath).toEqual({
blockId: mockTargetBlock1.id,
blockType: 'target',
blockTitle: 'Target Block 1',
})
})
it('should handle if→A, elseif→B, else→A pattern', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value === 1' },
{ id: 'cond2', title: 'else if', value: 'context.value === 2' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
// value is 10, so else should be selected (pointing to target 1)
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id)
})
})
describe('Condition evaluation with different data types', () => {
it('should evaluate string comparison conditions', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { name: 'test', status: 'active' },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.status === "active"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate boolean conditions', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { isEnabled: true, count: 5 },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.isEnabled' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate array length conditions', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { items: [1, 2, 3, 4, 5] },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.items.length > 3' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
it('should evaluate null/undefined check conditions', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { data: null },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.data === null' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
})
describe('Multiple else-if conditions', () => {
it('should evaluate multiple else-if conditions in order', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { score: 75 },
executed: true,
executionTime: 100,
})
const mockTargetBlock3: SerializedBlock = {
id: 'target-block-3',
metadata: { id: 'target', name: 'Target Block 3' },
position: { x: 100, y: 200 },
config: { tool: 'target_tool_3', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
mockContext.workflow!.blocks!.push(mockTargetBlock3)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
{ source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
// Score is 75, so second condition (>=70) should match
expect((result as any).selectedOption).toBe('cond2')
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id)
})
it('should skip to else when all else-if fail', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { score: 30 },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('else1')
})
})
describe('Condition with no outgoing edge', () => {
it('should return null path when condition matches but has no edge', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
// No connection for cond1
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
// Condition matches but no edge for it
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
})
})
describe('Empty conditions handling', () => {
it('should handle empty conditions array', async () => {
const conditions: unknown[] = []
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBeNull()
})
it('should handle conditions passed as array directly', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
// Pass as array instead of JSON string
const inputs = { conditions }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).selectedOption).toBe('cond1')
})
})
describe('Virtual block ID handling', () => {
it('should use currentVirtualBlockId for decision key when available', async () => {
mockContext.currentVirtualBlockId = 'virtual-block-123'
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
await handler.execute(mockContext, mockBlock, inputs)
// Decision should be stored under virtual block ID, not actual block ID
expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1')
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
})
})

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -10,43 +11,32 @@ const logger = createLogger('ConditionBlockHandler')
const CONDITION_TIMEOUT_MS = 5000
/**
* Evaluates a single condition expression with variable/block reference resolution
* Returns true if condition is met, false otherwise
* Evaluates a single condition expression.
* Variable resolution is handled consistently with the function block via the function_execute tool.
* Returns true if condition is met, false otherwise.
*/
export async function evaluateConditionExpression(
ctx: ExecutionContext,
conditionExpression: string,
block: SerializedBlock,
resolver: any,
providedEvalContext?: Record<string, any>
): Promise<boolean> {
const evalContext = providedEvalContext || {}
let resolvedConditionValue = conditionExpression
try {
if (resolver) {
const resolvedVars = resolver.resolveVariableReferences(conditionExpression, block)
const resolvedRefs = resolver.resolveBlockReferences(resolvedVars, ctx, block)
resolvedConditionValue = resolver.resolveEnvVariables(resolvedRefs)
}
} catch (resolveError: any) {
logger.error(`Failed to resolve references in condition: ${resolveError.message}`, {
conditionExpression,
resolveError,
})
throw new Error(`Failed to resolve references in condition: ${resolveError.message}`)
}
try {
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${resolvedConditionValue})`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping } = collectBlockData(ctx)
const result = await executeTool(
'function_execute',
{
code,
timeout: CONDITION_TIMEOUT_MS,
envVars: {},
envVars: ctx.environmentVariables || {},
workflowVariables: ctx.workflowVariables || {},
blockData,
blockNameMapping,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
@@ -60,26 +50,20 @@ export async function evaluateConditionExpression(
if (!result.success) {
logger.error(`Failed to evaluate condition: ${result.error}`, {
originalCondition: conditionExpression,
resolvedCondition: resolvedConditionValue,
evalContext,
error: result.error,
})
throw new Error(
`Evaluation error in condition: ${result.error}. (Resolved: ${resolvedConditionValue})`
)
throw new Error(`Evaluation error in condition: ${result.error}`)
}
return Boolean(result.output?.result)
} catch (evalError: any) {
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
originalCondition: conditionExpression,
resolvedCondition: resolvedConditionValue,
evalContext,
evalError,
})
throw new Error(
`Evaluation error in condition: ${evalError.message}. (Resolved: ${resolvedConditionValue})`
)
throw new Error(`Evaluation error in condition: ${evalError.message}`)
}
}
@@ -87,11 +71,6 @@ export async function evaluateConditionExpression(
* Handler for Condition blocks that evaluate expressions to determine execution paths.
*/
export class ConditionBlockHandler implements BlockHandler {
constructor(
private pathTracker?: any,
private resolver?: any
) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CONDITION
}
@@ -104,7 +83,7 @@ export class ConditionBlockHandler implements BlockHandler {
const conditions = this.parseConditions(inputs.conditions)
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
const evalContext = this.buildEvaluationContext(ctx, block.id, sourceBlockId)
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const sourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
@@ -113,8 +92,7 @@ export class ConditionBlockHandler implements BlockHandler {
conditions,
outgoingConnections || [],
evalContext,
ctx,
block
ctx
)
if (!selectedConnection || !selectedCondition) {
@@ -158,7 +136,6 @@ export class ConditionBlockHandler implements BlockHandler {
private buildEvaluationContext(
ctx: ExecutionContext,
blockId: string,
sourceBlockId?: string
): Record<string, any> {
let evalContext: Record<string, any> = {}
@@ -180,8 +157,7 @@ export class ConditionBlockHandler implements BlockHandler {
conditions: Array<{ id: string; title: string; value: string }>,
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
evalContext: Record<string, any>,
ctx: ExecutionContext,
block: SerializedBlock
ctx: ExecutionContext
): Promise<{
selectedConnection: { target: string; sourceHandle?: string } | null
selectedCondition: { id: string; title: string; value: string } | null
@@ -200,8 +176,6 @@ export class ConditionBlockHandler implements BlockHandler {
const conditionMet = await evaluateConditionExpression(
ctx,
conditionValueString,
block,
this.resolver,
evalContext
)
@@ -211,13 +185,6 @@ export class ConditionBlockHandler implements BlockHandler {
return { selectedConnection: connection, selectedCondition: condition }
}
// Condition is true but has no outgoing edge - branch ends gracefully
logger.info(
`Condition "${condition.title}" is true but has no outgoing edge - branch ending`,
{
blockId: block.id,
conditionId: condition.id,
}
)
return { selectedConnection: null, selectedCondition: null }
}
} catch (error: any) {
@@ -228,18 +195,13 @@ export class ConditionBlockHandler implements BlockHandler {
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
if (elseCondition) {
logger.warn(`No condition met, selecting 'else' path`, { blockId: block.id })
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
if (elseConnection) {
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
}
logger.info(`No condition matched and else has no connection - branch ending`, {
blockId: block.id,
})
return { selectedConnection: null, selectedCondition: null }
}
logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id })
return { selectedConnection: null, selectedCondition: null }
}

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
import { createLogger } from '@/lib/logs/console/logger'
@@ -25,6 +24,7 @@ export interface GeneralSettings {
telemetryEnabled: boolean
billingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
}
/**
@@ -49,18 +49,20 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
snapToGridSize: data.snapToGridSize ?? 0,
}
}
/**
* Sync React Query cache to Zustand store and next-themes.
* This ensures the rest of the app (which uses Zustand) stays in sync.
* Uses shallow comparison to prevent unnecessary updates and flickering.
* @param settings - The general settings to sync
*/
function syncSettingsToZustand(settings: GeneralSettings) {
const { setSettings } = useGeneralStore.getState()
const store = useGeneralStore.getState()
setSettings({
const newSettings = {
isAutoConnectEnabled: settings.autoConnect,
showTrainingControls: settings.showTrainingControls,
superUserModeEnabled: settings.superUserModeEnabled,
@@ -68,30 +70,35 @@ function syncSettingsToZustand(settings: GeneralSettings) {
telemetryEnabled: settings.telemetryEnabled,
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
isErrorNotificationsEnabled: settings.errorNotificationsEnabled,
})
snapToGridSize: settings.snapToGridSize,
}
const hasChanges = Object.entries(newSettings).some(
([key, value]) => store[key as keyof typeof newSettings] !== value
)
if (hasChanges) {
store.setSettings(newSettings)
}
syncThemeToNextThemes(settings.theme)
}
/**
* Hook to fetch general settings.
* Also syncs to Zustand store to keep the rest of the app in sync.
* Syncs to Zustand store only on successful fetch (not on cache updates from mutations).
*/
export function useGeneralSettings() {
const query = useQuery({
return useQuery({
queryKey: generalSettingsKeys.settings(),
queryFn: fetchGeneralSettings,
queryFn: async () => {
const settings = await fetchGeneralSettings()
syncSettingsToZustand(settings)
return settings
},
staleTime: 60 * 60 * 1000,
placeholderData: keepPreviousData,
})
useEffect(() => {
if (query.data) {
syncSettingsToZustand(query.data)
}
}, [query.data])
return query
}
/**
@@ -131,8 +138,8 @@ export function useUpdateGeneralSetting() {
...previousSettings,
[key]: value,
}
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
syncSettingsToZustand(newSettings)
}
@@ -145,8 +152,5 @@ export function useUpdateGeneralSetting() {
}
logger.error('Failed to update setting:', err)
},
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
},
})
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
*/
import { env } from './lib/core/config/env'
import { sanitizeEventData } from './lib/core/security/redaction'
if (typeof window !== 'undefined') {
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
@@ -41,37 +42,6 @@ if (typeof window !== 'undefined') {
}
}
/**
* Sanitize event data to remove sensitive information
*/
function sanitizeEvent(event: any): any {
const patterns = ['password', 'token', 'secret', 'key', 'auth', 'credential', 'private']
const sensitiveRe = new RegExp(patterns.join('|'), 'i')
const scrubString = (s: string) => (s && sensitiveRe.test(s) ? '[redacted]' : s)
if (event == null) return event
if (typeof event === 'string') return scrubString(event)
if (typeof event !== 'object') return event
if (Array.isArray(event)) {
return event.map((item) => sanitizeEvent(item))
}
const sanitized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(event)) {
const lowerKey = key.toLowerCase()
if (patterns.some((p) => lowerKey.includes(p))) continue
if (typeof value === 'string') sanitized[key] = scrubString(value)
else if (Array.isArray(value)) sanitized[key] = value.map((v) => sanitizeEvent(v))
else if (value && typeof value === 'object') sanitized[key] = sanitizeEvent(value)
else sanitized[key] = value
}
return sanitized
}
/**
* Flush batch of events to server
*/
@@ -84,7 +54,7 @@ if (typeof window !== 'undefined') {
batchTimer = null
}
const sanitizedBatch = batch.map(sanitizeEvent)
const sanitizedBatch = batch.map(sanitizeEventData)
const payload = JSON.stringify({
category: 'batch',

View File

@@ -0,0 +1,421 @@
import { db } from '@sim/db'
import { usageLog, workflow } from '@sim/db/schema'
import { and, desc, eq, gte, lte, sql } from 'drizzle-orm'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('UsageLog')
/**
* Usage log category types
*/
export type UsageLogCategory = 'model' | 'fixed'
/**
* Usage log source types
*/
export type UsageLogSource = 'workflow' | 'wand' | 'copilot'
/**
* Metadata for 'model' category charges
*/
export interface ModelUsageMetadata {
inputTokens: number
outputTokens: number
}
/**
* Metadata for 'fixed' category charges (currently empty, extensible)
*/
export type FixedUsageMetadata = Record<string, never>
/**
* Union type for all metadata types
*/
export type UsageLogMetadata = ModelUsageMetadata | FixedUsageMetadata | null
/**
* Parameters for logging model usage (token-based charges)
*/
export interface LogModelUsageParams {
userId: string
source: UsageLogSource
model: string
inputTokens: number
outputTokens: number
cost: number
workspaceId?: string
workflowId?: string
executionId?: string
}
/**
* Parameters for logging fixed charges (flat fees)
*/
export interface LogFixedUsageParams {
userId: string
source: UsageLogSource
description: string
cost: number
workspaceId?: string
workflowId?: string
executionId?: string
}
/**
* Log a model usage charge (token-based)
*/
export async function logModelUsage(params: LogModelUsageParams): Promise<void> {
if (!isBillingEnabled) {
return
}
try {
const metadata: ModelUsageMetadata = {
inputTokens: params.inputTokens,
outputTokens: params.outputTokens,
}
await db.insert(usageLog).values({
id: crypto.randomUUID(),
userId: params.userId,
category: 'model',
source: params.source,
description: params.model,
metadata,
cost: params.cost.toString(),
workspaceId: params.workspaceId ?? null,
workflowId: params.workflowId ?? null,
executionId: params.executionId ?? null,
})
logger.debug('Logged model usage', {
userId: params.userId,
source: params.source,
model: params.model,
cost: params.cost,
})
} catch (error) {
logger.error('Failed to log model usage', {
error: error instanceof Error ? error.message : String(error),
params,
})
// Don't throw - usage logging should not break the main flow
}
}
/**
* Log a fixed charge (flat fee like base execution charge or search)
*/
export async function logFixedUsage(params: LogFixedUsageParams): Promise<void> {
if (!isBillingEnabled) {
return
}
try {
await db.insert(usageLog).values({
id: crypto.randomUUID(),
userId: params.userId,
category: 'fixed',
source: params.source,
description: params.description,
metadata: null,
cost: params.cost.toString(),
workspaceId: params.workspaceId ?? null,
workflowId: params.workflowId ?? null,
executionId: params.executionId ?? null,
})
logger.debug('Logged fixed usage', {
userId: params.userId,
source: params.source,
description: params.description,
cost: params.cost,
})
} catch (error) {
logger.error('Failed to log fixed usage', {
error: error instanceof Error ? error.message : String(error),
params,
})
// Don't throw - usage logging should not break the main flow
}
}
/**
* Parameters for batch logging workflow usage
*/
export interface LogWorkflowUsageBatchParams {
userId: string
workspaceId?: string
workflowId: string
executionId?: string
baseExecutionCharge?: number
models?: Record<
string,
{
total: number
tokens: { prompt: number; completion: number }
}
>
}
/**
* Log all workflow usage entries in a single batch insert (performance optimized)
*/
export async function logWorkflowUsageBatch(params: LogWorkflowUsageBatchParams): Promise<void> {
if (!isBillingEnabled) {
return
}
const entries: Array<{
id: string
userId: string
category: 'model' | 'fixed'
source: 'workflow'
description: string
metadata: ModelUsageMetadata | null
cost: string
workspaceId: string | null
workflowId: string | null
executionId: string | null
}> = []
if (params.baseExecutionCharge && params.baseExecutionCharge > 0) {
entries.push({
id: crypto.randomUUID(),
userId: params.userId,
category: 'fixed',
source: 'workflow',
description: 'execution_fee',
metadata: null,
cost: params.baseExecutionCharge.toString(),
workspaceId: params.workspaceId ?? null,
workflowId: params.workflowId,
executionId: params.executionId ?? null,
})
}
if (params.models) {
for (const [modelName, modelData] of Object.entries(params.models)) {
if (modelData.total > 0) {
entries.push({
id: crypto.randomUUID(),
userId: params.userId,
category: 'model',
source: 'workflow',
description: modelName,
metadata: {
inputTokens: modelData.tokens.prompt,
outputTokens: modelData.tokens.completion,
},
cost: modelData.total.toString(),
workspaceId: params.workspaceId ?? null,
workflowId: params.workflowId,
executionId: params.executionId ?? null,
})
}
}
}
if (entries.length === 0) {
return
}
try {
await db.insert(usageLog).values(entries)
logger.debug('Logged workflow usage batch', {
userId: params.userId,
workflowId: params.workflowId,
entryCount: entries.length,
})
} catch (error) {
logger.error('Failed to log workflow usage batch', {
error: error instanceof Error ? error.message : String(error),
params,
})
// Don't throw - usage logging should not break the main flow
}
}
/**
* Options for querying usage logs
*/
export interface GetUsageLogsOptions {
/** Filter by source */
source?: UsageLogSource
/** Filter by workspace */
workspaceId?: string
/** Start date (inclusive) */
startDate?: Date
/** End date (inclusive) */
endDate?: Date
/** Maximum number of results */
limit?: number
/** Cursor for pagination (log ID) */
cursor?: string
}
/**
* Usage log entry returned from queries
*/
export interface UsageLogEntry {
id: string
createdAt: string
category: UsageLogCategory
source: UsageLogSource
description: string
metadata?: UsageLogMetadata
cost: number
workspaceId?: string
workflowId?: string
executionId?: string
}
/**
* Result from getUserUsageLogs
*/
export interface UsageLogsResult {
logs: UsageLogEntry[]
summary: {
totalCost: number
bySource: Record<string, number>
}
pagination: {
nextCursor?: string
hasMore: boolean
}
}
/**
* Get usage logs for a user with optional filtering and pagination
*/
export async function getUserUsageLogs(
userId: string,
options: GetUsageLogsOptions = {}
): Promise<UsageLogsResult> {
const { source, workspaceId, startDate, endDate, limit = 50, cursor } = options
try {
const conditions = [eq(usageLog.userId, userId)]
if (source) {
conditions.push(eq(usageLog.source, source))
}
if (workspaceId) {
conditions.push(eq(usageLog.workspaceId, workspaceId))
}
if (startDate) {
conditions.push(gte(usageLog.createdAt, startDate))
}
if (endDate) {
conditions.push(lte(usageLog.createdAt, endDate))
}
if (cursor) {
const cursorLog = await db
.select({ createdAt: usageLog.createdAt })
.from(usageLog)
.where(eq(usageLog.id, cursor))
.limit(1)
if (cursorLog.length > 0) {
conditions.push(
sql`(${usageLog.createdAt} < ${cursorLog[0].createdAt} OR (${usageLog.createdAt} = ${cursorLog[0].createdAt} AND ${usageLog.id} < ${cursor}))`
)
}
}
const logs = await db
.select()
.from(usageLog)
.where(and(...conditions))
.orderBy(desc(usageLog.createdAt), desc(usageLog.id))
.limit(limit + 1)
const hasMore = logs.length > limit
const resultLogs = hasMore ? logs.slice(0, limit) : logs
const transformedLogs: UsageLogEntry[] = resultLogs.map((log) => ({
id: log.id,
createdAt: log.createdAt.toISOString(),
category: log.category as UsageLogCategory,
source: log.source as UsageLogSource,
description: log.description,
...(log.metadata ? { metadata: log.metadata as UsageLogMetadata } : {}),
cost: Number.parseFloat(log.cost),
...(log.workspaceId ? { workspaceId: log.workspaceId } : {}),
...(log.workflowId ? { workflowId: log.workflowId } : {}),
...(log.executionId ? { executionId: log.executionId } : {}),
}))
const summaryConditions = [eq(usageLog.userId, userId)]
if (source) summaryConditions.push(eq(usageLog.source, source))
if (workspaceId) summaryConditions.push(eq(usageLog.workspaceId, workspaceId))
if (startDate) summaryConditions.push(gte(usageLog.createdAt, startDate))
if (endDate) summaryConditions.push(lte(usageLog.createdAt, endDate))
const summaryResult = await db
.select({
source: usageLog.source,
totalCost: sql<string>`SUM(${usageLog.cost})`,
})
.from(usageLog)
.where(and(...summaryConditions))
.groupBy(usageLog.source)
const bySource: Record<string, number> = {}
let totalCost = 0
for (const row of summaryResult) {
const sourceCost = Number.parseFloat(row.totalCost || '0')
bySource[row.source] = sourceCost
totalCost += sourceCost
}
return {
logs: transformedLogs,
summary: {
totalCost,
bySource,
},
pagination: {
nextCursor:
hasMore && resultLogs.length > 0 ? resultLogs[resultLogs.length - 1].id : undefined,
hasMore,
},
}
} catch (error) {
logger.error('Failed to get usage logs', {
error: error instanceof Error ? error.message : String(error),
userId,
options,
})
throw error
}
}
/**
* Get the user ID associated with a workflow
* Helper function for cases where we only have a workflow ID
*/
export async function getUserIdFromWorkflow(workflowId: string): Promise<string | null> {
try {
const [workflowRecord] = await db
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
return workflowRecord?.userId ?? null
} catch (error) {
logger.error('Failed to get user ID from workflow', {
error: error instanceof Error ? error.message : String(error),
workflowId,
})
return null
}
}

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
createPinnedUrl,
sanitizeForLogging,
validateAlphanumericId,
validateEnum,
validateFileExtension,
@@ -11,6 +10,7 @@ import {
validateUrlWithDNS,
validateUUID,
} from '@/lib/core/security/input-validation'
import { sanitizeForLogging } from '@/lib/core/security/redaction'
describe('validatePathSegment', () => {
describe('valid inputs', () => {

View File

@@ -556,29 +556,6 @@ export function validateFileExtension(
return { isValid: true, sanitized: normalizedExt }
}
/**
* Sanitizes a string for safe logging (removes potential sensitive data patterns)
*
* @param value - The value to sanitize
* @param maxLength - Maximum length to return (default: 100)
* @returns Sanitized string safe for logging
*/
export function sanitizeForLogging(value: string, maxLength = 100): string {
if (!value) return ''
// Truncate long values
let sanitized = value.substring(0, maxLength)
// Mask common sensitive patterns
sanitized = sanitized
.replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]')
.replace(/password['":\s]*['"]\w+['"]/gi, 'password: "[REDACTED]"')
.replace(/token['":\s]*['"]\w+['"]/gi, 'token: "[REDACTED]"')
.replace(/api[_-]?key['":\s]*['"]\w+['"]/gi, 'api_key: "[REDACTED]"')
return sanitized
}
/**
* Validates Microsoft Graph API resource IDs
*

View File

@@ -0,0 +1,391 @@
import { describe, expect, it } from 'vitest'
import {
isSensitiveKey,
REDACTED_MARKER,
redactApiKeys,
redactSensitiveValues,
sanitizeEventData,
sanitizeForLogging,
} from './redaction'
describe('REDACTED_MARKER', () => {
it.concurrent('should be the standard marker', () => {
expect(REDACTED_MARKER).toBe('[REDACTED]')
})
})
describe('isSensitiveKey', () => {
describe('exact matches', () => {
it.concurrent('should match apiKey variations', () => {
expect(isSensitiveKey('apiKey')).toBe(true)
expect(isSensitiveKey('api_key')).toBe(true)
expect(isSensitiveKey('api-key')).toBe(true)
expect(isSensitiveKey('APIKEY')).toBe(true)
expect(isSensitiveKey('API_KEY')).toBe(true)
})
it.concurrent('should match token variations', () => {
expect(isSensitiveKey('access_token')).toBe(true)
expect(isSensitiveKey('refresh_token')).toBe(true)
expect(isSensitiveKey('auth_token')).toBe(true)
expect(isSensitiveKey('accessToken')).toBe(true)
})
it.concurrent('should match secret variations', () => {
expect(isSensitiveKey('client_secret')).toBe(true)
expect(isSensitiveKey('clientSecret')).toBe(true)
expect(isSensitiveKey('secret')).toBe(true)
})
it.concurrent('should match other sensitive keys', () => {
expect(isSensitiveKey('private_key')).toBe(true)
expect(isSensitiveKey('authorization')).toBe(true)
expect(isSensitiveKey('bearer')).toBe(true)
expect(isSensitiveKey('private')).toBe(true)
expect(isSensitiveKey('auth')).toBe(true)
expect(isSensitiveKey('password')).toBe(true)
expect(isSensitiveKey('credential')).toBe(true)
})
})
describe('suffix matches', () => {
it.concurrent('should match keys ending in secret', () => {
expect(isSensitiveKey('clientSecret')).toBe(true)
expect(isSensitiveKey('appSecret')).toBe(true)
expect(isSensitiveKey('mySecret')).toBe(true)
})
it.concurrent('should match keys ending in password', () => {
expect(isSensitiveKey('userPassword')).toBe(true)
expect(isSensitiveKey('dbPassword')).toBe(true)
expect(isSensitiveKey('adminPassword')).toBe(true)
})
it.concurrent('should match keys ending in token', () => {
expect(isSensitiveKey('accessToken')).toBe(true)
expect(isSensitiveKey('refreshToken')).toBe(true)
expect(isSensitiveKey('bearerToken')).toBe(true)
})
it.concurrent('should match keys ending in credential', () => {
expect(isSensitiveKey('userCredential')).toBe(true)
expect(isSensitiveKey('dbCredential')).toBe(true)
})
})
describe('non-sensitive keys (no false positives)', () => {
it.concurrent('should not match keys with sensitive words as prefix only', () => {
expect(isSensitiveKey('tokenCount')).toBe(false)
expect(isSensitiveKey('tokenizer')).toBe(false)
expect(isSensitiveKey('secretKey')).toBe(false)
expect(isSensitiveKey('passwordStrength')).toBe(false)
expect(isSensitiveKey('authMethod')).toBe(false)
})
it.concurrent('should match keys ending with sensitive words (intentional)', () => {
expect(isSensitiveKey('hasSecret')).toBe(true)
expect(isSensitiveKey('userPassword')).toBe(true)
expect(isSensitiveKey('sessionToken')).toBe(true)
})
it.concurrent('should not match normal field names', () => {
expect(isSensitiveKey('name')).toBe(false)
expect(isSensitiveKey('email')).toBe(false)
expect(isSensitiveKey('id')).toBe(false)
expect(isSensitiveKey('value')).toBe(false)
expect(isSensitiveKey('data')).toBe(false)
expect(isSensitiveKey('count')).toBe(false)
expect(isSensitiveKey('status')).toBe(false)
})
})
})
describe('redactSensitiveValues', () => {
it.concurrent('should redact Bearer tokens', () => {
const input = 'Authorization: Bearer abc123xyz456'
const result = redactSensitiveValues(input)
expect(result).toBe('Authorization: Bearer [REDACTED]')
expect(result).not.toContain('abc123xyz456')
})
it.concurrent('should redact Basic auth', () => {
const input = 'Authorization: Basic dXNlcjpwYXNz'
const result = redactSensitiveValues(input)
expect(result).toBe('Authorization: Basic [REDACTED]')
})
it.concurrent('should redact API key prefixes', () => {
const input = 'Using key sk-1234567890abcdefghijklmnop'
const result = redactSensitiveValues(input)
expect(result).toContain('[REDACTED]')
expect(result).not.toContain('sk-1234567890abcdefghijklmnop')
})
it.concurrent('should redact JSON-style password fields', () => {
const input = 'password: "mysecretpass123"'
const result = redactSensitiveValues(input)
expect(result).toContain('[REDACTED]')
expect(result).not.toContain('mysecretpass123')
})
it.concurrent('should redact JSON-style token fields', () => {
const input = 'token: "tokenvalue123"'
const result = redactSensitiveValues(input)
expect(result).toContain('[REDACTED]')
expect(result).not.toContain('tokenvalue123')
})
it.concurrent('should redact JSON-style api_key fields', () => {
const input = 'api_key: "key123456"'
const result = redactSensitiveValues(input)
expect(result).toContain('[REDACTED]')
expect(result).not.toContain('key123456')
})
it.concurrent('should not modify safe strings', () => {
const input = 'This is a normal string with no secrets'
const result = redactSensitiveValues(input)
expect(result).toBe(input)
})
it.concurrent('should handle empty strings', () => {
expect(redactSensitiveValues('')).toBe('')
})
it.concurrent('should handle null/undefined gracefully', () => {
expect(redactSensitiveValues(null as any)).toBe(null)
expect(redactSensitiveValues(undefined as any)).toBe(undefined)
})
})
describe('redactApiKeys', () => {
describe('object redaction', () => {
it.concurrent('should redact sensitive keys in flat objects', () => {
const obj = {
apiKey: 'secret-key',
api_key: 'another-secret',
access_token: 'token-value',
secret: 'secret-value',
password: 'password-value',
normalField: 'normal-value',
}
const result = redactApiKeys(obj)
expect(result.apiKey).toBe('[REDACTED]')
expect(result.api_key).toBe('[REDACTED]')
expect(result.access_token).toBe('[REDACTED]')
expect(result.secret).toBe('[REDACTED]')
expect(result.password).toBe('[REDACTED]')
expect(result.normalField).toBe('normal-value')
})
it.concurrent('should redact sensitive keys in nested objects', () => {
const obj = {
config: {
apiKey: 'secret-key',
normalField: 'normal-value',
},
}
const result = redactApiKeys(obj)
expect(result.config.apiKey).toBe('[REDACTED]')
expect(result.config.normalField).toBe('normal-value')
})
it.concurrent('should redact sensitive keys in arrays', () => {
const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }]
const result = redactApiKeys(arr)
expect(result[0].apiKey).toBe('[REDACTED]')
expect(result[1].apiKey).toBe('[REDACTED]')
})
it.concurrent('should handle deeply nested structures', () => {
const obj = {
users: [
{
name: 'John',
credentials: {
apiKey: 'secret-key',
username: 'john_doe',
},
},
],
config: {
database: {
password: 'db-password',
host: 'localhost',
},
},
}
const result = redactApiKeys(obj)
expect(result.users[0].name).toBe('John')
expect(result.users[0].credentials.apiKey).toBe('[REDACTED]')
expect(result.users[0].credentials.username).toBe('john_doe')
expect(result.config.database.password).toBe('[REDACTED]')
expect(result.config.database.host).toBe('localhost')
})
})
describe('primitive handling', () => {
it.concurrent('should return primitives unchanged', () => {
expect(redactApiKeys('string')).toBe('string')
expect(redactApiKeys(123)).toBe(123)
expect(redactApiKeys(true)).toBe(true)
expect(redactApiKeys(null)).toBe(null)
expect(redactApiKeys(undefined)).toBe(undefined)
})
})
describe('no false positives', () => {
it.concurrent('should not redact keys with sensitive words as prefix only', () => {
const obj = {
tokenCount: 100,
secretKey: 'not-actually-secret',
passwordStrength: 'strong',
authMethod: 'oauth',
}
const result = redactApiKeys(obj)
expect(result.tokenCount).toBe(100)
expect(result.secretKey).toBe('not-actually-secret')
expect(result.passwordStrength).toBe('strong')
expect(result.authMethod).toBe('oauth')
})
})
})
describe('sanitizeForLogging', () => {
it.concurrent('should truncate long strings', () => {
const longString = 'a'.repeat(200)
const result = sanitizeForLogging(longString, 50)
expect(result.length).toBe(50)
})
it.concurrent('should use default max length of 100', () => {
const longString = 'a'.repeat(200)
const result = sanitizeForLogging(longString)
expect(result.length).toBe(100)
})
it.concurrent('should redact sensitive patterns', () => {
const input = 'Bearer abc123xyz456'
const result = sanitizeForLogging(input)
expect(result).toContain('[REDACTED]')
expect(result).not.toContain('abc123xyz456')
})
it.concurrent('should handle empty strings', () => {
expect(sanitizeForLogging('')).toBe('')
})
it.concurrent('should not modify safe short strings', () => {
const input = 'Safe string'
const result = sanitizeForLogging(input)
expect(result).toBe(input)
})
})
describe('sanitizeEventData', () => {
describe('object sanitization', () => {
it.concurrent('should remove sensitive keys entirely', () => {
const event = {
action: 'login',
apiKey: 'secret-key',
password: 'secret-pass',
userId: '123',
}
const result = sanitizeEventData(event)
expect(result.action).toBe('login')
expect(result.userId).toBe('123')
expect(result).not.toHaveProperty('apiKey')
expect(result).not.toHaveProperty('password')
})
it.concurrent('should redact sensitive patterns in string values', () => {
const event = {
message: 'Auth: Bearer abc123token',
normal: 'normal value',
}
const result = sanitizeEventData(event)
expect(result.message).toContain('[REDACTED]')
expect(result.message).not.toContain('abc123token')
expect(result.normal).toBe('normal value')
})
it.concurrent('should handle nested objects', () => {
const event = {
user: {
id: '123',
accessToken: 'secret-token',
},
}
const result = sanitizeEventData(event)
expect(result.user.id).toBe('123')
expect(result.user).not.toHaveProperty('accessToken')
})
it.concurrent('should handle arrays', () => {
const event = {
items: [
{ id: 1, apiKey: 'key1' },
{ id: 2, apiKey: 'key2' },
],
}
const result = sanitizeEventData(event)
expect(result.items[0].id).toBe(1)
expect(result.items[0]).not.toHaveProperty('apiKey')
expect(result.items[1].id).toBe(2)
expect(result.items[1]).not.toHaveProperty('apiKey')
})
})
describe('primitive handling', () => {
it.concurrent('should return primitives appropriately', () => {
expect(sanitizeEventData(null)).toBe(null)
expect(sanitizeEventData(undefined)).toBe(undefined)
expect(sanitizeEventData(123)).toBe(123)
expect(sanitizeEventData(true)).toBe(true)
})
it.concurrent('should redact sensitive patterns in top-level strings', () => {
const result = sanitizeEventData('Bearer secrettoken123')
expect(result).toContain('[REDACTED]')
})
it.concurrent('should not redact normal strings', () => {
const result = sanitizeEventData('normal string')
expect(result).toBe('normal string')
})
})
describe('no false positives', () => {
it.concurrent('should not remove keys with sensitive words in middle', () => {
const event = {
tokenCount: 500,
isAuthenticated: true,
hasSecretFeature: false,
}
const result = sanitizeEventData(event)
expect(result.tokenCount).toBe(500)
expect(result.isAuthenticated).toBe(true)
expect(result.hasSecretFeature).toBe(false)
})
})
})

View File

@@ -1,28 +1,122 @@
/**
* Recursively redacts API keys in an object
* @param obj The object to redact API keys from
* @returns A new object with API keys redacted
* Centralized redaction utilities for sensitive data
*/
export const redactApiKeys = (obj: any): any => {
if (!obj || typeof obj !== 'object') {
/** Standard marker used for all redacted values */
export const REDACTED_MARKER = '[REDACTED]'
/**
* Patterns for sensitive key names (case-insensitive matching)
* These patterns match common naming conventions for sensitive data
*/
const SENSITIVE_KEY_PATTERNS: RegExp[] = [
/^api[_-]?key$/i,
/^access[_-]?token$/i,
/^refresh[_-]?token$/i,
/^client[_-]?secret$/i,
/^private[_-]?key$/i,
/^auth[_-]?token$/i,
/^.*secret$/i,
/^.*password$/i,
/^.*token$/i,
/^.*credential$/i,
/^authorization$/i,
/^bearer$/i,
/^private$/i,
/^auth$/i,
]
/**
* Patterns for sensitive values in strings (for redacting values, not keys)
* Each pattern has a replacement function
*/
const SENSITIVE_VALUE_PATTERNS: Array<{
pattern: RegExp
replacement: string
}> = [
// Bearer tokens
{
pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
replacement: `Bearer ${REDACTED_MARKER}`,
},
// Basic auth
{
pattern: /Basic\s+[A-Za-z0-9+/]+=*/gi,
replacement: `Basic ${REDACTED_MARKER}`,
},
// API keys that look like sk-..., pk-..., etc.
{
pattern: /\b(sk|pk|api|key)[_-][A-Za-z0-9\-._]{20,}\b/gi,
replacement: REDACTED_MARKER,
},
// JSON-style password fields: password: "value" or password: 'value'
{
pattern: /password['":\s]*['"][^'"]+['"]/gi,
replacement: `password: "${REDACTED_MARKER}"`,
},
// JSON-style token fields: token: "value" or token: 'value'
{
pattern: /token['":\s]*['"][^'"]+['"]/gi,
replacement: `token: "${REDACTED_MARKER}"`,
},
// JSON-style api_key fields: api_key: "value" or api-key: "value"
{
pattern: /api[_-]?key['":\s]*['"][^'"]+['"]/gi,
replacement: `api_key: "${REDACTED_MARKER}"`,
},
]
/**
* Checks if a key name matches any sensitive pattern
* @param key - The key name to check
* @returns True if the key is considered sensitive
*/
export function isSensitiveKey(key: string): boolean {
const lowerKey = key.toLowerCase()
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey))
}
/**
* Redacts sensitive patterns from a string value
* @param value - The string to redact
* @returns The string with sensitive patterns redacted
*/
export function redactSensitiveValues(value: string): string {
if (!value || typeof value !== 'string') {
return value
}
let result = value
for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) {
result = result.replace(pattern, replacement)
}
return result
}
/**
* Recursively redacts sensitive data (API keys, passwords, tokens, etc.) from an object
*
* @param obj - The object to redact sensitive data from
* @returns A new object with sensitive data redacted
*/
export function redactApiKeys(obj: any): any {
if (obj === null || obj === undefined) {
return obj
}
if (typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map(redactApiKeys)
return obj.map((item) => redactApiKeys(item))
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
if (
key.toLowerCase() === 'apikey' ||
key.toLowerCase() === 'api_key' ||
key.toLowerCase() === 'access_token' ||
/\bsecret\b/i.test(key.toLowerCase()) ||
/\bpassword\b/i.test(key.toLowerCase())
) {
result[key] = '***REDACTED***'
if (isSensitiveKey(key)) {
result[key] = REDACTED_MARKER
} else if (typeof value === 'object' && value !== null) {
result[key] = redactApiKeys(value)
} else {
@@ -32,3 +126,64 @@ export const redactApiKeys = (obj: any): any => {
return result
}
/**
* Sanitizes a string for safe logging by truncating and redacting sensitive patterns
*
* @param value - The string to sanitize
* @param maxLength - Maximum length of the output (default: 100)
* @returns The sanitized string
*/
export function sanitizeForLogging(value: string, maxLength = 100): string {
if (!value) return ''
let sanitized = value.substring(0, maxLength)
sanitized = redactSensitiveValues(sanitized)
return sanitized
}
/**
* Sanitizes event data for error reporting/analytics
*
* @param event - The event data to sanitize
* @returns Sanitized event data safe for external reporting
*/
export function sanitizeEventData(event: any): any {
if (event === null || event === undefined) {
return event
}
if (typeof event === 'string') {
return redactSensitiveValues(event)
}
if (typeof event !== 'object') {
return event
}
if (Array.isArray(event)) {
return event.map((item) => sanitizeEventData(item))
}
const sanitized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(event)) {
if (isSensitiveKey(key)) {
continue
}
if (typeof value === 'string') {
sanitized[key] = redactSensitiveValues(value)
} else if (Array.isArray(value)) {
sanitized[key] = value.map((v) => sanitizeEventData(v))
} else if (value && typeof value === 'object') {
sanitized[key] = sanitizeEventData(value)
} else {
sanitized[key] = value
}
}
return sanitized
}

View File

@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
formatDate,
@@ -229,86 +228,6 @@ describe('getTimezoneAbbreviation', () => {
})
})
describe('redactApiKeys', () => {
it.concurrent('should redact API keys in objects', () => {
const obj = {
apiKey: 'secret-key',
api_key: 'another-secret',
access_token: 'token-value',
secret: 'secret-value',
password: 'password-value',
normalField: 'normal-value',
}
const result = redactApiKeys(obj)
expect(result.apiKey).toBe('***REDACTED***')
expect(result.api_key).toBe('***REDACTED***')
expect(result.access_token).toBe('***REDACTED***')
expect(result.secret).toBe('***REDACTED***')
expect(result.password).toBe('***REDACTED***')
expect(result.normalField).toBe('normal-value')
})
it.concurrent('should redact API keys in nested objects', () => {
const obj = {
config: {
apiKey: 'secret-key',
normalField: 'normal-value',
},
}
const result = redactApiKeys(obj)
expect(result.config.apiKey).toBe('***REDACTED***')
expect(result.config.normalField).toBe('normal-value')
})
it.concurrent('should redact API keys in arrays', () => {
const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }]
const result = redactApiKeys(arr)
expect(result[0].apiKey).toBe('***REDACTED***')
expect(result[1].apiKey).toBe('***REDACTED***')
})
it.concurrent('should handle primitive values', () => {
expect(redactApiKeys('string')).toBe('string')
expect(redactApiKeys(123)).toBe(123)
expect(redactApiKeys(null)).toBe(null)
expect(redactApiKeys(undefined)).toBe(undefined)
})
it.concurrent('should handle complex nested structures', () => {
const obj = {
users: [
{
name: 'John',
credentials: {
apiKey: 'secret-key',
username: 'john_doe',
},
},
],
config: {
database: {
password: 'db-password',
host: 'localhost',
},
},
}
const result = redactApiKeys(obj)
expect(result.users[0].name).toBe('John')
expect(result.users[0].credentials.apiKey).toBe('***REDACTED***')
expect(result.users[0].credentials.username).toBe('john_doe')
expect(result.config.database.password).toBe('***REDACTED***')
expect(result.config.database.host).toBe('localhost')
})
})
describe('validateName', () => {
it.concurrent('should remove invalid characters', () => {
const result = validateName('test@#$%name')

View File

@@ -1,6 +1,13 @@
/**
* Type guard to check if an object is a UserFile
*/
const MAX_STRING_LENGTH = 15000
const MAX_DEPTH = 50
function truncateString(value: string, maxLength = MAX_STRING_LENGTH): string {
if (value.length <= maxLength) {
return value
}
return `${value.substring(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
}
export function isUserFile(candidate: unknown): candidate is {
id: string
name: string
@@ -23,11 +30,6 @@ export function isUserFile(candidate: unknown): candidate is {
)
}
/**
* Filter function that transforms UserFile objects for display
* Removes internal fields: key, context
* Keeps user-friendly fields: id, name, url, size, type
*/
function filterUserFile(data: any): any {
if (isUserFile(data)) {
const { id, name, url, size, type } = data
@@ -36,50 +38,152 @@ function filterUserFile(data: any): any {
return data
}
/**
* Registry of filter functions to apply to data for cleaner display in logs/console.
* Add new filter functions here to handle additional data types.
*/
const DISPLAY_FILTERS = [
filterUserFile,
// Add more filters here as needed
]
const DISPLAY_FILTERS = [filterUserFile]
/**
* Generic helper to filter internal/technical fields from data for cleaner display in logs and console.
* Applies all registered filters recursively to the data structure.
*
* To add a new filter:
* 1. Create a filter function that checks and transforms a specific data type
* 2. Add it to the DISPLAY_FILTERS array above
*
* @param data - Data to filter (objects, arrays, primitives)
* @returns Filtered data with internal fields removed
*/
export function filterForDisplay(data: any): any {
if (!data || typeof data !== 'object') {
return data
}
// Apply all registered filters
const filtered = data
for (const filterFn of DISPLAY_FILTERS) {
const result = filterFn(filtered)
if (result !== filtered) {
// Filter matched and transformed the data
return result
}
}
// No filters matched - recursively filter nested structures
if (Array.isArray(filtered)) {
return filtered.map(filterForDisplay)
}
// Recursively filter object properties
const result: any = {}
for (const [key, value] of Object.entries(filtered)) {
result[key] = filterForDisplay(value)
}
return result
const seen = new WeakSet()
return filterForDisplayInternal(data, seen, 0)
}
function getObjectType(data: unknown): string {
return Object.prototype.toString.call(data).slice(8, -1)
}
function filterForDisplayInternal(data: any, seen: WeakSet<object>, depth: number): any {
try {
if (data === null || data === undefined) {
return data
}
const dataType = typeof data
if (dataType === 'string') {
// Remove null bytes which are not allowed in PostgreSQL JSONB
const sanitized = data.includes('\u0000') ? data.replace(/\u0000/g, '') : data
return truncateString(sanitized)
}
if (dataType === 'number') {
if (Number.isNaN(data)) {
return '[NaN]'
}
if (!Number.isFinite(data)) {
return data > 0 ? '[Infinity]' : '[-Infinity]'
}
return data
}
if (dataType === 'boolean') {
return data
}
if (dataType === 'bigint') {
return `[BigInt: ${data.toString()}]`
}
if (dataType === 'symbol') {
return `[Symbol: ${data.toString()}]`
}
if (dataType === 'function') {
return `[Function: ${data.name || 'anonymous'}]`
}
if (dataType !== 'object') {
return '[Unknown Type]'
}
if (seen.has(data)) {
return '[Circular Reference]'
}
if (depth > MAX_DEPTH) {
return '[Max Depth Exceeded]'
}
const objectType = getObjectType(data)
switch (objectType) {
case 'Date': {
const timestamp = (data as Date).getTime()
if (Number.isNaN(timestamp)) {
return '[Invalid Date]'
}
return (data as Date).toISOString()
}
case 'RegExp':
return (data as RegExp).toString()
case 'URL':
return (data as URL).toString()
case 'Error': {
const err = data as Error
return {
name: err.name,
message: truncateString(err.message),
stack: err.stack ? truncateString(err.stack) : undefined,
}
}
case 'ArrayBuffer':
return `[ArrayBuffer: ${(data as ArrayBuffer).byteLength} bytes]`
case 'Map': {
const obj: Record<string, any> = {}
for (const [key, value] of (data as Map<any, any>).entries()) {
const keyStr = typeof key === 'string' ? key : String(key)
obj[keyStr] = filterForDisplayInternal(value, seen, depth + 1)
}
return obj
}
case 'Set':
return Array.from(data as Set<any>).map((item) =>
filterForDisplayInternal(item, seen, depth + 1)
)
case 'WeakMap':
return '[WeakMap]'
case 'WeakSet':
return '[WeakSet]'
case 'WeakRef':
return '[WeakRef]'
case 'Promise':
return '[Promise]'
}
if (ArrayBuffer.isView(data)) {
return `[${objectType}: ${(data as ArrayBufferView).byteLength} bytes]`
}
seen.add(data)
for (const filterFn of DISPLAY_FILTERS) {
const result = filterFn(data)
if (result !== data) {
return filterForDisplayInternal(result, seen, depth + 1)
}
}
if (Array.isArray(data)) {
return data.map((item) => filterForDisplayInternal(item, seen, depth + 1))
}
const result: Record<string, any> = {}
for (const key of Object.keys(data)) {
try {
result[key] = filterForDisplayInternal(data[key], seen, depth + 1)
} catch {
result[key] = '[Error accessing property]'
}
}
return result
} catch {
return '[Unserializable]'
}
}

View File

@@ -516,6 +516,15 @@ async function logPreprocessingError(params: {
loggingSession,
} = params
if (!workspaceId) {
logger.warn(`[${requestId}] Cannot log preprocessing error: no workspaceId available`, {
workflowId,
executionId,
errorMessage,
})
return
}
try {
const session =
loggingSession || new LoggingSession(workflowId, executionId, triggerType as any, requestId)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
getOrgUsageLimit,
maybeSendUsageThresholdEmail,
} from '@/lib/billing/core/usage'
import { logWorkflowUsageBatch } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { redactApiKeys } from '@/lib/core/security/redaction'
@@ -46,43 +47,6 @@ export interface ToolCall {
const logger = createLogger('ExecutionLogger')
export class ExecutionLogger implements IExecutionLoggerService {
private mergeTraceSpans(existing: TraceSpan[], additional: TraceSpan[]): TraceSpan[] {
// If no existing spans, just return additional
if (!existing || existing.length === 0) return additional
if (!additional || additional.length === 0) return existing
// Find the root "Workflow Execution" span in both arrays
const existingRoot = existing.find((s) => s.name === 'Workflow Execution')
const additionalRoot = additional.find((s) => s.name === 'Workflow Execution')
if (!existingRoot || !additionalRoot) {
// If we can't find both roots, just concatenate (fallback)
return [...existing, ...additional]
}
// Calculate the full duration from original start to resume end
const startTime = existingRoot.startTime
const endTime = additionalRoot.endTime || existingRoot.endTime
const fullDuration =
startTime && endTime
? new Date(endTime).getTime() - new Date(startTime).getTime()
: (existingRoot.duration || 0) + (additionalRoot.duration || 0)
// Merge the children of the workflow execution spans
const mergedRoot = {
...existingRoot,
children: [...(existingRoot.children || []), ...(additionalRoot.children || [])],
endTime,
duration: fullDuration,
}
// Return array with merged root plus any other top-level spans
const otherExisting = existing.filter((s) => s.name !== 'Workflow Execution')
const otherAdditional = additional.filter((s) => s.name !== 'Workflow Execution')
return [mergedRoot, ...otherExisting, ...otherAdditional]
}
private mergeCostModels(
existing: Record<string, any>,
additional: Record<string, any>
@@ -109,6 +73,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
async startWorkflowExecution(params: {
workflowId: string
workspaceId: string
executionId: string
trigger: ExecutionTrigger
environment: ExecutionEnvironment
@@ -118,8 +83,15 @@ export class ExecutionLogger implements IExecutionLoggerService {
workflowLog: WorkflowExecutionLog
snapshot: WorkflowExecutionSnapshot
}> {
const { workflowId, executionId, trigger, environment, workflowState, deploymentVersionId } =
params
const {
workflowId,
workspaceId,
executionId,
trigger,
environment,
workflowState,
deploymentVersionId,
} = params
logger.debug(`Starting workflow execution ${executionId} for workflow ${workflowId}`)
@@ -168,6 +140,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
.values({
id: uuidv4(),
workflowId,
workspaceId,
executionId,
stateSnapshotId: snapshotResult.snapshot.id,
deploymentVersionId: deploymentVersionId ?? null,
@@ -230,6 +203,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
traceSpans?: TraceSpan[]
workflowInput?: any
isResume?: boolean // If true, merge with existing data instead of replacing
level?: 'info' | 'error' // Optional override for log level (used in cost-only fallback)
}): Promise<WorkflowExecutionLog> {
const {
executionId,
@@ -240,6 +214,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
traceSpans,
workflowInput,
isResume,
level: levelOverride,
} = params
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
@@ -256,6 +231,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
}
// Determine if workflow failed by checking trace spans for errors
// Use the override if provided (for cost-only fallback scenarios)
const hasErrors = traceSpans?.some((span: any) => {
const checkSpanForErrors = (s: any): boolean => {
if (s.status === 'error') return true
@@ -267,7 +243,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
return checkSpanForErrors(span)
})
const level = hasErrors ? 'error' : 'info'
const level = levelOverride ?? (hasErrors ? 'error' : 'info')
// Extract files from trace spans, final output, and workflow input
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput)
@@ -371,7 +347,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
updatedLog.trigger as ExecutionTrigger['type'],
executionId
)
const limit = before.usageData.limit
@@ -408,7 +385,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
updatedLog.trigger as ExecutionTrigger['type'],
executionId
)
const percentBefore =
@@ -433,14 +411,16 @@ export class ExecutionLogger implements IExecutionLoggerService {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
updatedLog.trigger as ExecutionTrigger['type'],
executionId
)
}
} else {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
updatedLog.trigger as ExecutionTrigger['type'],
executionId
)
}
} catch (e) {
@@ -448,7 +428,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
updatedLog.trigger as ExecutionTrigger['type'],
executionId
)
} catch {}
logger.warn('Usage threshold notification check failed (non-fatal)', { error: e })
@@ -521,8 +502,18 @@ export class ExecutionLogger implements IExecutionLoggerService {
totalCompletionTokens: number
baseExecutionCharge: number
modelCost: number
models?: Record<
string,
{
input: number
output: number
total: number
tokens: { prompt: number; completion: number; total: number }
}
>
},
trigger: ExecutionTrigger['type']
trigger: ExecutionTrigger['type'],
executionId?: string
): Promise<void> {
if (!isBillingEnabled) {
logger.debug('Billing is disabled, skipping user stats cost update')
@@ -594,6 +585,16 @@ export class ExecutionLogger implements IExecutionLoggerService {
addedTokens: costSummary.totalTokens,
})
// Log usage entries for auditing (batch insert for performance)
await logWorkflowUsageBatch({
userId,
workspaceId: workflowRecord.workspaceId ?? undefined,
workflowId,
executionId,
baseExecutionCharge: costSummary.baseExecutionCharge,
models: costSummary.models,
})
// Check if user has hit overage threshold and bill incrementally
await checkAndBillOverageThreshold(userId)
} catch (error) {

View File

@@ -18,7 +18,7 @@ const logger = createLogger('LoggingSession')
export interface SessionStartParams {
userId?: string
workspaceId?: string
workspaceId: string
variables?: Record<string, string>
triggerData?: Record<string, unknown>
skipLogCreation?: boolean // For resume executions - reuse existing log entry
@@ -29,7 +29,7 @@ export interface SessionCompleteParams {
endedAt?: string
totalDurationMs?: number
finalOutput?: any
traceSpans?: any[]
traceSpans?: TraceSpan[]
workflowInput?: any
}
@@ -65,7 +65,7 @@ export class LoggingSession {
this.requestId = requestId
}
async start(params: SessionStartParams = {}): Promise<void> {
async start(params: SessionStartParams): Promise<void> {
const { userId, workspaceId, variables, triggerData, skipLogCreation, deploymentVersionId } =
params
@@ -84,6 +84,7 @@ export class LoggingSession {
if (!skipLogCreation) {
await executionLogger.startWorkflowExecution({
workflowId: this.workflowId,
workspaceId,
executionId: this.executionId,
trigger: this.trigger,
environment: this.environment,
@@ -115,7 +116,6 @@ export class LoggingSession {
* Note: Logging now works through trace spans only, no direct executor integration needed
*/
setupExecutor(executor: any): void {
// No longer setting logger on executor - trace spans handle everything
if (this.requestId) {
logger.debug(`[${this.requestId}] Logging session ready for execution ${this.executionId}`)
}
@@ -272,7 +272,7 @@ export class LoggingSession {
}
}
async safeStart(params: SessionStartParams = {}): Promise<boolean> {
async safeStart(params: SessionStartParams): Promise<boolean> {
try {
await this.start(params)
return true
@@ -305,6 +305,7 @@ export class LoggingSession {
await executionLogger.startWorkflowExecution({
workflowId: this.workflowId,
workspaceId,
executionId: this.executionId,
trigger: this.trigger,
environment: this.environment,
@@ -331,20 +332,85 @@ export class LoggingSession {
try {
await this.complete(params)
} catch (error) {
// Error already logged in complete(), log a summary here
const errorMsg = error instanceof Error ? error.message : String(error)
logger.warn(
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - execution data not persisted`
`[${this.requestId || 'unknown'}] Complete failed for execution ${this.executionId}, attempting fallback`,
{ error: errorMsg }
)
await this.completeWithCostOnlyLog({
traceSpans: params.traceSpans,
endedAt: params.endedAt,
totalDurationMs: params.totalDurationMs,
errorMessage: `Failed to store trace spans: ${errorMsg}`,
isError: false,
})
}
}
async safeCompleteWithError(error?: SessionErrorCompleteParams): Promise<void> {
async safeCompleteWithError(params?: SessionErrorCompleteParams): Promise<void> {
try {
await this.completeWithError(error)
} catch (enhancedError) {
// Error already logged in completeWithError(), log a summary here
await this.completeWithError(params)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
logger.warn(
`[${this.requestId || 'unknown'}] Error logging completion failed for execution ${this.executionId} - execution data not persisted`
`[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`,
{ error: errorMsg }
)
await this.completeWithCostOnlyLog({
traceSpans: params?.traceSpans,
endedAt: params?.endedAt,
totalDurationMs: params?.totalDurationMs,
errorMessage:
params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`,
isError: true,
})
}
}
private async completeWithCostOnlyLog(params: {
traceSpans?: TraceSpan[]
endedAt?: string
totalDurationMs?: number
errorMessage: string
isError: boolean
}): Promise<void> {
logger.warn(
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback`
)
try {
const costSummary = params.traceSpans?.length
? calculateCostSummary(params.traceSpans)
: {
totalCost: BASE_EXECUTION_CHARGE,
totalInputCost: 0,
totalOutputCost: 0,
totalTokens: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
baseExecutionCharge: BASE_EXECUTION_CHARGE,
modelCost: 0,
models: {},
}
await executionLogger.completeWorkflowExecution({
executionId: this.executionId,
endedAt: params.endedAt || new Date().toISOString(),
totalDurationMs: params.totalDurationMs || 0,
costSummary,
finalOutput: { _fallback: true, error: params.errorMessage },
traceSpans: [],
isResume: this.isResume,
level: params.isError ? 'error' : 'info',
})
logger.info(
`[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}`
)
} catch (fallbackError) {
logger.error(
`[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`,
{ error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError) }
)
}
}

View File

@@ -334,6 +334,7 @@ export interface SnapshotCreationResult {
export interface ExecutionLoggerService {
startWorkflowExecution(params: {
workflowId: string
workspaceId: string
executionId: string
trigger: ExecutionTrigger
environment: ExecutionEnvironment

View File

@@ -103,6 +103,9 @@ export async function executeWorkflowCore(
const { onBlockStart, onBlockComplete, onStream, onExecutorCreated } = callbacks
const providedWorkspaceId = metadata.workspaceId
if (!providedWorkspaceId) {
throw new Error(`Execution metadata missing workspaceId for workflow ${workflowId}`)
}
let processedInput = input || {}

View File

@@ -479,8 +479,16 @@ export async function transformBlockTool(
const llmSchema = await createLLMToolSchema(toolConfig, userProvidedParams)
// Create unique tool ID by appending resource ID for multi-instance tools
let uniqueToolId = toolConfig.id
if (toolId === 'workflow_executor' && userProvidedParams.workflowId) {
uniqueToolId = `${toolConfig.id}_${userProvidedParams.workflowId}`
} else if (toolId.startsWith('knowledge_') && userProvidedParams.knowledgeBaseId) {
uniqueToolId = `${toolConfig.id}_${userProvidedParams.knowledgeBaseId}`
}
return {
id: toolConfig.id,
id: uniqueToolId,
name: toolConfig.name,
description: toolConfig.description,
params: userProvidedParams,

View File

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

View File

@@ -533,7 +533,11 @@ function createStreamingMessage(): CopilotMessage {
}
}
function createErrorMessage(messageId: string, content: string): CopilotMessage {
function createErrorMessage(
messageId: string,
content: string,
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required'
): CopilotMessage {
return {
id: messageId,
role: 'assistant',
@@ -546,6 +550,7 @@ function createErrorMessage(messageId: string, content: string): CopilotMessage
timestamp: Date.now(),
},
],
errorType,
}
}
@@ -2066,23 +2071,35 @@ export const useCopilotStore = create<CopilotStore>()(
// Check for specific status codes and provide custom messages
let errorContent = result.error || 'Failed to send message'
let errorType:
| 'usage_limit'
| 'unauthorized'
| 'forbidden'
| 'rate_limit'
| 'upgrade_required'
| undefined
if (result.status === 401) {
errorContent =
'_Unauthorized request. You need a valid API key to use the copilot. You can get one by going to [sim.ai](https://sim.ai) settings and generating one there._'
errorType = 'unauthorized'
} else if (result.status === 402) {
errorContent =
'_Usage limit exceeded. To continue using this service, upgrade your plan or top up on credits._'
'_Usage limit exceeded. To continue using this service, upgrade your plan or increase your usage limit to:_'
errorType = 'usage_limit'
} else if (result.status === 403) {
errorContent =
'_Provider config not allowed for non-enterprise users. Please remove the provider config and try again_'
errorType = 'forbidden'
} else if (result.status === 426) {
errorContent =
'_Please upgrade to the latest version of the Sim platform to continue using the copilot._'
errorType = 'upgrade_required'
} else if (result.status === 429) {
errorContent = '_Provider rate limit exceeded. Please try again later._'
errorType = 'rate_limit'
}
const errorMessage = createErrorMessage(streamingMessage.id, errorContent)
const errorMessage = createErrorMessage(streamingMessage.id, errorContent, errorType)
set((state) => ({
messages: state.messages.map((m) => (m.id === streamingMessage.id ? errorMessage : m)),
error: errorContent,

Some files were not shown because too many files have changed in this diff Show More