Compare commits

..

6 Commits

Author SHA1 Message Date
priyanshu.solanki
d615c78a30 undo-redo-functionality for code blocks 2025-12-19 13:34:05 -07:00
priyanshu.solanki
b7f25786ce fixed the refrencing errors and making sure it propogates to the console 2025-12-19 11:57:44 -07:00
priyanshu.solanki
3a9e5f3b78 extracted utility functions 2025-12-19 11:13:41 -07:00
priyanshu.solanki
39444fa1a8 fixed for empty loop and paralle blocks and showing input on dashboard 2025-12-18 22:16:55 -07:00
Siddharth Ganesan
45ca926e6d Fix array check for collection 2025-12-18 20:18:19 -08:00
priyanshu.solanki
77ee01747d fixed logs for parallel and loop execution flow 2025-12-18 21:01:13 -07:00
248 changed files with 4332 additions and 59813 deletions

View File

@@ -6,10 +6,7 @@ import { source } from '@/lib/source'
export const revalidate = false
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug?: string[] }> }
) {
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
const { slug } = await params
let lang: (typeof i18n.languages)[number] = i18n.defaultLanguage

View File

@@ -120,117 +120,117 @@ import {
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export const blockTypeToIconMap: Record<string, IconComponent> = {
ahrefs: AhrefsIcon,
airtable: AirtableIcon,
apify: ApifyIcon,
apollo: ApolloIcon,
arxiv: ArxivIcon,
asana: AsanaIcon,
browser_use: BrowserUseIcon,
calendly: CalendlyIcon,
clay: ClayIcon,
confluence: ConfluenceIcon,
cursor: CursorIcon,
datadog: DatadogIcon,
discord: DiscordIcon,
dropbox: DropboxIcon,
duckduckgo: DuckDuckGoIcon,
dynamodb: DynamoDBIcon,
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
exa: ExaAIIcon,
file: DocumentIcon,
firecrawl: FirecrawlIcon,
github: GithubIcon,
gitlab: GitLabIcon,
gmail: GmailIcon,
google_calendar: GoogleCalendarIcon,
google_docs: GoogleDocsIcon,
google_drive: GoogleDriveIcon,
google_forms: GoogleFormsIcon,
google_groups: GoogleGroupsIcon,
google_search: GoogleIcon,
google_sheets: GoogleSheetsIcon,
google_slides: GoogleSlidesIcon,
google_vault: GoogleVaultIcon,
grafana: GrafanaIcon,
hubspot: HubspotIcon,
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,
image_generator: ImageIcon,
incidentio: IncidentioIcon,
intercom: IntercomIcon,
jina: JinaAIIcon,
jira: JiraIcon,
kalshi: KalshiIcon,
knowledge: PackageSearchIcon,
linear: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
mailchimp: MailchimpIcon,
mailgun: MailgunIcon,
mem0: Mem0Icon,
memory: BrainIcon,
microsoft_excel: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
mistral_parse: MistralIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
notion: NotionIcon,
onedrive: MicrosoftOneDriveIcon,
openai: OpenAIIcon,
outlook: OutlookIcon,
parallel_ai: ParallelIcon,
perplexity: PerplexityIcon,
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
qdrant: QdrantIcon,
rds: RDSIcon,
reddit: RedditIcon,
resend: ResendIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,
sendgrid: SendgridIcon,
sentry: SentryIcon,
serper: SerperIcon,
servicenow: ServiceNowIcon,
sftp: SftpIcon,
sharepoint: MicrosoftSharepointIcon,
shopify: ShopifyIcon,
slack: SlackIcon,
smtp: SmtpIcon,
spotify: SpotifyIcon,
sqs: SQSIcon,
ssh: SshIcon,
stagehand: StagehandIcon,
stripe: StripeIcon,
stt: STTIcon,
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
thinking: BrainIcon,
translate: TranslateIcon,
trello: TrelloIcon,
tts: TTSIcon,
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
video_generator: VideoIcon,
vision: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,
whatsapp: WhatsAppIcon,
wikipedia: WikipediaIcon,
wordpress: WordpressIcon,
x: xIcon,
youtube: YouTubeIcon,
zendesk: ZendeskIcon,
zep: ZepIcon,
zoom: ZoomIcon,
zep: ZepIcon,
zendesk: ZendeskIcon,
youtube: YouTubeIcon,
x: xIcon,
wordpress: WordpressIcon,
wikipedia: WikipediaIcon,
whatsapp: WhatsAppIcon,
webflow: WebflowIcon,
wealthbox: WealthboxIcon,
vision: EyeIcon,
video_generator: VideoIcon,
typeform: TypeformIcon,
twilio_voice: TwilioIcon,
twilio_sms: TwilioIcon,
tts: TTSIcon,
trello: TrelloIcon,
translate: TranslateIcon,
thinking: BrainIcon,
telegram: TelegramIcon,
tavily: TavilyIcon,
supabase: SupabaseIcon,
stt: STTIcon,
stripe: StripeIcon,
stagehand: StagehandIcon,
ssh: SshIcon,
sqs: SQSIcon,
spotify: SpotifyIcon,
smtp: SmtpIcon,
slack: SlackIcon,
shopify: ShopifyIcon,
sharepoint: MicrosoftSharepointIcon,
sftp: SftpIcon,
servicenow: ServiceNowIcon,
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,
search: SearchIcon,
salesforce: SalesforceIcon,
s3: S3Icon,
resend: ResendIcon,
reddit: RedditIcon,
rds: RDSIcon,
qdrant: QdrantIcon,
posthog: PosthogIcon,
postgresql: PostgresIcon,
polymarket: PolymarketIcon,
pipedrive: PipedriveIcon,
pinecone: PineconeIcon,
perplexity: PerplexityIcon,
parallel_ai: ParallelIcon,
outlook: OutlookIcon,
openai: OpenAIIcon,
onedrive: MicrosoftOneDriveIcon,
notion: NotionIcon,
neo4j: Neo4jIcon,
mysql: MySQLIcon,
mongodb: MongoDBIcon,
mistral_parse: MistralIcon,
microsoft_teams: MicrosoftTeamsIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_excel: MicrosoftExcelIcon,
memory: BrainIcon,
mem0: Mem0Icon,
mailgun: MailgunIcon,
mailchimp: MailchimpIcon,
linkup: LinkupIcon,
linkedin: LinkedInIcon,
linear: LinearIcon,
knowledge: PackageSearchIcon,
kalshi: KalshiIcon,
jira: JiraIcon,
jina: JinaAIIcon,
intercom: IntercomIcon,
incidentio: IncidentioIcon,
image_generator: ImageIcon,
hunter: HunterIOIcon,
huggingface: HuggingFaceIcon,
hubspot: HubspotIcon,
grafana: GrafanaIcon,
google_vault: GoogleVaultIcon,
google_slides: GoogleSlidesIcon,
google_sheets: GoogleSheetsIcon,
google_groups: GoogleGroupsIcon,
google_forms: GoogleFormsIcon,
google_drive: GoogleDriveIcon,
google_docs: GoogleDocsIcon,
google_calendar: GoogleCalendarIcon,
google_search: GoogleIcon,
gmail: GmailIcon,
gitlab: GitLabIcon,
github: GithubIcon,
firecrawl: FirecrawlIcon,
file: DocumentIcon,
exa: ExaAIIcon,
elevenlabs: ElevenLabsIcon,
elasticsearch: ElasticsearchIcon,
dynamodb: DynamoDBIcon,
duckduckgo: DuckDuckGoIcon,
dropbox: DropboxIcon,
discord: DiscordIcon,
datadog: DatadogIcon,
cursor: CursorIcon,
confluence: ConfluenceIcon,
clay: ClayIcon,
calendly: CalendlyIcon,
browser_use: BrowserUseIcon,
asana: AsanaIcon,
arxiv: ArxivIcon,
apollo: ApolloIcon,
apify: ApifyIcon,
airtable: AirtableIcon,
ahrefs: AhrefsIcon,
}

View File

@@ -109,12 +109,12 @@ Lesen Sie die neuesten Nachrichten aus Slack-Kanälen. Rufen Sie den Konversatio
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | Nein | Authentifizierungsmethode: oauth oder bot_token |
| `botToken` | string | Nein | Bot-Token für Custom Bot |
| `botToken` | string | Nein | Bot-Token für benutzerdefinierten Bot |
| `channel` | string | Nein | Slack-Kanal, aus dem Nachrichten gelesen werden sollen \(z.B. #general\) |
| `userId` | string | Nein | Benutzer-ID für DM-Konversation \(z.B. U1234567890\) |
| `limit` | number | Nein | Anzahl der abzurufenden Nachrichten \(Standard: 10, max: 15\) |
| `oldest` | string | Nein | Beginn des Zeitbereichs \(Zeitstempel\) |
| `latest` | string | Nein | Ende des Zeitbereichs \(Zeitstempel\) |
| `limit` | number | Nein | Anzahl der abzurufenden Nachrichten \(Standard: 10, max: 100\) |
| `oldest` | string | Nein | Beginn des Zeitraums \(Zeitstempel\) |
| `latest` | string | Nein | Ende des Zeitraums \(Zeitstempel\) |
#### Ausgabe

View File

@@ -114,7 +114,7 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 100\) |
| `oldest` | string | No | Start of time range \(timestamp\) |
| `latest` | string | No | End of time range \(timestamp\) |

View File

@@ -111,8 +111,8 @@ Lee los últimos mensajes de los canales de Slack. Recupera el historial de conv
| `authMethod` | string | No | Método de autenticación: oauth o bot_token |
| `botToken` | string | No | Token del bot para Bot personalizado |
| `channel` | string | No | Canal de Slack del que leer mensajes (p. ej., #general) |
| `userId` | string | No | ID de usuario para conversación de mensaje directo (p. ej., U1234567890) |
| `limit` | number | No | Número de mensajes a recuperar (predeterminado: 10, máx: 15) |
| `userId` | string | No | ID de usuario para conversación por MD (p. ej., U1234567890) |
| `limit` | number | No | Número de mensajes a recuperar (predeterminado: 10, máx: 100) |
| `oldest` | string | No | Inicio del rango de tiempo (marca de tiempo) |
| `latest` | string | No | Fin del rango de tiempo (marca de tiempo) |

View File

@@ -107,14 +107,14 @@ Lisez les derniers messages des canaux Slack. Récupérez l'historique des conve
#### Entrée
| Paramètre | Type | Obligatoire | Description |
| --------- | ---- | ----------- | ----------- |
| --------- | ---- | ---------- | ----------- |
| `authMethod` | chaîne | Non | Méthode d'authentification : oauth ou bot_token |
| `botToken` | chaîne | Non | Jeton du bot pour Bot personnalisé |
| `channel` | chaîne | Non | Canal Slack depuis lequel lire les messages \(ex. : #general\) |
| `userId` | chaîne | Non | ID utilisateur pour la conversation en message direct \(ex. : U1234567890\) |
| `limit` | nombre | Non | Nombre de messages à récupérer \(par défaut : 10, max : 15\) |
| `oldest` | chaîne | Non | Début de la plage horaire \(horodatage\) |
| `latest` | chaîne | Non | Fin de la plage horaire \(horodatage\) |
| `channel` | chaîne | Non | Canal Slack pour lire les messages \(ex. : #general\) |
| `userId` | chaîne | Non | ID utilisateur pour la conversation en MP \(ex. : U1234567890\) |
| `limit` | nombre | Non | Nombre de messages à récupérer \(par défaut : 10, max : 100\) |
| `oldest` | chaîne | Non | Début de la plage temporelle \(horodatage\) |
| `latest` | chaîne | Non | Fin de la plage temporelle \(horodatage\) |
#### Sortie

View File

@@ -110,8 +110,8 @@ Slackチャンネルから最新のメッセージを読み取ります。フィ
| `authMethod` | string | いいえ | 認証方法oauthまたはbot_token |
| `botToken` | string | いいえ | カスタムボット用のボットトークン |
| `channel` | string | いいえ | メッセージを読み取るSlackチャンネル#general |
| `userId` | string | いいえ | DM会話用のユーザーIDU1234567890 |
| `limit` | number | いいえ | 取得するメッセージ数デフォルト10、最大15 |
| `userId` | string | いいえ | DM会話用のユーザーIDU1234567890 |
| `limit` | number | いいえ | 取得するメッセージ数デフォルト10、最大100 |
| `oldest` | string | いいえ | 時間範囲の開始(タイムスタンプ) |
| `latest` | string | いいえ | 時間範囲の終了(タイムスタンプ) |

View File

@@ -109,10 +109,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| `authMethod` | string | 否 | 认证方法oauth 或 bot_token |
| `botToken` | string | 否 | 自定义 Bot 的令牌 |
| `channel` | string | 否 | 要读取消息的 Slack 频道(例如,#general |
| `userId` | string | 否 | DM 话的用户 ID例如U1234567890 |
| `limit` | number | 否 | 要检索的消息数量默认10最大15 |
| `oldest` | string | 否 | 时间范围始(时间戳) |
| `latest` | string | 否 | 时间范围结束(时间戳) |
| `userId` | string | 否 | DM 话的用户 ID例如U1234567890 |
| `limit` | number | 否 | 要检索的消息数量默认10最大100 |
| `oldest` | string | 否 | 时间范围的开始(时间戳) |
| `latest` | string | 否 | 时间范围结束(时间戳) |
#### 输出

View File

@@ -903,7 +903,7 @@ checksums:
content/24: 228a8ece96627883153b826a1cbaa06c
content/25: 53abe061a259c296c82676b4770ddd1b
content/26: 371d0e46b4bd2c23f559b8bc112f6955
content/27: 5b9546f77fbafc0741f3fc2548f81c7e
content/27: 03e8b10ec08b354de98e360b66b779e3
content/28: bcadfc362b69078beee0088e5936c98b
content/29: b82def7d82657f941fbe60df3924eeeb
content/30: 1ca7ee3856805fa1718031c5f75b6ffb

View File

@@ -2,7 +2,6 @@ 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')
@@ -237,13 +236,13 @@ export async function POST(request: NextRequest) {
oidcConfig: providerConfig.oidcConfig
? {
...providerConfig.oidcConfig,
clientSecret: REDACTED_MARKER,
clientSecret: '[REDACTED]',
}
: undefined,
samlConfig: providerConfig.samlConfig
? {
...providerConfig.samlConfig,
cert: REDACTED_MARKER,
cert: '[REDACTED]',
}
: undefined,
},

View File

@@ -3,7 +3,6 @@ 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'
@@ -15,9 +14,6 @@ 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),
})
/**
@@ -75,12 +71,11 @@ export async function POST(req: NextRequest) {
)
}
const { userId, cost, model, inputTokens, outputTokens } = validation.data
const { userId, cost } = validation.data
logger.info(`[${requestId}] Processing cost update`, {
userId,
cost,
model,
})
// Check if user stats record exists (same as ExecutionLogger)
@@ -112,16 +107,6 @@ 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, workflow } from '@sim/db/schema'
import { chat } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -94,21 +94,6 @@ 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,
@@ -119,7 +104,7 @@ export async function POST(
await loggingSession.safeStart({
userId: deployment.userId,
workspaceId,
workspaceId: '', // Will be resolved if needed
variables: {},
})
@@ -184,14 +169,7 @@ export async function POST(
const { actorUserId, workflowRecord } = preprocessResult
const workspaceOwnerId = actorUserId!
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
)
}
const workspaceId = workflowRecord?.workspaceId || ''
try {
const selectedOutputs: string[] = []

View File

@@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CopilotChatsListAPI')
export async function GET(_request: NextRequest) {
export async function GET(_req: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {

View File

@@ -38,13 +38,14 @@ export async function GET(
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const contextParam = request.nextUrl.searchParams.get('context')
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
if (context === 'profile-pictures' || context === 'og-images') {
logger.info(`Serving public ${context}:`, { cloudKey })
if (context === 'profile-pictures') {
logger.info('Serving public profile picture:', { cloudKey })
if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxyPublic(cloudKey, context)
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
}
return await handleLocalFilePublic(fullPath)
}
@@ -181,7 +182,8 @@ async function handleCloudProxy(
async function handleCloudProxyPublic(
cloudKey: string,
context: StorageContext
context: StorageContext,
legacyBucketType?: string | null
): Promise<NextResponse> {
try {
let fileBuffer: Buffer

View File

@@ -141,23 +141,6 @@ 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)
@@ -219,34 +202,6 @@ 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

@@ -1,6 +1,7 @@
import { runs } from '@trigger.dev/sdk'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse } from '@/app/api/workflows/utils'
@@ -17,44 +18,38 @@ export async function GET(
try {
logger.debug(`[${requestId}] Getting status for task: ${taskId}`)
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized task status request`)
return createErrorResponse(authResult.error || 'Authentication required', 401)
// Try session auth first (for web UI)
const session = await getSession()
let authenticatedUserId: string | null = session?.user?.id || null
if (!authenticatedUserId) {
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
if (authResult.success && authResult.userId) {
authenticatedUserId = authResult.userId
if (authResult.keyId) {
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
keyId: authResult.keyId,
error,
})
})
}
}
}
}
const authenticatedUserId = authResult.userId
if (!authenticatedUserId) {
return createErrorResponse('Authentication required', 401)
}
// Fetch task status from Trigger.dev
const run = await runs.retrieve(taskId)
logger.debug(`[${requestId}] Task ${taskId} status: ${run.status}`)
const payload = run.payload as any
if (payload?.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
const accessCheck = await verifyWorkflowAccess(authenticatedUserId, payload.workflowId)
if (!accessCheck.hasAccess) {
logger.warn(`[${requestId}] User ${authenticatedUserId} denied access to task ${taskId}`, {
workflowId: payload.workflowId,
})
return createErrorResponse('Access denied', 403)
}
logger.debug(`[${requestId}] User ${authenticatedUserId} has access to task ${taskId}`)
} else {
if (payload?.userId && payload.userId !== authenticatedUserId) {
logger.warn(
`[${requestId}] User ${authenticatedUserId} attempted to access task ${taskId} owned by ${payload.userId}`
)
return createErrorResponse('Access denied', 403)
}
if (!payload?.userId) {
logger.warn(
`[${requestId}] Task ${taskId} has no ownership information in payload. Denying access for security.`
)
return createErrorResponse('Access denied', 403)
}
}
// Map Trigger.dev status to our format
const statusMap = {
QUEUED: 'queued',
WAITING_FOR_DEPLOY: 'queued',
@@ -72,6 +67,7 @@ export async function GET(
const mappedStatus = statusMap[run.status as keyof typeof statusMap] || 'unknown'
// Build response based on status
const response: any = {
success: true,
taskId,
@@ -81,18 +77,21 @@ export async function GET(
},
}
// Add completion details if finished
if (mappedStatus === 'completed') {
response.output = run.output // This contains the workflow execution results
response.metadata.completedAt = run.finishedAt
response.metadata.duration = run.durationMs
}
// Add error details if failed
if (mappedStatus === 'failed') {
response.error = run.error
response.metadata.completedAt = run.finishedAt
response.metadata.duration = run.durationMs
}
// Add progress info if still processing
if (mappedStatus === 'processing' || mappedStatus === 'queued') {
response.estimatedDuration = 180000 // 3 minutes max from our config
}
@@ -108,3 +107,6 @@ export async function GET(
return createErrorResponse('Failed to fetch task status', 500)
}
}
// TODO: Implement task cancellation via Trigger.dev API if needed
// export async function DELETE() { ... }

View File

@@ -156,7 +156,6 @@ 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,
@@ -164,19 +163,6 @@ 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,16 +72,6 @@ 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(),
// Text tag fields
// Tag fields
tag1: z.string().optional(),
tag2: z.string().optional(),
tag3: z.string().optional(),
@@ -31,19 +31,6 @@ 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,16 +80,6 @@ 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

@@ -27,7 +27,7 @@ const UpdateKnowledgeBaseSchema = z.object({
.optional(),
})
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -133,10 +133,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params

View File

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

View File

@@ -1,10 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { generateRequestId } from '@/lib/core/utils/request'
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
import { 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'
@@ -22,16 +20,6 @@ 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([
@@ -51,17 +39,18 @@ const VectorSearchSchema = z
.nullable()
.default(10)
.transform((val) => val ?? 10),
tagFilters: z
.array(StructuredTagFilterSchema)
filters: z
.record(z.string())
.optional()
.nullable()
.transform((val) => val || undefined),
.transform((val) => val || undefined), // Allow dynamic filter keys (display names)
})
.refine(
(data) => {
// Ensure at least query or filters are provided
const hasQuery = data.query && data.query.trim().length > 0
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
return hasQuery || hasTagFilters
const hasFilters = data.filters && Object.keys(data.filters).length > 0
return hasQuery || hasFilters
},
{
message: 'Please provide either a search query or tag filters to search your knowledge base',
@@ -99,81 +88,45 @@ export async function POST(request: NextRequest) {
)
// Map display names to tag slots for filtering
let structuredFilters: StructuredFilter[] = []
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)
// Handle tag filters
if (validatedData.tagFilters && accessibleKbIds.length > 0) {
const kbId = accessibleKbIds[0]
const tagDefs = await getDocumentTagDefinitions(kbId)
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
// 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,
}
})
// Create mapping from display name to tag slot
const displayNameToSlot: Record<string, string> = {}
tagDefs.forEach((def) => {
displayNameToSlot[def.displayName] = def.tagSlot
})
// Validate all tag filters first
const undefinedTags: string[] = []
const typeErrors: string[] = []
// 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
for (const filter of validatedData.tagFilters) {
const tagDef = displayNameToTagDef[filter.tagName]
// Check if this is an OR filter (contains |OR| separator)
if (value.includes('|OR|')) {
logger.debug(
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
)
}
// Check if tag exists
if (!tagDef) {
undefinedTags.push(filter.tagName)
continue
}
mappedFilters[tagSlot] = value
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
}
})
// Validate value type using shared validation
const validationError = validateTagValue(
filter.tagName,
String(filter.value),
tagDef.fieldType
)
if (validationError) {
typeErrors.push(validationError)
}
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
}
// 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) {
@@ -202,29 +155,26 @@ export async function POST(request: NextRequest) {
let results: SearchResult[]
const hasFilters = structuredFilters && structuredFilters.length > 0
const hasFilters = mappedFilters && Object.keys(mappedFilters).length > 0
if (!hasQuery && hasFilters) {
// Tag-only search without vector similarity
logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters)
logger.debug(`[${requestId}] Executing tag-only search with filters:`, mappedFilters)
results = await handleTagOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
structuredFilters,
filters: mappedFilters,
})
} else if (hasQuery && hasFilters) {
// Tag + Vector search
logger.debug(
`[${requestId}] Executing tag + vector search with filters:`,
structuredFilters
)
logger.debug(`[${requestId}] Executing tag + vector search with filters:`, mappedFilters)
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
const queryVector = JSON.stringify(await queryEmbeddingPromise)
results = await handleTagAndVectorSearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
structuredFilters,
filters: mappedFilters,
queryVector,
distanceThreshold: strategy.distanceThreshold,
})
@@ -307,9 +257,9 @@ export async function POST(request: NextRequest) {
// Create tags object with display names
const tags: Record<string, any> = {}
ALL_TAG_SLOTS.forEach((slot) => {
TAG_SLOTS.forEach((slot) => {
const tagValue = (result as any)[slot]
if (tagValue !== null && tagValue !== undefined) {
if (tagValue) {
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,
structuredFilters: [],
filters: {},
}
await expect(handleTagOnlySearch(params)).rejects.toThrow(
@@ -66,14 +66,14 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
filters: { tag1: '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.structuredFilters).toHaveLength(1)
expect(params.filters).toEqual({ tag1: 'api' })
})
})
@@ -123,7 +123,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
structuredFilters: [],
filters: {},
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,
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
filters: { tag1: 'api' },
distanceThreshold: 0.8,
}
@@ -150,7 +150,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
filters: { tag1: '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,
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
filters: { tag1: '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.structuredFilters).toHaveLength(1)
expect(params.filters).toEqual({ tag1: 'api' })
expect(params.queryVector).toBe(JSON.stringify([0.1, 0.2, 0.3]))
expect(params.distanceThreshold).toBe(0.8)
})

View File

@@ -1,7 +1,6 @@
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')
@@ -35,7 +34,6 @@ export interface SearchResult {
content: string
documentId: string
chunkIndex: number
// Text tags
tag1: string | null
tag2: string | null
tag3: string | null
@@ -43,19 +41,6 @@ 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
}
@@ -63,7 +48,7 @@ export interface SearchResult {
export interface SearchParams {
knowledgeBaseIds: string[]
topK: number
structuredFilters?: StructuredFilter[]
filters?: Record<string, string>
queryVector?: string
distanceThreshold?: number
}
@@ -71,230 +56,46 @@ export interface SearchParams {
// Use shared embedding utility
export { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
/** 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
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)
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 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
}
}
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`
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]})`
}
}
// 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
// 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 `)})`
})
}
export function getQueryStrategy(kbCount: number, topK: number) {
@@ -312,10 +113,8 @@ export function getQueryStrategy(kbCount: number, topK: number) {
async function executeTagFilterQuery(
knowledgeBaseIds: string[],
structuredFilters: StructuredFilter[]
filters: Record<string, string>
): Promise<{ id: string }[]> {
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
if (knowledgeBaseIds.length === 1) {
return await db
.select({ id: embedding.id })
@@ -326,7 +125,7 @@ async function executeTagFilterQuery(
eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...tagFilterConditions
...getTagFilters(filters, embedding)
)
)
}
@@ -339,7 +138,7 @@ async function executeTagFilterQuery(
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...tagFilterConditions
...getTagFilters(filters, embedding)
)
)
}
@@ -355,11 +154,21 @@ async function executeVectorSearchOnIds(
}
return await db
.select(
getSearchResultFields(
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
)
)
.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,
})
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -374,16 +183,15 @@ async function executeVectorSearchOnIds(
}
export async function handleTagOnlySearch(params: SearchParams): Promise<SearchResult[]> {
const { knowledgeBaseIds, topK, structuredFilters } = params
const { knowledgeBaseIds, topK, filters } = params
if (!structuredFilters || structuredFilters.length === 0) {
if (!filters || Object.keys(filters).length === 0) {
throw new Error('Tag filters are required for tag-only search')
}
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, structuredFilters)
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, filters)
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
if (strategy.useParallel) {
// Parallel approach for many KBs
@@ -391,7 +199,21 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
return await db
.select(getSearchResultFields(sql<number>`0`.as('distance')))
.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,
})
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -399,7 +221,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
eq(embedding.knowledgeBaseId, kbId),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...tagFilterConditions
...getTagFilters(filters, embedding)
)
)
.limit(parallelLimit)
@@ -410,7 +232,21 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
}
// Single query for fewer KBs
return await db
.select(getSearchResultFields(sql<number>`0`.as('distance')))
.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,
})
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -418,7 +254,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...tagFilterConditions
...getTagFilters(filters, embedding)
)
)
.limit(topK)
@@ -435,15 +271,27 @@ 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(getSearchResultFields(distanceExpr))
.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,
})
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -464,7 +312,21 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
}
// Single query for fewer KBs
return await db
.select(getSearchResultFields(distanceExpr))
.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,
})
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -480,22 +342,19 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
}
export async function handleTagAndVectorSearch(params: SearchParams): Promise<SearchResult[]> {
const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params
const { knowledgeBaseIds, topK, filters, queryVector, distanceThreshold } = params
if (!structuredFilters || structuredFilters.length === 0) {
if (!filters || Object.keys(filters).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:`,
structuredFilters
)
logger.debug(`[handleTagAndVectorSearch] Executing tag + vector search with filters:`, filters)
// Step 1: Filter by tags first
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters)
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, filters)
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
// Text tags
// Document tags
tag1?: string | null
tag2?: string | null
tag3?: string | null
@@ -43,19 +43,6 @@ 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 {
@@ -71,7 +58,7 @@ export interface EmbeddingData {
embeddingModel: string
startOffset: number
endOffset: number
// Text tags
// Tag fields for filtering
tag1?: string | null
tag2?: string | null
tag3?: string | null
@@ -79,19 +66,6 @@ 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
@@ -258,27 +232,6 @@ export async function checkDocumentWriteAccess(
processingStartedAt: document.processingStartedAt,
processingCompletedAt: document.processingCompletedAt,
knowledgeBaseId: document.knowledgeBaseId,
// Text tags
tag1: document.tag1,
tag2: document.tag2,
tag3: document.tag3,
tag4: document.tag4,
tag5: document.tag5,
tag6: document.tag6,
tag7: document.tag7,
// Number tags (5 slots)
number1: document.number1,
number2: document.number2,
number3: document.number3,
number4: document.number4,
number5: document.number5,
// Date tags (2 slots)
date1: document.date1,
date2: document.date2,
// Boolean tags (3 slots)
boolean1: document.boolean1,
boolean2: document.boolean2,
boolean3: document.boolean3,
})
.from(document)
.where(and(eq(document.id, documentId), isNull(document.deletedAt)))

View File

@@ -1,72 +1,32 @@
import { db } from '@sim/db'
import {
permissions,
workflow,
workflowExecutionLogs,
workflowExecutionSnapshots,
} from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('LogsByExecutionIdAPI')
export async function GET(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ executionId: string }> }
) {
const requestId = generateRequestId()
try {
const { executionId } = await params
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized execution data access attempt for: ${executionId}`)
return NextResponse.json(
{ error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
const authenticatedUserId = authResult.userId
logger.debug(
`[${requestId}] Fetching execution data for: ${executionId} (auth: ${authResult.authType})`
)
logger.debug(`Fetching execution data for: ${executionId}`)
// Get the workflow execution log to find the snapshot
const [workflowLog] = await db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost,
})
.select()
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, authenticatedUserId)
)
)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
if (!workflowLog) {
logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`)
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
}
// Get the workflow state snapshot
const [snapshot] = await db
.select()
.from(workflowExecutionSnapshots)
@@ -74,7 +34,6 @@ export async function GET(
.limit(1)
if (!snapshot) {
logger.warn(`[${requestId}] Workflow state snapshot not found for execution: ${executionId}`)
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
}
@@ -91,14 +50,14 @@ export async function GET(
},
}
logger.debug(`[${requestId}] Successfully fetched execution data for: ${executionId}`)
logger.debug(`Successfully fetched execution data for: ${executionId}`)
logger.debug(
`[${requestId}] Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
)
return NextResponse.json(response)
} catch (error) {
logger.error(`[${requestId}] Error fetching execution data:`, error)
logger.error('Error fetching execution data:', error)
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
}
}

View File

@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
workflowName: workflow.name,
}
let conditions: SQL | undefined = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
let conditions: SQL | undefined = eq(workflow.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, workflowExecutionLogs.workspaceId),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)

View File

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

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { permissions, workflowExecutionLogs } from '@sim/db/schema'
import { permissions, workflow, 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,17 +42,23 @@ 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, workflowExecutionLogs.workspaceId),
eq(permissions.entityId, workflow.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

@@ -3,10 +3,8 @@ import { memory, workflowBlocks } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
const logger = createLogger('MemoryByIdAPI')
@@ -67,65 +65,6 @@ const memoryPutBodySchema = z.object({
workflowId: z.string().uuid('Invalid workflow ID format'),
})
/**
* Validates authentication and workflow access for memory operations
* @param request - The incoming request
* @param workflowId - The workflow ID to check access for
* @param requestId - Request ID for logging
* @param action - 'read' for GET, 'write' for PUT/DELETE
* @returns Object with userId if successful, or error response if failed
*/
async function validateMemoryAccess(
request: NextRequest,
workflowId: string,
requestId: string,
action: 'read' | 'write'
): Promise<{ userId: string } | { error: NextResponse }> {
const authResult = await checkHybridAuth(request, {
requireWorkflowId: false,
})
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized memory ${action} attempt`)
return {
error: NextResponse.json(
{ success: false, error: { message: 'Authentication required' } },
{ status: 401 }
),
}
}
const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId)
if (!accessContext) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
return {
error: NextResponse.json(
{ success: false, error: { message: 'Workflow not found' } },
{ status: 404 }
),
}
}
const { isOwner, workspacePermission } = accessContext
const hasAccess =
action === 'read'
? isOwner || workspacePermission !== null
: isOwner || workspacePermission === 'write' || workspacePermission === 'admin'
if (!hasAccess) {
logger.warn(
`[${requestId}] User ${authResult.userId} denied ${action} access to workflow ${workflowId}`
)
return {
error: NextResponse.json(
{ success: false, error: { message: 'Access denied' } },
{ status: 403 }
),
}
}
return { userId: authResult.userId }
}
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -162,11 +101,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { workflowId: validatedWorkflowId } = validation.data
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'read')
if ('error' in accessCheck) {
return accessCheck.error
}
const memories = await db
.select()
.from(memory)
@@ -269,11 +203,6 @@ export async function DELETE(
const { workflowId: validatedWorkflowId } = validation.data
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
if ('error' in accessCheck) {
return accessCheck.error
}
const existingMemory = await db
.select({ id: memory.id })
.from(memory)
@@ -367,11 +296,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
const accessCheck = await validateMemoryAccess(request, validatedWorkflowId, requestId, 'write')
if ('error' in accessCheck) {
return accessCheck.error
}
const existingMemories = await db
.select()
.from(memory)

View File

@@ -28,7 +28,7 @@ const updateInvitationSchema = z.object({
// Get invitation details
export async function GET(
_request: NextRequest,
_req: NextRequest,
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params

View File

@@ -1,19 +1,16 @@
import { db } from '@sim/db'
import { templates } from '@sim/db/schema'
import { templates, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { verifySuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateApprovalAPI')
export const revalidate = 0
/**
* POST /api/templates/[id]/approve - Approve a template (super users only)
*/
// POST /api/templates/[id]/approve - Approve a template (super users only)
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -25,18 +22,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for approval: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to approved
await db
.update(templates)
.set({ status: 'approved', updatedAt: new Date() })
@@ -54,11 +56,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
/**
* DELETE /api/templates/[id]/approve - Unapprove a template (super users only)
*/
// POST /api/templates/[id]/reject - Reject a template (super users only)
export async function DELETE(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
@@ -71,18 +71,23 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to rejected
await db
.update(templates)
.set({ status: 'rejected', updatedAt: new Date() })

View File

@@ -1,142 +0,0 @@
import { db } from '@sim/db'
import { templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { verifyTemplateOwnership } from '@/lib/templates/permissions'
import { uploadFile } from '@/lib/uploads/core/storage-service'
import { isValidPng } from '@/lib/uploads/utils/validation'
const logger = createLogger('TemplateOGImageAPI')
/**
* PUT /api/templates/[id]/og-image
* Upload a pre-generated OG image for a template.
* Accepts base64-encoded image data in the request body.
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { authorized, error, status } = await verifyTemplateOwnership(
id,
session.user.id,
'admin'
)
if (!authorized) {
logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`)
return NextResponse.json({ error }, { status: status || 403 })
}
const body = await request.json()
const { imageData } = body
if (!imageData || typeof imageData !== 'string') {
return NextResponse.json(
{ error: 'Missing or invalid imageData (expected base64 string)' },
{ status: 400 }
)
}
const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData
const imageBuffer = Buffer.from(base64Data, 'base64')
if (!isValidPng(imageBuffer)) {
return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 })
}
const maxSize = 5 * 1024 * 1024
if (imageBuffer.length > maxSize) {
return NextResponse.json({ error: 'Image too large. Maximum size is 5MB.' }, { status: 400 })
}
const timestamp = Date.now()
const storageKey = `og-images/templates/${id}/${timestamp}.png`
logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`)
const uploadResult = await uploadFile({
file: imageBuffer,
fileName: storageKey,
contentType: 'image/png',
context: 'og-images',
preserveKey: true,
customKey: storageKey,
})
const baseUrl = getBaseUrl()
const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images`
await db
.update(templates)
.set({
ogImageUrl,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`)
return NextResponse.json({
success: true,
ogImageUrl,
})
} catch (error: unknown) {
logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error)
return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
}
}
/**
* DELETE /api/templates/[id]/og-image
* Remove the OG image for a template.
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { authorized, error, status } = await verifyTemplateOwnership(
id,
session.user.id,
'admin'
)
if (!authorized) {
logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`)
return NextResponse.json({ error }, { status: status || 403 })
}
await db
.update(templates)
.set({
ogImageUrl: null,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.info(`[${requestId}] Removed OG image for template ${id}`)
return NextResponse.json({ success: true })
} catch (error: unknown) {
logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error)
return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 })
}
}

View File

@@ -1,19 +1,16 @@
import { db } from '@sim/db'
import { templates } from '@sim/db/schema'
import { templates, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { verifySuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateRejectionAPI')
export const revalidate = 0
/**
* POST /api/templates/[id]/reject - Reject a template (super users only)
*/
// POST /api/templates/[id]/reject - Reject a template (super users only)
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -25,18 +22,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { isSuperUser } = await verifySuperUser(session.user.id)
if (!isSuperUser) {
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to rejected
await db
.update(templates)
.set({ status: 'rejected', updatedAt: new Date() })

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { templateCreators, templates, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { member, templateCreators, templates, workflow } from '@sim/db/schema'
import { and, eq, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -15,6 +15,7 @@ const logger = createLogger('TemplateByIdAPI')
export const revalidate = 0
// GET /api/templates/[id] - Retrieve a single template by ID
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
@@ -24,6 +25,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Fetching template: ${id}`)
// Fetch the template by ID with creator info
const result = await db
.select({
template: templates,
@@ -45,10 +47,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
creator: creator || undefined,
}
// Only show approved templates to non-authenticated users
if (!session?.user?.id && template.status !== 'approved') {
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Check if user has starred (only if authenticated)
let isStarred = false
if (session?.user?.id) {
const { templateStars } = await import('@sim/db/schema')
@@ -76,6 +80,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
} catch (viewError) {
// Log the error but don't fail the request
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
}
}
@@ -133,6 +138,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const { name, details, creatorId, tags, updateState } = validationResult.data
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
@@ -140,54 +146,32 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
const template = existingTemplate[0]
if (!template.creatorId) {
logger.warn(`[${requestId}] Template ${id} has no creator, denying update`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
template.creatorId,
'admin'
)
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
}
// No permission check needed - template updates only happen from within the workspace
// where the user is already editing the connected workflow
// Prepare update data - only include fields that were provided
const updateData: any = {
updatedAt: new Date(),
}
// Only update fields that were provided
if (name !== undefined) updateData.name = name
if (details !== undefined) updateData.details = details
if (tags !== undefined) updateData.tags = tags
if (creatorId !== undefined) updateData.creatorId = creatorId
if (updateState && template.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket-server/middleware/permissions')
const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess(
session.user.id,
template.workflowId
)
if (!hasWorkflowAccess) {
logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`)
return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 })
}
// Only update the state if explicitly requested and the template has a connected workflow
if (updateState && existingTemplate[0].workflowId) {
// Load the current workflow state from normalized tables
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId)
const normalizedData = await loadWorkflowFromNormalizedTables(existingTemplate[0].workflowId)
if (normalizedData) {
// Also fetch workflow variables
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, template.workflowId))
.where(eq(workflow.id, existingTemplate[0].workflowId))
.limit(1)
const currentState = {
@@ -199,15 +183,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(),
}
// Extract credential requirements from the new state
const requiredCredentials = extractRequiredCredentials(currentState)
// Sanitize the state before storing
const sanitizedState = sanitizeCredentials(currentState)
updateData.state = sanitizedState
updateData.requiredCredentials = requiredCredentials
logger.info(
`[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}`
`[${requestId}] Updating template state and credentials from current workflow: ${existingTemplate[0].workflowId}`
)
} else {
logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
@@ -247,6 +233,7 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch template
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existing.length === 0) {
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
@@ -255,21 +242,41 @@ export async function DELETE(
const template = existing[0]
if (!template.creatorId) {
logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Permission: Only admin/owner of creator profile can delete
if (template.creatorId) {
const creatorProfile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, template.creatorId))
.limit(1)
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
template.creatorId,
'admin'
)
if (creatorProfile.length > 0) {
const creator = creatorProfile[0]
let hasPermission = false
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
if (creator.referenceType === 'user') {
hasPermission = creator.referenceId === session.user.id
} else if (creator.referenceType === 'organization') {
// For delete, require admin/owner role
const membership = await db
.select()
.from(member)
.where(
and(
eq(member.userId, session.user.id),
eq(member.organizationId, creator.referenceId),
or(eq(member.role, 'admin'), eq(member.role, 'owner'))
)
)
.limit(1)
hasPermission = membership.length > 0
}
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}
}
await db.delete(templates).where(eq(templates.id, id))

View File

@@ -1,5 +1,6 @@
import { db } from '@sim/db'
import {
member,
templateCreators,
templateStars,
templates,
@@ -203,18 +204,51 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
const { hasPermission, error: permissionError } = await verifyCreatorPermission(
session.user.id,
data.creatorId,
'member'
)
// Validate creator profile - required for all templates
const creatorProfile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, data.creatorId))
.limit(1)
if (!hasPermission) {
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
if (creatorProfile.length === 0) {
logger.warn(`[${requestId}] Creator profile not found: ${data.creatorId}`)
return NextResponse.json({ error: 'Creator profile not found' }, { status: 404 })
}
const creator = creatorProfile[0]
// Verify user has permission to use this creator profile
if (creator.referenceType === 'user') {
if (creator.referenceId !== session.user.id) {
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
return NextResponse.json(
{ error: 'You do not have permission to use this creator profile' },
{ status: 403 }
)
}
} else if (creator.referenceType === 'organization') {
// Verify user is a member of the organization
const membership = await db
.select()
.from(member)
.where(
and(eq(member.userId, session.user.id), eq(member.organizationId, creator.referenceId))
)
.limit(1)
if (membership.length === 0) {
logger.warn(
`[${requestId}] User not a member of organization for creator: ${data.creatorId}`
)
return NextResponse.json(
{ error: 'You must be a member of the organization to use its creator profile' },
{ status: 403 }
)
}
}
// Create the template
const templateId = uuidv4()
const now = new Date()

View File

@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -109,14 +108,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
if (folderId) {
const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50)
if (!folderIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error })
return NextResponse.json({ error: folderIdValidation.error }, { status: 400 })
}
}
const qParts: string[] = ['trashed = false']
if (folderId) {
qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`)

View File

@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
@@ -51,29 +50,6 @@ export async function POST(request: NextRequest) {
.map((id) => id.trim())
.filter((id) => id.length > 0)
for (const labelId of labelIds) {
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
if (!labelIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
return NextResponse.json(
{
success: false,
error: labelIdValidation.error,
},
{ status: 400 }
)
}
}
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
if (!messageIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
return NextResponse.json(
{ success: false, error: messageIdValidation.error },
{ status: 400 }
)
}
const gmailResponse = await fetch(
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
{

View File

@@ -3,7 +3,6 @@ import { account } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -39,12 +38,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
let credentials = await db
.select()
.from(account)

View File

@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
@@ -54,29 +53,6 @@ export async function POST(request: NextRequest) {
.map((id) => id.trim())
.filter((id) => id.length > 0)
for (const labelId of labelIds) {
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
if (!labelIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`)
return NextResponse.json(
{
success: false,
error: labelIdValidation.error,
},
{ status: 400 }
)
}
}
const messageIdValidation = validateAlphanumericId(validatedData.messageId, 'messageId', 255)
if (!messageIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid message ID: ${messageIdValidation.error}`)
return NextResponse.json(
{ success: false, error: messageIdValidation.error },
{ status: 400 }
)
}
const gmailResponse = await fetch(
`${GMAIL_API_BASE}/messages/${validatedData.messageId}/modify`,
{

View File

@@ -1,6 +1,5 @@
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateUUID } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -26,6 +25,7 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Calendar calendars request received`)
try {
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId') || undefined
@@ -34,25 +34,12 @@ export async function GET(request: NextRequest) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialValidation = validateUUID(credentialId, 'credentialId')
if (!credentialValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId })
return NextResponse.json({ error: credentialValidation.error }, { status: 400 })
}
if (workflowId) {
const workflowValidation = validateUUID(workflowId, 'workflowId')
if (!workflowValidation.isValid) {
logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId })
return NextResponse.json({ error: workflowValidation.error }, { status: 400 })
}
}
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
@@ -63,6 +50,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Fetch calendars from Google Calendar API
logger.info(`[${requestId}] Fetching calendars from Google Calendar API`)
const calendarResponse = await fetch(
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
@@ -93,6 +81,7 @@ export async function GET(request: NextRequest) {
const data = await calendarResponse.json()
const calendars: CalendarListItem[] = data.items || []
// Sort calendars with primary first, then alphabetically
calendars.sort((a, b) => {
if (a.primary && !b.primary) return -1
if (!a.primary && b.primary) return 1

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -24,12 +23,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Team ID is required' }, { status: 400 })
}
const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID')
if (!teamIdValidation.isValid) {
logger.warn('Invalid team ID provided', { teamId, error: teamIdValidation.error })
return NextResponse.json({ error: teamIdValidation.error }, { status: 400 })
}
try {
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
@@ -77,6 +70,7 @@ export async function POST(request: Request) {
endpoint: `https://graph.microsoft.com/v1.0/teams/${teamId}/channels`,
})
// Check for auth errors specifically
if (response.status === 401) {
return NextResponse.json(
{
@@ -99,6 +93,7 @@ export async function POST(request: Request) {
} catch (innerError) {
logger.error('Error during API requests:', innerError)
// Check if it's an authentication error
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
if (
errorMessage.includes('auth') ||

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -8,35 +7,21 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('TeamsChatsAPI')
/**
* Helper function to get chat members and create a meaningful name
*
* @param chatId - Microsoft Teams chat ID to get display name for
* @param accessToken - Access token for Microsoft Graph API
* @param chatTopic - Optional existing chat topic
* @returns A meaningful display name for the chat
*/
// Helper function to get chat members and create a meaningful name
const getChatDisplayName = async (
chatId: string,
accessToken: string,
chatTopic?: string
): Promise<string> => {
try {
const chatIdValidation = validateMicrosoftGraphId(chatId, 'chatId')
if (!chatIdValidation.isValid) {
logger.warn('Invalid chat ID in getChatDisplayName', {
error: chatIdValidation.error,
chatId: chatId.substring(0, 50),
})
return `Chat ${chatId.substring(0, 8)}...`
}
// If the chat already has a topic, use it
if (chatTopic?.trim() && chatTopic !== 'null') {
return chatTopic
}
// Fetch chat members to create a meaningful name
const membersResponse = await fetch(
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`,
`https://graph.microsoft.com/v1.0/chats/${chatId}/members`,
{
method: 'GET',
headers: {
@@ -50,25 +35,27 @@ const getChatDisplayName = async (
const membersData = await membersResponse.json()
const members = membersData.value || []
// Filter out the current user and get display names
const memberNames = members
.filter((member: any) => member.displayName && member.displayName !== 'Unknown')
.map((member: any) => member.displayName)
.slice(0, 3)
.slice(0, 3) // Limit to first 3 names to avoid very long names
if (memberNames.length > 0) {
if (memberNames.length === 1) {
return memberNames[0]
return memberNames[0] // 1:1 chat
}
if (memberNames.length === 2) {
return memberNames.join(' & ')
return memberNames.join(' & ') // 2-person group
}
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more`
return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` // Larger group
}
}
// Fallback: try to get a better name from recent messages
try {
const messagesResponse = await fetch(
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=10&$orderby=createdDateTime desc`,
`https://graph.microsoft.com/v1.0/chats/${chatId}/messages?$top=10&$orderby=createdDateTime desc`,
{
method: 'GET',
headers: {
@@ -82,12 +69,14 @@ const getChatDisplayName = async (
const messagesData = await messagesResponse.json()
const messages = messagesData.value || []
// Look for chat rename events
for (const message of messages) {
if (message.eventDetail?.chatDisplayName) {
return message.eventDetail.chatDisplayName
}
}
// Get unique sender names from recent messages as last resort
const senderNames = [
...new Set(
messages
@@ -114,6 +103,7 @@ const getChatDisplayName = async (
)
}
// Final fallback
return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...`
} catch (error) {
logger.warn(
@@ -156,6 +146,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}
// Now try to fetch the chats
const response = await fetch('https://graph.microsoft.com/v1.0/me/chats', {
method: 'GET',
headers: {
@@ -172,6 +163,7 @@ export async function POST(request: Request) {
endpoint: 'https://graph.microsoft.com/v1.0/me/chats',
})
// Check for auth errors specifically
if (response.status === 401) {
return NextResponse.json(
{
@@ -187,6 +179,7 @@ export async function POST(request: Request) {
const data = await response.json()
// Process chats with enhanced display names
const chats = await Promise.all(
data.value.map(async (chat: any) => ({
id: chat.id,
@@ -200,6 +193,7 @@ export async function POST(request: Request) {
} catch (innerError) {
logger.error('Error during API requests:', innerError)
// Check if it's an authentication error
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
if (
errorMessage.includes('auth') ||

View File

@@ -30,41 +30,23 @@ export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
return client
}
/**
* Recursively checks an object for dangerous MongoDB operators
* @param obj - The object to check
* @param dangerousOperators - Array of operator names to block
* @returns true if a dangerous operator is found
*/
function containsDangerousOperator(obj: unknown, dangerousOperators: string[]): boolean {
if (typeof obj !== 'object' || obj === null) return false
for (const key of Object.keys(obj as Record<string, unknown>)) {
if (dangerousOperators.includes(key)) return true
if (
typeof (obj as Record<string, unknown>)[key] === 'object' &&
containsDangerousOperator((obj as Record<string, unknown>)[key], dangerousOperators)
) {
return true
}
}
return false
}
export function validateFilter(filter: string): { isValid: boolean; error?: string } {
try {
const parsed = JSON.parse(filter)
const dangerousOperators = [
'$where', // Executes arbitrary JavaScript
'$regex', // Can cause ReDoS attacks
'$expr', // Expression evaluation
'$function', // Custom JavaScript functions
'$accumulator', // Custom JavaScript accumulators
'$let', // Variable definitions that could be exploited
]
const dangerousOperators = ['$where', '$regex', '$expr', '$function', '$accumulator', '$let']
if (containsDangerousOperator(parsed, dangerousOperators)) {
const checkForDangerousOps = (obj: any): boolean => {
if (typeof obj !== 'object' || obj === null) return false
for (const key of Object.keys(obj)) {
if (dangerousOperators.includes(key)) return true
if (typeof obj[key] === 'object' && checkForDangerousOps(obj[key])) return true
}
return false
}
if (checkForDangerousOps(parsed)) {
return {
isValid: false,
error: 'Filter contains potentially dangerous operators',
@@ -92,19 +74,29 @@ export function validatePipeline(pipeline: string): { isValid: boolean; error?:
}
const dangerousOperators = [
'$where', // Executes arbitrary JavaScript
'$function', // Custom JavaScript functions
'$accumulator', // Custom JavaScript accumulators
'$let', // Variable definitions that could be exploited
'$merge', // Writes to external collections
'$out', // Writes to external collections
'$currentOp', // Exposes system operation info
'$listSessions', // Exposes session info
'$listLocalSessions', // Exposes local session info
'$where',
'$function',
'$accumulator',
'$let',
'$merge',
'$out',
'$currentOp',
'$listSessions',
'$listLocalSessions',
]
const checkPipelineStage = (stage: any): boolean => {
if (typeof stage !== 'object' || stage === null) return false
for (const key of Object.keys(stage)) {
if (dangerousOperators.includes(key)) return true
if (typeof stage[key] === 'object' && checkPipelineStage(stage[key])) return true
}
return false
}
for (const stage of parsed) {
if (containsDangerousOperator(stage, dangerousOperators)) {
if (checkPipelineStage(stage)) {
return {
isValid: false,
error: 'Pipeline contains potentially dangerous operators',

View File

@@ -98,45 +98,15 @@ export function buildDeleteQuery(table: string, where: string) {
return { query, values: [] }
}
/**
* Validates a WHERE clause to prevent SQL injection attacks
* @param where - The WHERE clause string to validate
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
*/
function validateWhereClause(where: string): void {
const dangerousPatterns = [
// DDL and DML injection via stacked queries
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
// Union-based injection
/union\s+(all\s+)?select/i,
// File operations
/union\s+select/i,
/into\s+outfile/i,
/into\s+dumpfile/i,
/load_file\s*\(/i,
// Comment-based injection (can truncate query)
/load_file/i,
/--/,
/\/\*/,
/\*\//,
// Tautologies - always true/false conditions using backreferences
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\bor\s+true\b/i,
/\bor\s+false\b/i,
// AND tautologies (less common but still used in attacks)
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\band\s+true\b/i,
/\band\s+false\b/i,
// Time-based blind injection
/\bsleep\s*\(/i,
/\bbenchmark\s*\(/i,
/\bwaitfor\s+delay/i,
// Stacked queries (any statement after semicolon)
/;\s*\w+/,
// Information schema queries
/information_schema/i,
/mysql\./i,
// System functions and procedures
/\bxp_cmdshell/i,
]
for (const pattern of dangerousPatterns) {

View File

@@ -4,7 +4,6 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -37,12 +36,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
logger.info(`[${requestId}] Fetching credential`, { credentialId })
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)

View File

@@ -4,7 +4,6 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -34,12 +33,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId')
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -55,6 +48,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build URL for OneDrive folders
let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50`
if (query) {
@@ -77,7 +71,7 @@ export async function GET(request: NextRequest) {
const data = await response.json()
const folders = (data.value || [])
.filter((item: MicrosoftGraphDriveItem) => item.folder)
.filter((item: MicrosoftGraphDriveItem) => item.folder) // Only folders
.map((folder: MicrosoftGraphDriveItem) => ({
id: folder.id,
name: folder.name,

View File

@@ -2,7 +2,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -29,9 +28,9 @@ const ExcelValuesSchema = z.union([
const OneDriveUploadSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
fileName: z.string().min(1, 'File name is required'),
file: z.any().optional(),
file: z.any().optional(), // UserFile object (optional for blank Excel creation)
folderId: z.string().optional().nullable(),
mimeType: z.string().nullish(),
mimeType: z.string().nullish(), // Accept string, null, or undefined
values: ExcelValuesSchema.optional().nullable(),
})
@@ -63,19 +62,24 @@ export async function POST(request: NextRequest) {
let fileBuffer: Buffer
let mimeType: string
// Check if we're creating a blank Excel file
const isExcelCreation =
validatedData.mimeType ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' && !validatedData.file
if (isExcelCreation) {
// Create a blank Excel workbook
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet([[]])
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
// Generate XLSX file as buffer
const xlsxBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
fileBuffer = Buffer.from(xlsxBuffer)
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
} else {
// Handle regular file upload
const rawFile = validatedData.file
if (!rawFile) {
@@ -104,6 +108,7 @@ export async function POST(request: NextRequest) {
fileToProcess = rawFile
}
// Convert to UserFile format
let userFile
try {
userFile = processSingleFileToUserFile(fileToProcess, requestId, logger)
@@ -133,7 +138,7 @@ export async function POST(request: NextRequest) {
mimeType = userFile.type || 'application/octet-stream'
}
const maxSize = 250 * 1024 * 1024
const maxSize = 250 * 1024 * 1024 // 250MB
if (fileBuffer.length > maxSize) {
const sizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2)
logger.warn(`[${requestId}] File too large: ${sizeMB}MB`)
@@ -146,6 +151,7 @@ export async function POST(request: NextRequest) {
)
}
// Ensure file name has an appropriate extension
let fileName = validatedData.fileName
const hasExtension = fileName.includes('.') && fileName.lastIndexOf('.') > 0
@@ -163,17 +169,6 @@ export async function POST(request: NextRequest) {
const folderId = validatedData.folderId?.trim()
if (folderId && folderId !== '') {
const folderIdValidation = validateMicrosoftGraphId(folderId, 'folderId')
if (!folderIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid folder ID`, { error: folderIdValidation.error })
return NextResponse.json(
{
success: false,
error: folderIdValidation.error,
},
{ status: 400 }
)
}
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content`
} else {
uploadUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/root:/${encodeURIComponent(fileName)}:/content`
@@ -202,12 +197,14 @@ export async function POST(request: NextRequest) {
const fileData = await uploadResponse.json()
// If this is an Excel creation and values were provided, write them using the Excel API
let excelWriteResult: any | undefined
const shouldWriteExcelContent =
isExcelCreation && Array.isArray(excelValues) && excelValues.length > 0
if (shouldWriteExcelContent) {
try {
// Create a workbook session to ensure reliability and persistence of changes
let workbookSessionId: string | undefined
const sessionResp = await fetch(
`${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(fileData.id)}/workbook/createSession`,
@@ -226,6 +223,7 @@ export async function POST(request: NextRequest) {
workbookSessionId = sessionData?.id
}
// Determine the first worksheet name
let sheetName = 'Sheet1'
try {
const listUrl = `${MICROSOFT_GRAPH_BASE}/me/drive/items/${encodeURIComponent(
@@ -274,6 +272,7 @@ export async function POST(request: NextRequest) {
return paddedRow
})
// Compute concise end range from A1 and matrix size (no network round-trip)
const indexToColLetters = (index: number): string => {
let n = index
let s = ''
@@ -314,6 +313,7 @@ export async function POST(request: NextRequest) {
statusText: excelWriteResponse?.statusText,
error: errorText,
})
// Do not fail the entire request; return upload success with write error details
excelWriteResult = {
success: false,
error: `Excel write failed: ${excelWriteResponse?.statusText || 'unknown'}`,
@@ -321,6 +321,7 @@ export async function POST(request: NextRequest) {
}
} else {
const writeData = await excelWriteResponse.json()
// The Range PATCH returns a Range object; log address and values length
const addr = writeData.address || writeData.addressLocal
const v = writeData.values || []
excelWriteResult = {
@@ -332,6 +333,7 @@ export async function POST(request: NextRequest) {
}
}
// Attempt to close the workbook session if one was created
if (workbookSessionId) {
try {
const closeResp = await fetch(

View File

@@ -3,7 +3,6 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -30,13 +29,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId')
if (!credentialIdValidation.isValid) {
logger.warn('Invalid credentialId format', { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
try {
// Ensure we have a session for permission checks
const sessionUserId = session?.user?.id || ''
if (!sessionUserId) {
@@ -44,6 +38,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Resolve the credential owner to support collaborator-owned credentials
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!creds.length) {
logger.warn('Credential not found', { credentialId })
@@ -84,6 +79,7 @@ export async function GET(request: Request) {
endpoint: 'https://graph.microsoft.com/v1.0/me/mailFolders',
})
// Check for auth errors specifically
if (response.status === 401) {
return NextResponse.json(
{
@@ -100,6 +96,7 @@ export async function GET(request: Request) {
const data = await response.json()
const folders = data.value || []
// Transform folders to match the expected format
const transformedFolders = folders.map((folder: OutlookFolder) => ({
id: folder.id,
name: folder.displayName,
@@ -114,6 +111,7 @@ export async function GET(request: Request) {
} catch (innerError) {
logger.error('Error during API requests:', innerError)
// Check if it's an authentication error
const errorMessage = innerError instanceof Error ? innerError.message : String(innerError)
if (
errorMessage.includes('auth') ||

View File

@@ -64,46 +64,15 @@ export function sanitizeIdentifier(identifier: string): string {
return sanitizeSingleIdentifier(identifier)
}
/**
* Validates a WHERE clause to prevent SQL injection attacks
* @param where - The WHERE clause string to validate
* @throws {Error} If the WHERE clause contains potentially dangerous patterns
*/
function validateWhereClause(where: string): void {
const dangerousPatterns = [
// DDL and DML injection via stacked queries
/;\s*(drop|delete|insert|update|create|alter|grant|revoke)/i,
// Union-based injection
/union\s+(all\s+)?select/i,
// File operations
/union\s+select/i,
/into\s+outfile/i,
/load_file\s*\(/i,
/pg_read_file/i,
// Comment-based injection (can truncate query)
/load_file/i,
/--/,
/\/\*/,
/\*\//,
// Tautologies - always true/false conditions using backreferences
// Matches OR 'x'='x' or OR x=x (same value both sides) but NOT OR col='value'
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\bor\s+true\b/i,
/\bor\s+false\b/i,
// AND tautologies (less common but still used in attacks)
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
/\band\s+true\b/i,
/\band\s+false\b/i,
// Time-based blind injection
/\bsleep\s*\(/i,
/\bwaitfor\s+delay/i,
/\bpg_sleep\s*\(/i,
/\bbenchmark\s*\(/i,
// Stacked queries (any statement after semicolon)
/;\s*\w+/,
// Information schema / system catalog queries
/information_schema/i,
/pg_catalog/i,
// System functions and procedures
/\bxp_cmdshell/i,
]
for (const pattern of dangerousPatterns) {

View File

@@ -4,7 +4,6 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { SharepointSite } from '@/tools/sharepoint/types'
@@ -33,12 +32,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -54,6 +47,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build URL for SharePoint sites
// Use search=* to get all sites the user has access to, or search for specific query
const searchQuery = query || '*'
const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50`

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -94,6 +93,7 @@ export async function POST(request: Request) {
}
}
// Filter to channels the bot can access and format the response
const channels = (data.channels || [])
.filter((channel: SlackChannel) => {
const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private)
@@ -106,28 +106,6 @@ export async function POST(request: Request) {
return canAccess
})
.filter((channel: SlackChannel) => {
const validation = validateAlphanumericId(channel.id, 'channelId', 50)
if (!validation.isValid) {
logger.warn('Invalid channel ID received from Slack API', {
channelId: channel.id,
channelName: channel.name,
error: validation.error,
})
return false
}
if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) {
logger.warn('Channel ID does not match Slack format', {
channelId: channel.id,
channelName: channel.name,
})
return false
}
return true
})
.map((channel: SlackChannel) => ({
id: channel.id,
name: channel.name,

View File

@@ -14,12 +14,7 @@ const SlackReadMessagesSchema = z
accessToken: z.string().min(1, 'Access token is required'),
channel: z.string().optional().nullable(),
userId: z.string().optional().nullable(),
limit: z.coerce
.number()
.min(1, 'Limit must be at least 1')
.max(15, 'Limit cannot exceed 15')
.optional()
.nullable(),
limit: z.number().optional().nullable(),
oldest: z.string().optional().nullable(),
latest: z.string().optional().nullable(),
})
@@ -67,8 +62,8 @@ export async function POST(request: NextRequest) {
const url = new URL('https://slack.com/api/conversations.history')
url.searchParams.append('channel', channel!)
const limit = validatedData.limit ?? 10
url.searchParams.append('limit', String(limit))
const limit = validatedData.limit ? Number(validatedData.limit) : 10
url.searchParams.append('limit', String(Math.min(limit, 15)))
if (validatedData.oldest) {
url.searchParams.append('oldest', validatedData.oldest)

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -21,21 +20,13 @@ export async function POST(request: Request) {
try {
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId, userId } = body
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (userId !== undefined && userId !== null) {
const validation = validateAlphanumericId(userId, 'userId', 100)
if (!validation.isValid) {
logger.warn('Invalid Slack user ID', { userId, error: validation.error })
return NextResponse.json({ error: validation.error }, { status: 400 })
}
}
let accessToken: string
const isBotToken = credential.startsWith('xoxb-')
@@ -72,17 +63,6 @@ export async function POST(request: Request) {
logger.info('Using OAuth token for Slack API')
}
if (userId) {
const userData = await fetchSlackUser(accessToken, userId)
const user = {
id: userData.user.id,
name: userData.user.name,
real_name: userData.user.real_name || userData.user.name,
}
logger.info(`Successfully fetched Slack user: ${userId}`)
return NextResponse.json({ user })
}
const data = await fetchSlackUsers(accessToken)
const users = (data.members || [])
@@ -107,31 +87,6 @@ export async function POST(request: Request) {
}
}
async function fetchSlackUser(accessToken: string, userId: string) {
const url = new URL('https://slack.com/api/users.info')
url.searchParams.append('user', userId)
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (!data.ok) {
throw new Error(data.error || 'Failed to fetch user')
}
return data
}
async function fetchSlackUsers(accessToken: string) {
const url = new URL('https://slack.com/api/users.list')
url.searchParams.append('limit', '200')

View File

@@ -1,7 +1,4 @@
import { type Attributes, Client, type ConnectConfig } from 'ssh2'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SSHUtils')
// File type constants from POSIX
const S_IFMT = 0o170000 // bit mask for the file type bit field
@@ -35,6 +32,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
const host = config.host
const port = config.port
// Connection refused - server not running or wrong port
if (errorMessage.includes('econnrefused') || errorMessage.includes('connection refused')) {
return new Error(
`Connection refused to ${host}:${port}. ` +
@@ -44,6 +42,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Connection reset - server closed connection unexpectedly
if (errorMessage.includes('econnreset') || errorMessage.includes('connection reset')) {
return new Error(
`Connection reset by ${host}:${port}. ` +
@@ -54,6 +53,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Timeout - server unreachable or slow
if (errorMessage.includes('etimedout') || errorMessage.includes('timeout')) {
return new Error(
`Connection timed out to ${host}:${port}. ` +
@@ -63,6 +63,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// DNS/hostname resolution
if (errorMessage.includes('enotfound') || errorMessage.includes('getaddrinfo')) {
return new Error(
`Could not resolve hostname "${host}". ` +
@@ -70,6 +71,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Authentication failure
if (errorMessage.includes('authentication') || errorMessage.includes('auth')) {
return new Error(
`Authentication failed for user on ${host}:${port}. ` +
@@ -79,6 +81,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Private key format issues
if (
errorMessage.includes('key') &&
(errorMessage.includes('parse') || errorMessage.includes('invalid'))
@@ -90,6 +93,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Host key verification (first connection)
if (errorMessage.includes('host key') || errorMessage.includes('hostkey')) {
return new Error(
`Host key verification issue for ${host}. ` +
@@ -97,6 +101,7 @@ function formatSSHError(err: Error, config: { host: string; port: number }): Err
)
}
// Return original error with context if no specific match
return new Error(`SSH connection to ${host}:${port} failed: ${err.message}`)
}
@@ -200,119 +205,19 @@ export function executeSSHCommand(client: Client, command: string): Promise<SSHC
/**
* Sanitize command input to prevent command injection
*
* Removes null bytes and other dangerous control characters while preserving
* legitimate shell syntax. Logs warnings for potentially dangerous patterns.
*
* Note: This function does not block complex shell commands (pipes, redirects, etc.)
* as users legitimately need these features for remote command execution.
*
* @param command - The command to sanitize
* @returns The sanitized command string
*
* @example
* ```typescript
* const safeCommand = sanitizeCommand(userInput)
* // Use safeCommand for SSH execution
* ```
*/
export function sanitizeCommand(command: string): string {
let sanitized = command.replace(/\0/g, '')
sanitized = sanitized.replace(/[\x0B\x0C]/g, '')
sanitized = sanitized.trim()
const dangerousPatterns = [
{ pattern: /\$\(.*\)/, name: 'command substitution $()' },
{ pattern: /`.*`/, name: 'backtick command substitution' },
{ pattern: /;\s*rm\s+-rf/i, name: 'destructive rm -rf command' },
{ pattern: /;\s*dd\s+/i, name: 'dd command (disk operations)' },
{ pattern: /mkfs/i, name: 'filesystem formatting command' },
{ pattern: />\s*\/dev\/sd[a-z]/i, name: 'direct disk write' },
]
for (const { pattern, name } of dangerousPatterns) {
if (pattern.test(sanitized)) {
logger.warn(`Command contains ${name}`, {
command: sanitized.substring(0, 100) + (sanitized.length > 100 ? '...' : ''),
})
}
}
return sanitized
return command.trim()
}
/**
* Sanitize and validate file path to prevent path traversal attacks
*
* This function validates that a file path does not contain:
* - Null bytes
* - Path traversal sequences (.. or ../)
* - URL-encoded path traversal attempts
*
* @param path - The file path to sanitize and validate
* @returns The sanitized path if valid
* @throws Error if path traversal is detected
*
* @example
* ```typescript
* try {
* const safePath = sanitizePath(userInput)
* // Use safePath safely
* } catch (error) {
* // Handle invalid path
* }
* ```
* Sanitize file path - removes null bytes and trims whitespace
*/
export function sanitizePath(path: string): string {
let sanitized = path.replace(/\0/g, '')
sanitized = sanitized.trim()
if (sanitized.includes('%00')) {
logger.warn('Path contains URL-encoded null bytes', {
path: path.substring(0, 100),
})
throw new Error('Path contains invalid characters')
}
const pathTraversalPatterns = [
'../', // Standard Unix path traversal
'..\\', // Windows path traversal
'/../', // Mid-path traversal
'\\..\\', // Windows mid-path traversal
'%2e%2e%2f', // Fully encoded ../
'%2e%2e/', // Partially encoded ../
'%2e%2e%5c', // Fully encoded ..\
'%2e%2e\\', // Partially encoded ..\
'..%2f', // .. with encoded /
'..%5c', // .. with encoded \
'%252e%252e', // Double URL encoded ..
'..%252f', // .. with double encoded /
'..%255c', // .. with double encoded \
]
const lowerPath = sanitized.toLowerCase()
for (const pattern of pathTraversalPatterns) {
if (lowerPath.includes(pattern.toLowerCase())) {
logger.warn('Path traversal attempt detected', {
pattern,
path: path.substring(0, 100),
})
throw new Error('Path contains invalid path traversal sequences')
}
}
const segments = sanitized.split(/[/\\]/)
for (const segment of segments) {
if (segment === '..') {
logger.warn('Path traversal attempt detected (.. as path segment)', {
path: path.substring(0, 100),
})
throw new Error('Path contains invalid path traversal sequences')
}
}
return sanitized
}

View File

@@ -1,7 +1,6 @@
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'
@@ -189,7 +188,7 @@ export async function POST(request: NextRequest) {
if (variablesObject && Object.keys(variablesObject).length > 0) {
const safeVarKeys = Object.keys(variablesObject).map((key) => {
return isSensitiveKey(key) ? `${key}: ${REDACTED_MARKER}` : key
return key.toLowerCase().includes('password') ? `${key}: [REDACTED]` : key
})
logger.info('Variables available for task', { variables: safeVarKeys })
}

View File

@@ -3,7 +3,6 @@ import { account } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -12,6 +11,7 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('WealthboxItemsAPI')
// Interface for transformed Wealthbox items
interface WealthboxItem {
id: string
name: string
@@ -45,23 +45,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialIdValidation = validatePathSegment(credentialId, {
paramName: 'credentialId',
maxLength: 100,
allowHyphens: true,
allowUnderscores: true,
allowDots: false,
})
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`)
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const ALLOWED_TYPES = ['contact'] as const
const typeValidation = validateEnum(type, ALLOWED_TYPES, 'type')
if (!typeValidation.isValid) {
if (type !== 'contact') {
logger.warn(`[${requestId}] Invalid item type: ${type}`)
return NextResponse.json({ error: typeValidation.error }, { status: 400 })
return NextResponse.json(
{ error: 'Invalid item type. Only contact is supported.' },
{ status: 400 }
)
}
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)

View File

@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -13,21 +12,13 @@ export async function POST(request: Request) {
try {
const requestId = generateRequestId()
const body = await request.json()
const { credential, workflowId, siteId } = body
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (siteId) {
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
if (!siteIdValidation.isValid) {
logger.error('Invalid siteId', { error: siteIdValidation.error })
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
@@ -55,11 +46,7 @@ export async function POST(request: Request) {
)
}
const url = siteId
? `https://api.webflow.com/v2/sites/${siteId}`
: 'https://api.webflow.com/v2/sites'
const response = await fetch(url, {
const response = await fetch('https://api.webflow.com/v2/sites', {
headers: {
Authorization: `Bearer ${accessToken}`,
accept: 'application/json',
@@ -71,7 +58,6 @@ export async function POST(request: Request) {
logger.error('Failed to fetch Webflow sites', {
status: response.status,
error: errorData,
siteId: siteId || 'all',
})
return NextResponse.json(
{ error: 'Failed to fetch Webflow sites', details: errorData },
@@ -80,13 +66,7 @@ export async function POST(request: Request) {
}
const data = await response.json()
let sites: any[]
if (siteId) {
sites = [data]
} else {
sites = data.sites || []
}
const sites = data.sites || []
const formattedSites = sites.map((site: any) => ({
id: site.id,

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,7 +38,6 @@ const defaultSettings = {
showTrainingControls: false,
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
}
export async function GET() {
@@ -47,6 +46,7 @@ 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,6 +89,7 @@ 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`
@@ -102,6 +103,7 @@ export async function PATCH(request: Request) {
try {
const validatedData = SettingsSchema.parse(body)
// Store the settings
await db
.insert(settings)
.values({
@@ -133,6 +135,7 @@ 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

@@ -32,6 +32,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
}
// Verify token and get email type
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
@@ -41,6 +42,7 @@ export async function GET(req: NextRequest) {
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
// Get current preferences
const preferences = await getEmailPreferences(email)
logger.info(
@@ -65,42 +67,22 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const { searchParams } = new URL(req.url)
const contentType = req.headers.get('content-type') || ''
const body = await req.json()
const result = unsubscribeSchema.safeParse(body)
let email: string
let token: string
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
if (contentType.includes('application/x-www-form-urlencoded')) {
email = searchParams.get('email') || ''
token = searchParams.get('token') || ''
if (!email || !token) {
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
}
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
} else {
const body = await req.json()
const result = unsubscribeSchema.safeParse(body)
if (!result.success) {
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
errors: result.error.format(),
})
return NextResponse.json(
{ error: 'Invalid request data', details: result.error.format() },
{ status: 400 }
)
}
email = result.data.email
token = result.data.token
type = result.data.type
if (!result.success) {
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
errors: result.error.format(),
})
return NextResponse.json(
{ error: 'Invalid request data', details: result.error.format() },
{ status: 400 }
)
}
const { email, token, type } = result.data
// Verify token and get email type
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
@@ -110,6 +92,7 @@ export async function POST(req: NextRequest) {
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
// Prevent unsubscribing from transactional emails
if (isTransactional) {
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
return NextResponse.json(
@@ -123,6 +106,7 @@ export async function POST(req: NextRequest) {
)
}
// Process unsubscribe based on type
let success = false
switch (type) {
case 'all':
@@ -146,6 +130,7 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
// Return 200 for one-click unsubscribe compliance
return NextResponse.json(
{
success: true,

View File

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

View File

@@ -105,6 +105,7 @@ 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,
@@ -123,7 +124,13 @@ export async function GET(request: NextRequest) {
workflowDescription: workflow.description,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
workflow,
and(
eq(workflowExecutionLogs.workflowId, workflow.id),
eq(workflow.workspaceId, params.workspaceId)
)
)
.innerJoin(
permissions,
and(
@@ -190,8 +197,11 @@ 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,7 +4,6 @@ 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'
@@ -89,7 +88,7 @@ async function updateUserStatsForWand(
try {
const [workflowRecord] = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -102,7 +101,6 @@ 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
@@ -139,17 +137,6 @@ 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,16 +409,10 @@ 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,
workspaceId: workflow.workspaceId,
})
if (isAsyncMode) {
@@ -466,7 +460,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
const executionContext = {
workspaceId,
workspaceId: workflow.workspaceId || '',
workflowId,
executionId,
}
@@ -484,7 +478,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
await loggingSession.safeStart({
userId: actorUserId,
workspaceId,
workspaceId: workflow.workspaceId || '',
variables: {},
})
@@ -513,7 +507,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
executionId,
workflowId,
workspaceId,
workspaceId: workflow.workspaceId ?? undefined,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
@@ -595,7 +589,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflow: {
id: workflow.id,
userId: actorUserId,
workspaceId,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
variables: (workflow as any).variables,
},
@@ -781,7 +775,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
executionId,
workflowId,
workspaceId,
workspaceId: workflow.workspaceId ?? undefined,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,

View File

@@ -70,11 +70,7 @@ 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
if (!workspaceId) {
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
return createErrorResponse('Workflow has no associated workspace', 500)
}
const workspaceId = accessValidation.workflow.workspaceId || ''
await loggingSession.safeStart({
userId,

View File

@@ -14,7 +14,6 @@ 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(),
@@ -50,7 +49,6 @@ vi.mock('@sim/db', () => ({
db: {
delete: () => mockDbDelete(),
update: () => mockDbUpdate(),
select: () => mockDbSelect(),
},
workflow: {},
}))
@@ -329,13 +327,6 @@ 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' }]),
})
@@ -356,46 +347,6 @@ 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,21 +228,6 @@ 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

@@ -0,0 +1,97 @@
import { db } from '@sim/db'
import { userStats, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('WorkflowStatsAPI')
const queryParamsSchema = z.object({
runs: z.coerce.number().int().min(1).max(100).default(1),
})
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const searchParams = request.nextUrl.searchParams
const validation = queryParamsSchema.safeParse({
runs: searchParams.get('runs'),
})
if (!validation.success) {
logger.error(`Invalid query parameters: ${validation.error.message}`)
return NextResponse.json(
{
error:
validation.error.errors[0]?.message ||
'Invalid number of runs. Must be between 1 and 100.',
},
{ status: 400 }
)
}
const { runs } = validation.data
try {
const [workflowRecord] = await db.select().from(workflow).where(eq(workflow.id, id)).limit(1)
if (!workflowRecord) {
return NextResponse.json({ error: `Workflow ${id} not found` }, { status: 404 })
}
try {
await db
.update(workflow)
.set({
runCount: workflowRecord.runCount + runs,
lastRunAt: new Date(),
})
.where(eq(workflow.id, id))
} catch (error) {
logger.error('Error updating workflow runCount:', error)
throw error
}
try {
const userStatsRecords = await db
.select()
.from(userStats)
.where(eq(userStats.userId, workflowRecord.userId))
if (userStatsRecords.length === 0) {
await db.insert(userStats).values({
id: crypto.randomUUID(),
userId: workflowRecord.userId,
totalManualExecutions: 0,
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: 0,
totalCost: '0.00',
lastActive: sql`now()`,
})
} else {
await db
.update(userStats)
.set({
lastActive: sql`now()`,
})
.where(eq(userStats.userId, workflowRecord.userId))
}
} catch (error) {
logger.error(`Error ensuring userStats for userId ${workflowRecord.userId}:`, error)
// Don't rethrow - we want to continue even if this fails
}
return NextResponse.json({
success: true,
runsAdded: runs,
newTotal: workflowRecord.runCount + runs,
})
} catch (error) {
logger.error('Error updating workflow stats:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -98,6 +98,23 @@ 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)
@@ -185,6 +202,23 @@ 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

@@ -173,7 +173,7 @@ export async function GET(
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
export async function DELETE(
_request: NextRequest,
_req: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params
@@ -221,7 +221,7 @@ export async function DELETE(
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
export async function POST(
_request: NextRequest,
_req: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
const { invitationId } = await params

View File

@@ -29,24 +29,30 @@ export const metadata: Metadata = {
locale: 'en_US',
images: [
{
url: '/logo/primary/rounded.png',
width: 512,
height: 512,
alt: 'Sim - AI Agent Workflow Builder',
url: '/social/og-image.png',
width: 1200,
height: 630,
alt: 'Sim - Visual AI Workflow Builder',
type: 'image/png',
},
{
url: '/social/og-image-square.png',
width: 600,
height: 600,
alt: 'Sim Logo',
},
],
},
twitter: {
card: 'summary',
card: 'summary_large_image',
site: '@simdotai',
creator: '@simdotai',
title: 'Sim - AI Agent Workflow Builder | Open Source',
description:
'Open-source platform for agentic workflows. 60,000+ developers. Visual builder. 100+ integrations. SOC2 & HIPAA compliant.',
images: {
url: '/logo/primary/rounded.png',
alt: 'Sim - AI Agent Workflow Builder',
url: '/social/twitter-image.png',
alt: 'Sim - Visual AI Workflow Builder',
},
},
alternates: {
@@ -71,6 +77,7 @@ export const metadata: Metadata = {
category: 'technology',
classification: 'AI Development Tools',
referrer: 'origin-when-cross-origin',
// LLM SEO optimizations
other: {
'llm:content-type': 'AI workflow builder, visual programming, no-code AI development',
'llm:use-cases':

View File

@@ -1,88 +1,5 @@
import { db } from '@sim/db'
import { templateCreators, templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import TemplateDetails from '@/app/templates/[id]/template'
const logger = createLogger('TemplateMetadata')
/**
* Generate dynamic metadata for template pages.
* This provides OpenGraph images for social media sharing.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
try {
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
return {
title: 'Template Not Found',
description: 'The requested template could not be found.',
}
}
const { template, creator } = result[0]
const baseUrl = getBaseUrl()
const details = template.details as { tagline?: string; about?: string } | null
const description = details?.tagline || 'AI workflow template on Sim'
const hasOgImage = !!template.ogImageUrl
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
return {
title: template.name,
description,
openGraph: {
title: template.name,
description,
type: 'website',
url: `${baseUrl}/templates/${id}`,
siteName: 'Sim',
images: [
{
url: ogImageUrl,
width: hasOgImage ? 1200 : 512,
height: hasOgImage ? 630 : 512,
alt: `${template.name} - Workflow Preview`,
},
],
},
twitter: {
card: hasOgImage ? 'summary_large_image' : 'summary',
title: template.name,
description,
images: [ogImageUrl],
creator: creator?.details
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
: undefined,
},
}
} catch (error) {
logger.error('Failed to generate template metadata:', error)
return {
title: 'Template',
description: 'AI workflow template on Sim',
}
}
}
/**
* Public template detail page for unauthenticated users.
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.

View File

@@ -39,6 +39,7 @@ function UnsubscribeContent() {
return
}
// Validate the unsubscribe link
fetch(
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
@@ -80,7 +81,9 @@ function UnsubscribeContent() {
if (result.success) {
setUnsubscribed(true)
// Update the data to reflect the change
if (data) {
// Type-safe property construction with validation
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
@@ -189,6 +192,7 @@ function UnsubscribeContent() {
)
}
// Handle transactional emails
if (data?.isTransactional) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>

View File

@@ -5,7 +5,6 @@ import { Loader2 } from 'lucide-react'
import {
Button,
Combobox,
DatePicker,
Input,
Label,
Modal,
@@ -16,7 +15,7 @@ import {
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
import type { DocumentTag } from '@/lib/knowledge/tags/types'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -29,54 +28,6 @@ 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
@@ -116,21 +67,17 @@ export function DocumentTagsModal({
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
const tags: DocumentTag[] = []
ALL_TAG_SLOTS.forEach((slot) => {
const rawValue = docData[slot]
TAG_SLOTS.forEach((slot) => {
const value = docData[slot] as string | null | undefined
const definition = definitions.find((def) => def.tagSlot === slot)
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,
})
}
if (value?.trim() && definition) {
tags.push({
slot,
displayName: definition.displayName,
fieldType: definition.fieldType,
value: value.trim(),
})
}
})
@@ -148,15 +95,13 @@ export function DocumentTagsModal({
try {
const tagData: Record<string, string> = {}
// 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] = ''
TAG_SLOTS.forEach((slot) => {
tagData[slot] = ''
})
tagsToSave.forEach((tag) => {
if (tag.value.trim()) {
tagData[tag.slot] = tag.value.trim()
}
})
@@ -172,8 +117,8 @@ export function DocumentTagsModal({
throw new Error('Failed to update document tags')
}
updateDocumentInStore(knowledgeBaseId, documentId, tagData as Record<string, string>)
onDocumentUpdate?.(tagData as Record<string, string>)
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
onDocumentUpdate?.(tagData)
await fetchTagDefinitions()
} catch (error) {
@@ -334,7 +279,7 @@ export function DocumentTagsModal({
const newDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: formData.fieldType,
tagSlot: targetSlot as AllTagSlot,
tagSlot: targetSlot as TagSlot,
}
if (saveTagDefinitions) {
@@ -414,7 +359,20 @@ export function DocumentTagsModal({
<ModalBody className='!pb-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>Tags</Label>
<Label>
Tags{' '}
<span className='pl-[6px] text-[var(--text-tertiary)]'>
{documentTags.length}/{MAX_TAG_SLOTS} slots used
</span>
</Label>
{documentTags.length === 0 && !isCreatingTag && (
<div className='rounded-[6px] border p-[16px] text-center'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
No tags added yet. Add tags to help organize this document.
</p>
</div>
)}
{documentTags.map((tag, index) => (
<div key={index} className='space-y-[8px]'>
@@ -425,12 +383,9 @@ 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)]'>
{formatValueForDisplay(tag.value, tag.fieldType)}
{tag.value}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Button
@@ -460,16 +415,10 @@ export function DocumentTagsModal({
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
const newFieldType = def?.fieldType || 'text'
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: newFieldType,
value: getValueForFieldType(
newFieldType,
editTagForm.fieldType,
editTagForm.value
),
fieldType: def?.fieldType || 'text',
})
}}
placeholder='Enter or select tag name'
@@ -504,70 +453,33 @@ 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>
{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 })
<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()
}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
</div>
<div className='flex gap-[8px]'>
@@ -588,7 +500,7 @@ export function DocumentTagsModal({
</div>
))}
{documentTags.length > 0 && !isTagEditing && (
{!isTagEditing && (
<Button
variant='default'
onClick={openTagCreator}
@@ -599,7 +511,7 @@ export function DocumentTagsModal({
</Button>
)}
{(isCreatingTag || documentTags.length === 0) && editingTagIndex === null && (
{isCreatingTag && (
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagName'>Tag Name</Label>
@@ -613,16 +525,10 @@ export function DocumentTagsModal({
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
const newFieldType = def?.fieldType || 'text'
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: newFieldType,
value: getValueForFieldType(
newFieldType,
editTagForm.fieldType,
editTagForm.value
),
fieldType: def?.fieldType || 'text',
})
}}
placeholder='Enter or select tag name'
@@ -657,68 +563,31 @@ 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>
{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()
}
}}
/>
)}
<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 &&
@@ -735,11 +604,9 @@ export function DocumentTagsModal({
)}
<div className='flex gap-[8px]'>
{documentTags.length > 0 && (
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
)}
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
<Button
variant='primary'
onClick={saveDocumentTag}

View File

@@ -1,11 +1,9 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Modal,
@@ -16,7 +14,7 @@ import {
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import { createLogger } from '@/lib/logs/console/logger'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
@@ -26,14 +24,6 @@ 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
@@ -184,55 +174,22 @@ 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 {
// Check if selected field type has available slots
if (!hasAvailableSlots(createTagForm.fieldType)) {
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
}
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))
// 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')
if (!availableSlot) {
throw new Error('No available tag slots')
}
const newTagDefinition = {
tagSlot: slotResult.data.nextAvailableSlot,
tagSlot: availableSlot,
displayName: createTagForm.displayName.trim(),
fieldType: createTagForm.fieldType,
}
@@ -320,7 +277,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Label>
Tags:{' '}
<span className='pl-[6px] text-[var(--text-tertiary)]'>
{kbTagDefinitions.length} defined
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
</span>
</Label>
@@ -343,9 +300,6 @@ 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' : ''}
@@ -370,7 +324,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Button
variant='default'
onClick={openTagCreator}
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
className='w-full'
>
Add Tag
@@ -407,22 +361,12 @@ 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>
<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>
)}
<Input id='tagType' value='Text' disabled className='capitalize' />
</div>
*/}
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
@@ -432,11 +376,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
variant='primary'
onClick={saveTagDefinition}
className='flex-1'
disabled={
!canSaveTag() ||
isSavingTag ||
!hasAvailableSlots(createTagForm.fieldType)
}
disabled={!canSaveTag() || isSavingTag}
>
{isSavingTag ? (
<>

View File

@@ -339,31 +339,12 @@ 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='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
/>
<Label htmlFor='name'>Name</Label>
<Input
id='kb-name'
id='name'
placeholder='Enter knowledge base name'
{...register('name')}
className={cn(errors.name && 'border-[var(--text-error)]')}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>

View File

@@ -1,16 +1,8 @@
import { db } from '@sim/db'
import { templateCreators, templates } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import TemplateDetails from '@/app/templates/[id]/template'
const logger = createLogger('WorkspaceTemplateMetadata')
interface TemplatePageProps {
params: Promise<{
workspaceId: string
@@ -18,81 +10,6 @@ interface TemplatePageProps {
}>
}
/**
* Generate dynamic metadata for workspace template pages.
* This provides OpenGraph images for social media sharing.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ workspaceId: string; id: string }>
}): Promise<Metadata> {
const { workspaceId, id } = await params
try {
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
return {
title: 'Template Not Found',
description: 'The requested template could not be found.',
}
}
const { template, creator } = result[0]
const baseUrl = getBaseUrl()
const details = template.details as { tagline?: string; about?: string } | null
const description = details?.tagline || 'AI workflow template on Sim'
const hasOgImage = !!template.ogImageUrl
const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
return {
title: template.name,
description,
openGraph: {
title: template.name,
description,
type: 'website',
url: `${baseUrl}/workspace/${workspaceId}/templates/${id}`,
siteName: 'Sim',
images: [
{
url: ogImageUrl,
width: hasOgImage ? 1200 : 512,
height: hasOgImage ? 630 : 512,
alt: `${template.name} - Workflow Preview`,
},
],
},
twitter: {
card: hasOgImage ? 'summary_large_image' : 'summary',
title: template.name,
description,
images: [ogImageUrl],
creator: creator?.details
? ((creator.details as Record<string, unknown>).xHandle as string) || undefined
: undefined,
},
}
} catch (error) {
logger.error('Failed to generate workspace template metadata:', error)
return {
title: 'Template',
description: 'AI workflow template on Sim',
}
}
}
/**
* Workspace-scoped template detail page.
* Requires authentication and workspace membership to access.
@@ -102,10 +19,12 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params
const session = await getSession()
// Redirect unauthenticated users to public template detail page
if (!session?.user?.id) {
redirect(`/templates/${id}`)
}
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')

View File

@@ -2,4 +2,3 @@ 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

@@ -1,99 +0,0 @@
'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,7 +9,6 @@ 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 {
@@ -459,12 +458,6 @@ 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

@@ -1,6 +1,6 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
@@ -18,7 +18,6 @@ import { Skeleton, TagInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { createLogger } from '@/lib/logs/console/logger'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import {
useCreateTemplate,
@@ -26,7 +25,6 @@ import {
useTemplateByWorkflow,
useUpdateTemplate,
} from '@/hooks/queries/templates'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
@@ -81,9 +79,6 @@ export function TemplateDeploy({
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
@@ -213,8 +208,6 @@ export function TemplateDeploy({
tags: formData.tags,
}
let templateId: string
if (existingTemplate) {
await updateMutation.mutateAsync({
id: existingTemplate.id,
@@ -223,32 +216,11 @@ export function TemplateDeploy({
updateState: true,
},
})
templateId = existingTemplate.id
} else {
const result = await createMutation.mutateAsync({ ...templateData, workflowId })
templateId = result.id
await createMutation.mutateAsync({ ...templateData, workflowId })
}
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully`)
setIsCapturing(true)
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try {
if (ogCaptureRef.current) {
const ogUrl = await captureAndUploadOGImage(ogCaptureRef.current, templateId)
if (ogUrl) {
logger.info(`OG image uploaded for template ${templateId}: ${ogUrl}`)
}
}
} catch (ogError) {
logger.warn('Failed to capture/upload OG image:', ogError)
} finally {
setIsCapturing(false)
}
})
})
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
@@ -303,7 +275,6 @@ export function TemplateDeploy({
Live Template
</Label>
<div
ref={previewContainerRef}
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
@@ -452,65 +423,10 @@ export function TemplateDeploy({
</ModalFooter>
</ModalContent>
</Modal>
{/* Hidden container for OG image capture */}
{isCapturing && <OGCaptureContainer ref={ogCaptureRef} />}
</div>
)
}
/**
* Hidden container for OG image capture.
* Lazy-rendered only when capturing - gets workflow state from store on mount.
*/
const OGCaptureContainer = React.forwardRef<HTMLDivElement>((_, ref) => {
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
if (!blocks || Object.keys(blocks).length === 0) {
return null
}
const workflowState: WorkflowState = {
blocks,
edges: edges ?? [],
loops: loops ?? {},
parallels: parallels ?? {},
lastSaved: Date.now(),
}
return (
<div
ref={ref}
style={{
position: 'absolute',
left: '-9999px',
top: '-9999px',
width: OG_IMAGE_WIDTH,
height: OG_IMAGE_HEIGHT,
backgroundColor: '#0c0c0c',
overflow: 'hidden',
}}
aria-hidden='true'
>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={false}
height='100%'
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)
})
OGCaptureContainer.displayName = 'OGCaptureContainer'
interface TemplatePreviewContentProps {
existingTemplate:
| {

View File

@@ -37,7 +37,8 @@ import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-
import type { GenerationType } from '@/blocks/types'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { normalizeName } from '@/stores/workflows/utils'
import { useTextHistory } from '@/hooks/use-text-history'
import { normalizeBlockName } from '@/stores/workflows/utils'
const logger = createLogger('Code')
@@ -214,7 +215,6 @@ 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)
@@ -306,6 +306,20 @@ export function Code({
},
})
// Text history for undo/redo with debouncing
const textHistory = useTextHistory({
blockId,
subBlockId,
value: code,
onChange: (newValue) => {
setCode(newValue)
if (!isPreview && !disabled) {
setStoreValue(newValue)
}
},
disabled: isPreview || disabled || readOnly || isAiStreaming,
})
const getDefaultValueString = () => {
if (defaultValue === undefined || defaultValue === null) return ''
if (typeof defaultValue === 'string') return defaultValue
@@ -349,10 +363,12 @@ export function Code({
useEffect(() => {
handleStreamStartRef.current = () => {
setCode('')
lastInternalValueRef.current = ''
}
handleGeneratedContentRef.current = (generatedCode: string) => {
setCode(generatedCode)
lastInternalValueRef.current = generatedCode
if (!isPreview && !disabled) {
setStoreValue(generatedCode)
}
@@ -388,14 +404,21 @@ export function Code({
}
}, [readOnly])
// Effects: Sync code with external value
// Ref to track the last value we set internally (to avoid sync loops)
const lastInternalValueRef = useRef<string>('')
// Effects: Sync code with external value (only for truly external changes)
useEffect(() => {
if (isAiStreaming) return
const valueString = value?.toString() ?? ''
if (valueString !== code) {
// Only sync if this is a genuine external change, not our own update
// This prevents resetting the undo history when we update the store
if (valueString !== code && valueString !== lastInternalValueRef.current) {
setCode(valueString)
lastInternalValueRef.current = valueString
}
}, [value, code, isAiStreaming])
}, [value, isAiStreaming]) // Removed 'code' from dependencies to prevent sync loops
// Effects: Track active line number for cursor position
useEffect(() => {
@@ -503,9 +526,9 @@ export function Code({
const dropPosition = textarea?.selectionStart ?? code.length
const newValue = `${code.slice(0, dropPosition)}<${code.slice(dropPosition)}`
setCode(newValue)
setStoreValue(newValue)
hasEditedSinceFocusRef.current = true
// Use textHistory for proper undo tracking
textHistory.handleChange(newValue)
lastInternalValueRef.current = newValue
const newCursorPosition = dropPosition + 1
setCursorPosition(newCursorPosition)
@@ -533,9 +556,10 @@ export function Code({
*/
const handleTagSelect = (newValue: string) => {
if (!isPreview && !readOnly) {
setCode(newValue)
// Use textHistory for proper undo tracking
textHistory.handleChange(newValue)
lastInternalValueRef.current = newValue
emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true
}
setShowTags(false)
setActiveSourceBlockId(null)
@@ -551,9 +575,10 @@ export function Code({
*/
const handleEnvVarSelect = (newValue: string) => {
if (!isPreview && !readOnly) {
setCode(newValue)
// Use textHistory for proper undo tracking
textHistory.handleChange(newValue)
lastInternalValueRef.current = newValue
emitTagSelection(newValue)
hasEditedSinceFocusRef.current = true
}
setShowEnvVars(false)
@@ -602,7 +627,7 @@ export function Code({
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
@@ -745,9 +770,10 @@ export function Code({
value={code}
onValueChange={(newCode) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
// Use textHistory for debounced undo/redo tracking
textHistory.handleChange(newCode)
// Track this as an internal change to prevent sync loops
lastInternalValueRef.current = newCode
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
@@ -767,6 +793,10 @@ export function Code({
}
}}
onKeyDown={(e) => {
// Let text history handle undo/redo first
if (textHistory.handleKeyDown(e)) {
return
}
if (e.key === 'Escape') {
setShowTags(false)
setShowEnvVars(false)
@@ -774,12 +804,10 @@ export function Code({
if (isAiStreaming) {
e.preventDefault()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
}
}}
onFocus={() => {
hasEditedSinceFocusRef.current = false
onBlur={() => {
// Commit any pending text history changes on blur
textHistory.handleBlur()
}}
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}

View File

@@ -33,7 +33,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useTagSelection } from '@/hooks/use-tag-selection'
import { normalizeName } from '@/stores/workflows/utils'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ConditionInput')
@@ -139,7 +139,7 @@ export function ConditionInput({
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true

View File

@@ -1,10 +1,12 @@
'use client'
import { useMemo, useRef } from 'react'
import { useMemo, useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { 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'
@@ -18,8 +20,7 @@ interface DocumentTagRow {
id: string
cells: {
tagName: string
tagSlot?: string
fieldType: string
type: string
value: string
}
}
@@ -65,11 +66,17 @@ 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)
@@ -78,8 +85,7 @@ export function DocumentTagEntry({
id: tag.id || `tag-${index}`,
cells: {
tagName: tag.tagName || '',
tagSlot: tag.tagSlot,
fieldType: tag.fieldType || 'text',
type: tag.fieldType || 'text',
value: tag.value || '',
},
}))
@@ -93,109 +99,137 @@ export function DocumentTagEntry({
return [
{
id: 'empty-row-0',
cells: { tagName: '', tagSlot: undefined, fieldType: 'text', value: '' },
cells: { tagName: '', type: 'text', value: '' },
},
]
}, [currentValue])
// 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])
// 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())
)
// Filter available tags (exclude already used ones)
const availableTagDefinitions = useMemo(() => {
return tagDefinitions.filter((def) => !usedTagNames.has(def.displayName.toLowerCase()))
}, [tagDefinitions, usedTagNames])
const availableTagDefinitions = tagDefinitions.filter(
(def) => !usedTagNames.has(def.displayName.toLowerCase())
)
// Can add more tags if there are available tag definitions
const canAddMoreTags = availableTagDefinitions.length > 0
// 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)
}
// Shared helper function for updating rows and generating JSON
const updateRowsAndGenerateJson = (
rowIndex: number,
column: string,
value: string,
tagDef?: { tagSlot: string; fieldType: string }
) => {
const updateRowsAndGenerateJson = (rowIndex: number, column: string, value: string) => {
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
const newCells = { ...row.cells, [column]: value }
// 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 = ''
// 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
}
}
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 || '',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
fieldType: row.cells.type || 'text',
value: row.cells.value || '',
}))
return dataToStore.length > 0 ? JSON.stringify(dataToStore) : ''
}
const handleTagSelection = (rowIndex: number, tagName: string) => {
const handleCellChange = (rowIndex: number, column: string, value: string) => {
if (isPreview || disabled) return
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
const jsonString = updateRowsAndGenerateJson(rowIndex, 'tagName', tagName, tagDef)
// 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)
setStoreValue(jsonString)
}
const handleValueChange = (rowIndex: number, value: string) => {
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
if (isPreview || disabled) return
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
setStoreValue(jsonString)
}
const handleTagDropdownSelection = (rowIndex: number, value: string) => {
if (isPreview || disabled) return
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
const jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
emitTagSelection(jsonString)
}
const handleAddRow = () => {
if (isPreview || disabled || !canAddMoreTags) return
if (isPreview || disabled) 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).slice(2, 11)}`
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).substr(2, 9)}`
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
setStoreValue(JSON.stringify(newData))
}
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled) return
if (rows.length <= 1) {
// Clear the single row instead of deleting
setStoreValue('')
return
}
if (isPreview || disabled || rows.length <= 1) return
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
// Store all remaining rows including empty ones - don't auto-remove
const tableDataForStorage = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
fieldType: row.cells.type || 'text',
value: row.cells.value || '',
}))
@@ -203,15 +237,15 @@ export function DocumentTagEntry({
setStoreValue(jsonString)
}
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>
// 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()
)
}
@@ -219,82 +253,209 @@ 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 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>
<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>
</tr>
</thead>
)
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
const showDropdown = dropdownStates[rowIndex] || false
// 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 setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const tagOptions: ComboboxOption[] = selectableTags.map((tag) => ({
value: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
}))
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)
}
return (
<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 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>
)
}
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) => handleValueChange(rowIndex, newValue)
(newValue) => handleCellChange(rowIndex, 'value', newValue)
)
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
cellKey,
cellValue,
(newValue) => handleTagDropdownSelection(rowIndex, newValue)
(newValue) => handleTagDropdownSelection(rowIndex, 'value', newValue)
)
return (
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
<td className='p-1'>
<div className='relative w-full'>
<Input
ref={(el) => {
@@ -305,13 +466,12 @@ export function DocumentTagEntry({
onKeyDown={handlers.onKeyDown}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
disabled={disabled || !isTagSelected}
disabled={disabled}
autoComplete='off'
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'
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='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]'>
<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,
@@ -340,33 +500,49 @@ export function DocumentTagEntry({
}
const renderDeleteButton = (rowIndex: number) => {
if (isPreview || disabled) return null
// Allow deletion of any row
const canDelete = !isPreview && !disabled
return (
return canDelete ? (
<td className='w-0 p-0'>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash className='h-[14px] w-[14px]' />
<Trash className='h-4 w-4 text-muted-foreground' />
</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 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'>
<div className='relative'>
{showPreFillButton && (
<div className='mb-2'>
<Button variant='outline' size='sm' onClick={handlePreFillTags}>
Prefill Existing Tags
</Button>
</div>
)}
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
{renderHeader()}
<tbody className='bg-transparent'>
<tbody>
{rows.map((row, rowIndex) => (
<tr
key={row.id}
className='group relative border-[var(--border-strong)] border-t bg-transparent'
>
<tr key={row.id} className='group relative border-t'>
{renderTagNameCell(row, rowIndex)}
{renderTypeCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
@@ -375,13 +551,24 @@ export function DocumentTagEntry({
</table>
</div>
{/* Add Tag Button */}
{/* Add Row Button and Tag slots usage indicator */}
{!isPreview && !disabled && (
<div className='mt-3'>
<Button onClick={handleAddRow} disabled={!canAddMoreTags} className='h-7 px-2 text-xs'>
<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'
>
<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

@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
import { REFERENCE } from '@/executor/constants'
import { createCombinedPattern } from '@/executor/utils/reference-validation'
import { normalizeName } from '@/stores/workflows/utils'
import { normalizeBlockName } from '@/stores/workflows/utils'
export interface HighlightContext {
accessiblePrefixes?: Set<string>
@@ -31,7 +31,7 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_PREFIXES.has(normalizedPrefix)) {
return true

View File

@@ -2,10 +2,10 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import { Label } from '@/components/ui/label'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
@@ -20,22 +20,14 @@ 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
}
}
@@ -55,15 +47,21 @@ 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
@@ -72,15 +70,14 @@ 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 {
const parsed = JSON.parse(filterValue)
return parsed.map((f: TagFilter) => ({
...f,
fieldType: f.fieldType || 'text',
operator: f.operator || 'eq',
}))
return JSON.parse(filterValue)
} catch {
return []
}
@@ -89,23 +86,20 @@ 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: '', fieldType: 'text', operator: '', value: '' },
cells: { tagName: '', value: '' },
},
]
@@ -115,72 +109,27 @@ export function KnowledgeTagFilters({
setStoreValue(value)
}
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) => {
const handleCellChange = (rowIndex: number, column: string, value: string) => {
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,
tagName,
tagSlot: tagDef?.tagSlot,
fieldType,
operator: operators[0]?.value || 'eq',
value: '',
valueTo: undefined,
},
cells: { ...row.cells, [column]: value },
}
}
return row
})
updateFilters(rowsToFilters(updatedRows))
// 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)
}
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
@@ -196,36 +145,36 @@ export function KnowledgeTagFilters({
return row
})
const jsonValue =
rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null
// 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
emitTagSelection(jsonValue)
}
const handleAddRow = () => {
if (isPreview || disabled) return
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 newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
updateFilters(newFilters)
}
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled) return
if (rows.length <= 1) {
// Clear the single row instead of deleting
setStoreValue(null)
return
}
if (isPreview || disabled || rows.length <= 1) return
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
updateFilters(rowsToFilters(updatedRows))
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
}
if (isPreview) {
@@ -242,88 +191,108 @@ export function KnowledgeTagFilters({
}
const renderHeader = () => (
<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>
<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>
</tr>
</thead>
)
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const showDropdown = dropdownStates[rowIndex] || false
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
value: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
}))
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)
}
return (
<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 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>
)
}
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)
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
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
handleCellChange(rowIndex, column, newValue)
handleCellChange(rowIndex, 'value', newValue)
if (column === 'value') {
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
setActiveTagDropdown({
@@ -333,78 +302,58 @@ export function KnowledgeTagFilters({
activeSourceBlockId: null,
element: e.target,
})
}
}}
onFocus={(e) => {
if (!isDisabled && column === 'value') {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
if (column === 'value') {
}}
onFocus={(e) => {
if (!disabled) {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
setTimeout(() => setActiveTagDropdown(null), 200)
}
}}
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,
})}
}}
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>
</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) => {
if (isPreview || disabled) return null
const canDelete = !isPreview && !disabled
return (
return canDelete ? (
<td className='w-0 p-0'>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash className='h-[14px] w-[14px]' />
<Trash className='h-4 w-4 text-muted-foreground' />
</Button>
</td>
)
) : null
}
if (isLoading) {
@@ -412,18 +361,14 @@ export function KnowledgeTagFilters({
}
return (
<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'>
<div className='relative'>
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
{renderHeader()}
<tbody className='bg-transparent'>
<tbody>
{rows.map((row, rowIndex) => (
<tr
key={row.id}
className='group relative border-[var(--border-strong)] border-t bg-transparent'
>
<tr key={row.id} className='group relative border-t'>
{renderTagNameCell(row, rowIndex)}
{renderOperatorCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
@@ -455,7 +400,7 @@ export function KnowledgeTagFilters({
{/* Add Filter Button */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<Button onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Button variant='outline' size='sm' onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Plus className='mr-1 h-2.5 w-2.5' />
Add Filter
</Button>

View File

@@ -34,7 +34,6 @@ import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
@@ -118,6 +117,20 @@ const TAG_PREFIXES = {
VARIABLE: 'variable.',
} as const
/**
* Normalizes a block name by removing spaces and converting to lowercase
*/
const normalizeBlockName = (blockName: string): string => {
return blockName.replace(/\s+/g, '').toLowerCase()
}
/**
* Normalizes a variable name by removing spaces
*/
const normalizeVariableName = (variableName: string): string => {
return variableName.replace(/\s+/g, '')
}
/**
* Ensures the root tag is present in the tags array
*/
@@ -508,7 +521,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (sourceBlock.type === 'loop' || sourceBlock.type === 'parallel') {
const mockConfig = { outputs: { results: 'array' } }
const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = normalizeName(blockName)
const normalizedBlockName = normalizeBlockName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
@@ -529,7 +542,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
const blockName = sourceBlock.name || sourceBlock.type
const normalizedBlockName = normalizeName(blockName)
const normalizedBlockName = normalizeBlockName(blockName)
const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId)
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
@@ -722,12 +735,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
)
const variableTags = validVariables.map(
(variable: Variable) => `${TAG_PREFIXES.VARIABLE}${normalizeName(variable.name)}`
(variable: Variable) => `${TAG_PREFIXES.VARIABLE}${normalizeVariableName(variable.name)}`
)
const variableInfoMap = validVariables.reduce(
(acc, variable) => {
const tagName = `${TAG_PREFIXES.VARIABLE}${normalizeName(variable.name)}`
const tagName = `${TAG_PREFIXES.VARIABLE}${normalizeVariableName(variable.name)}`
acc[tagName] = {
type: variable.type,
id: variable.id,
@@ -852,7 +865,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const mockConfig = { outputs: { results: 'array' } }
const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = normalizeName(blockName)
const normalizedBlockName = normalizeBlockName(blockName)
const outputPaths = generateOutputPaths(mockConfig.outputs)
let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
@@ -872,7 +885,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
const blockName = accessibleBlock.name || accessibleBlock.type
const normalizedBlockName = normalizeName(blockName)
const normalizedBlockName = normalizeBlockName(blockName)
const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId)
const responseFormatValue = mergedSubBlocks?.responseFormat?.value

View File

@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import 'prismjs/components/prism-json'
import { Wand2 } from 'lucide-react'
import Editor from 'react-simple-code-editor'
@@ -17,6 +17,7 @@ import {
createEnvVarPattern,
createWorkflowVariablePattern,
} from '@/executor/utils/reference-validation'
import { useTextHistoryStore } from '@/stores/text-history'
interface CodeEditorProps {
value: string
@@ -33,6 +34,11 @@ interface CodeEditorProps {
showWandButton?: boolean
onWandClick?: () => void
wandButtonDisabled?: boolean
/**
* Unique identifier for text history. When provided, enables undo/redo functionality.
* Format: "blockId:fieldName" e.g. "block-123:schema" or "block-123:code"
*/
historyId?: string
}
export function CodeEditor({
@@ -50,16 +56,125 @@ export function CodeEditor({
showWandButton = false,
onWandClick,
wandButtonDisabled = false,
historyId,
}: CodeEditorProps) {
const [code, setCode] = useState(value)
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
const editorRef = useRef<HTMLDivElement>(null)
const lastInternalValueRef = useRef<string>(value)
const initializedRef = useRef(false)
// Text history store for undo/redo
const textHistoryStore = useTextHistoryStore()
// Parse historyId into blockId and subBlockId for the store
const [historyBlockId, historySubBlockId] = historyId?.split(':') ?? ['', '']
const hasHistory = Boolean(historyId && historyBlockId && historySubBlockId)
// Initialize history on mount
useEffect(() => {
setCode(value)
if (hasHistory && !initializedRef.current) {
textHistoryStore.initHistory(historyBlockId, historySubBlockId, value)
initializedRef.current = true
}
}, [hasHistory, historyBlockId, historySubBlockId, value, textHistoryStore])
// Sync external value changes (but avoid resetting undo history for internal changes)
useEffect(() => {
if (value !== code && value !== lastInternalValueRef.current) {
setCode(value)
lastInternalValueRef.current = value
}
}, [value])
// Handle value change with history tracking
const handleValueChange = useCallback(
(newCode: string) => {
setCode(newCode)
lastInternalValueRef.current = newCode
onChange(newCode)
// Record to history if enabled
if (hasHistory) {
textHistoryStore.recordChange(historyBlockId, historySubBlockId, newCode)
}
},
[onChange, hasHistory, historyBlockId, historySubBlockId, textHistoryStore]
)
// Handle undo
const handleUndo = useCallback(() => {
if (!hasHistory) return false
const previousValue = textHistoryStore.undo(historyBlockId, historySubBlockId)
if (previousValue !== null) {
setCode(previousValue)
lastInternalValueRef.current = previousValue
onChange(previousValue)
return true
}
return false
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore, onChange])
// Handle redo
const handleRedo = useCallback(() => {
if (!hasHistory) return false
const nextValue = textHistoryStore.redo(historyBlockId, historySubBlockId)
if (nextValue !== null) {
setCode(nextValue)
lastInternalValueRef.current = nextValue
onChange(nextValue)
return true
}
return false
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore, onChange])
// Handle keyboard events for undo/redo
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return
const isMod = e.metaKey || e.ctrlKey
// Undo: Cmd+Z / Ctrl+Z
if (isMod && e.key === 'z' && !e.shiftKey && hasHistory) {
if (handleUndo()) {
e.preventDefault()
e.stopPropagation()
return
}
}
// Redo: Cmd+Shift+Z / Ctrl+Shift+Z / Ctrl+Y
if (hasHistory) {
if (
(isMod && e.key === 'z' && e.shiftKey) ||
(isMod && e.key === 'Z') ||
(e.ctrlKey && e.key === 'y')
) {
if (handleRedo()) {
e.preventDefault()
e.stopPropagation()
return
}
}
}
// Call parent's onKeyDown if provided
onKeyDown?.(e)
},
[disabled, hasHistory, handleUndo, handleRedo, onKeyDown]
)
// Handle blur - commit pending history
const handleBlur = useCallback(() => {
if (hasHistory) {
textHistoryStore.commitPending(historyBlockId, historySubBlockId)
}
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore])
useEffect(() => {
if (!editorRef.current) return
@@ -211,11 +326,9 @@ export function CodeEditor({
<Editor
value={code}
onValueChange={(newCode) => {
setCode(newCode)
onChange(newCode)
}}
onKeyDown={onKeyDown}
onValueChange={handleValueChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
highlight={(code) => customHighlight(code)}
disabled={disabled}
{...getCodeEditorProps({ disabled })}

View File

@@ -936,6 +936,7 @@ try {
gutterClassName='bg-[var(--bg)]'
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
onKeyDown={handleKeyDown}
historyId={`${blockId}:tool-schema`}
/>
</ModalTabsContent>
@@ -1018,6 +1019,7 @@ try {
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
onKeyDown={handleKeyDown}
schemaParameters={schemaParameters}
historyId={`${blockId}:tool-code`}
/>
{showEnvVars && (

View File

@@ -982,11 +982,6 @@ 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

@@ -9,7 +9,7 @@ import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { normalizeName } from '@/stores/workflows/utils'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -110,7 +110,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true

View File

@@ -134,111 +134,29 @@ 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 '-'
// Try parsing JSON strings first
const parsedValue = tryParseJson(value)
if (isMessagesArray(parsedValue)) {
const firstMessage = parsedValue[0]
if (isMessagesArray(value)) {
const firstMessage = value[0]
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
const content = firstMessage.content.trim()
return content.length > 50 ? `${content.slice(0, 50)}...` : content
}
if (isVariableAssignmentsArray(parsedValue)) {
const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name)
if (isVariableAssignmentsArray(value)) {
const names = value.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 (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) => {
if (isTableRowArray(value)) {
const nonEmptyRows = value.filter((row) => {
const cellValues = Object.values(row.cells)
return cellValues.some((cell) => cell && cell.trim() !== '')
})
@@ -257,16 +175,16 @@ const getDisplayValue = (value: unknown): string => {
return `${nonEmptyRows.length} rows`
}
if (isFieldFormatArray(parsedValue)) {
const namedFields = parsedValue.filter((field) => field.name && field.name.trim() !== '')
if (isFieldFormatArray(value)) {
const namedFields = value.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(parsedValue)) {
const entries = Object.entries(parsedValue).filter(
if (isPlainObject(value)) {
const entries = Object.entries(value).filter(
([, val]) => val !== null && val !== undefined && val !== ''
)
@@ -283,10 +201,8 @@ const getDisplayValue = (value: unknown): string => {
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
}
if (Array.isArray(parsedValue)) {
const nonEmptyItems = parsedValue.filter(
(item) => item !== null && item !== undefined && item !== ''
)
if (Array.isArray(value)) {
const nonEmptyItems = value.filter((item) => item !== null && item !== undefined && item !== '')
if (nonEmptyItems.length === 0) return '-'
const getItemDisplayValue = (item: unknown): string => {
@@ -304,11 +220,10 @@ 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(parsedValue)
const json = JSON.stringify(value)
if (json.length <= 40) return json
return `${json.slice(0, 37)}...`
} catch {

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
import { normalizeName } from '@/stores/workflows/utils'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
@@ -53,10 +53,10 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
const prefixes = new Set<string>()
accessibleIds.forEach((id) => {
prefixes.add(normalizeName(id))
prefixes.add(normalizeBlockName(id))
const block = blocks[id]
if (block?.name) {
prefixes.add(normalizeName(block.name))
prefixes.add(normalizeBlockName(block.name))
}
})

View File

@@ -18,7 +18,6 @@ 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'
@@ -33,7 +32,6 @@ 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'
@@ -92,6 +90,7 @@ 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
@@ -159,14 +158,6 @@ 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)
@@ -532,7 +523,7 @@ const WorkflowContent = React.memo(() => {
useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => {
const customEvent = event as CustomEvent<{ blockId: string }>
const blockId = customEvent.detail?.blockId
const { blockId } = customEvent.detail || ({} as any)
if (!blockId) return
try {
@@ -564,7 +555,6 @@ 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
@@ -611,152 +601,6 @@ 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.
*
@@ -785,10 +629,21 @@ const WorkflowContent = React.memo(() => {
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
blockType: data.type,
targetParentId: null,
})
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',
}
}
}
addBlock(
id,
@@ -796,8 +651,8 @@ const WorkflowContent = React.memo(() => {
name,
position,
{
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
width: 500,
height: 300,
type: 'subflowNode',
},
undefined,
@@ -819,7 +674,12 @@ 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 = defaultTriggerNameDrop || blockConfig.name
const baseName =
data.type === 'loop'
? 'Loop'
: data.type === 'parallel'
? 'Parallel'
: defaultTriggerNameDrop || blockConfig!.name
const name = getUniqueBlockName(baseName, blocks)
if (containerInfo) {
@@ -851,18 +711,70 @@ const WorkflowContent = React.memo(() => {
estimateBlockDimensions(data.type)
)
// 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 }))
// Capture existing child blocks before adding the new one
const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === containerInfo.loopId
)
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
blockType: data.type,
enableTriggerMode: data.enableTriggerMode,
targetParentId: containerInfo.loopId,
existingChildBlocks,
containerId: containerInfo.loopId,
})
// 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',
}
}
}
// Add block with parent info AND autoConnectEdge (atomic operation)
addBlock(
@@ -884,13 +796,49 @@ const WorkflowContent = React.memo(() => {
resizeLoopNodesWrapper()
} else {
// Centralized trigger constraints
if (checkTriggerConstraints(data.type)) return
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
}
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
blockType: data.type,
enableTriggerMode: data.enableTriggerMode,
targetParentId: null,
})
// 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',
}
}
}
}
// Regular canvas drop with auto-connect edge
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
@@ -913,13 +861,14 @@ const WorkflowContent = React.memo(() => {
},
[
blocks,
getNodes,
findClosestOutput,
determineSourceHandle,
isPointInLoopNode,
resizeLoopNodesWrapper,
addBlock,
addNotification,
activeWorkflowId,
tryCreateAutoConnectEdge,
checkTriggerConstraints,
]
)
@@ -936,73 +885,44 @@ 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 autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
targetParentId: null,
const centerPosition = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
// 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,
basePosition,
centerPosition,
{
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
width: 500,
height: 300,
type: 'subflowNode',
},
undefined,
@@ -1019,8 +939,11 @@ const WorkflowContent = React.memo(() => {
return
}
// Check trigger constraints first
if (checkTriggerConstraints(type)) return
// Calculate the center position of the viewport
const centerPosition = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
// Create a new block with a unique ID
const id = crypto.randomUUID()
@@ -1029,11 +952,51 @@ const WorkflowContent = React.memo(() => {
const baseName = defaultTriggerName || blockConfig.name
const name = getUniqueBlockName(baseName, blocks)
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
blockType: type,
enableTriggerMode,
targetParentId: null,
})
// 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
}
// Add the block to the workflow with auto-connect edge
// Enable trigger mode if this is a trigger-capable block from the triggers tab
@@ -1041,7 +1004,7 @@ const WorkflowContent = React.memo(() => {
id,
type,
name,
basePosition,
centerPosition,
undefined,
undefined,
undefined,
@@ -1062,12 +1025,11 @@ const WorkflowContent = React.memo(() => {
screenToFlowPosition,
blocks,
addBlock,
tryCreateAutoConnectEdge,
isPointInLoopNode,
findClosestOutput,
determineSourceHandle,
effectivePermissions.canEdit,
addNotification,
activeWorkflowId,
checkTriggerConstraints,
])
/**
@@ -1258,12 +1220,12 @@ const WorkflowContent = React.memo(() => {
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as SubflowNodeData)?.kind === 'loop'
(containerNode.data as any)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
(containerNode.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
@@ -1462,8 +1424,8 @@ const WorkflowContent = React.memo(() => {
data: {
...block.data,
name: block.name,
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
width: block.data?.width || 500,
height: block.data?.height || 300,
kind: block.type === 'loop' ? 'loop' : 'parallel',
},
})
@@ -1522,8 +1484,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: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
width: 250, // Standard width for both block types
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
})
})
@@ -1610,7 +1572,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.
* Skips during loading to avoid unnecessary work.
*/
useEffect(() => {
// Skip during initial render when nodes aren't loaded yet or workflow not ready
@@ -1832,15 +1794,12 @@ 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 || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
: BLOCK_DIMENSIONS.FIXED_WIDTH
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
const nodeHeight =
node.type === 'subflowNode'
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
? node.data?.height || 300
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
// Check intersection using absolute coordinates
const nodeRect = {
@@ -1852,10 +1811,9 @@ const WorkflowContent = React.memo(() => {
const containerRect = {
left: containerAbsolutePos.x,
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
right: containerAbsolutePos.x + (n.data?.width || 500),
top: containerAbsolutePos.y,
bottom:
containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
bottom: containerAbsolutePos.y + (n.data?.height || 300),
}
// Check intersection with absolute coordinates for accurate detection
@@ -1871,9 +1829,7 @@ const WorkflowContent = React.memo(() => {
container: n,
depth: getNodeDepth(n.id),
// Calculate size for secondary sorting
size:
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
size: (n.data?.width || 500) * (n.data?.height || 300),
}))
// Update potential parent if there's at least one intersecting container node
@@ -1901,12 +1857,12 @@ const WorkflowContent = React.memo(() => {
// Apply appropriate class based on container type
if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
(bestContainerMatch.container.data as any)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
(bestContainerMatch.container.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
@@ -2078,19 +2034,62 @@ 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 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 }))
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 autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
blockType: node.data?.type || '',
targetParentId: potentialParentId,
existingChildBlocks,
containerId: potentialParentId,
})
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 edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
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',
})
}
}
// Skip recording these edges separately since they're part of the parent update
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
@@ -2115,7 +2114,7 @@ const WorkflowContent = React.memo(() => {
updateNodeParent,
collaborativeUpdateBlockPosition,
addEdge,
tryCreateAutoConnectEdge,
determineSourceHandle,
blocks,
edgesForDisplay,
removeEdgesForNode,
@@ -2318,7 +2317,7 @@ const WorkflowContent = React.memo(() => {
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
snapToGrid={snapToGrid}
snapToGrid={false}
snapGrid={snapGrid}
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}

View File

@@ -490,20 +490,6 @@ 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) => {
@@ -513,12 +499,6 @@ 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

@@ -52,17 +52,6 @@ interface UIEnvironmentVariable {
id?: number
}
/**
* Validates an environment variable key.
* Returns an error message if invalid, undefined if valid.
*/
function validateEnvVarKey(key: string): string | undefined {
if (!key) return undefined
if (key.includes(' ')) return 'Spaces are not allowed'
if (!ENV_VAR_PATTERN.test(key)) return 'Only letters, numbers, and underscores allowed'
return undefined
}
interface EnvironmentVariablesProps {
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
}
@@ -233,10 +222,6 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
}, [envVars, workspaceVars])
const hasInvalidKeys = useMemo(() => {
return envVars.some((envVar) => !!envVar.key && validateEnvVarKey(envVar.key))
}, [envVars])
useEffect(() => {
hasChangesRef.current = hasChanges
}, [hasChanges])
@@ -566,7 +551,6 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
const renderEnvVarRow = useCallback(
(envVar: UIEnvironmentVariable, originalIndex: number) => {
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
const keyError = validateEnvVarKey(envVar.key)
const maskedValueStyle =
focusedValueIndex !== originalIndex && !isConflict
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
@@ -587,7 +571,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
spellCheck='false'
readOnly
onFocus={(e) => e.target.removeAttribute('readOnly')}
className={`h-9 ${isConflict ? conflictClassName : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`}
className={`h-9 ${isConflict ? conflictClassName : ''}`}
/>
<div />
<EmcnInput
@@ -643,12 +627,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
</Tooltip.Root>
</div>
</div>
{keyError && (
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
{keyError}
</div>
)}
{isConflict && !keyError && (
{isConflict && (
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
Workspace variable with the same name overrides this. Rename your personal key to use
it.
@@ -728,17 +707,14 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<Tooltip.Trigger asChild>
<Button
onClick={handleSave}
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
disabled={isLoading || !hasChanges || hasConflicts}
variant='primary'
className={`${PRIMARY_BUTTON_STYLES} ${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
className={`${PRIMARY_BUTTON_STYLES} ${hasConflicts ? 'cursor-not-allowed opacity-50' : ''}`}
>
Save
</Button>
</Tooltip.Trigger>
{hasConflicts && <Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>}
{hasInvalidKeys && !hasConflicts && (
<Tooltip.Content>Fix invalid variable names before saving</Tooltip.Content>
)}
</Tooltip.Root>
</div>
@@ -832,8 +808,8 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{hasConflicts || hasInvalidKeys
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
{hasConflicts
? 'You have unsaved changes, but conflicts must be resolved before saving. You can discard your changes to close the modal.'
: 'You have unsaved changes. Do you want to save them before closing?'}
</p>
</ModalBody>
@@ -841,22 +817,18 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<Button variant='default' onClick={handleCancel}>
Discard Changes
</Button>
{hasConflicts || hasInvalidKeys ? (
{hasConflicts ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
disabled={true}
variant='primary'
className={`${PRIMARY_BUTTON_STYLES} cursor-not-allowed opacity-50`}
className='cursor-not-allowed opacity-50'
>
Save Changes
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{hasConflicts
? 'Resolve all conflicts before saving'
: 'Fix invalid variable names before saving'}
</Tooltip.Content>
<Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>
</Tooltip.Root>
) : (
<Button onClick={handleSave} variant='primary' className={PRIMARY_BUTTON_STYLES}>

View File

@@ -12,7 +12,6 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Slider,
Switch,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
@@ -77,9 +76,6 @@ 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)
@@ -238,18 +234,6 @@ 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 })
@@ -409,6 +393,7 @@ export function General({ onOpenChange }: GeneralProps) {
dropdownWidth={140}
value={settings?.theme}
onChange={handleThemeChange}
disabled={updateSetting.isPending}
placeholder='Select theme'
options={[
{ label: 'System', value: 'system' },
@@ -425,34 +410,17 @@ 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>
@@ -462,6 +430,7 @@ export function General({ onOpenChange }: GeneralProps) {
id='telemetry'
checked={settings?.telemetryEnabled ?? true}
onCheckedChange={handleTelemetryToggle}
disabled={updateSetting.isPending}
/>
</div>
@@ -477,6 +446,7 @@ export function General({ onOpenChange }: GeneralProps) {
id='training-controls'
checked={settings?.showTrainingControls ?? false}
onCheckedChange={handleTrainingControlsChange}
disabled={updateSetting.isPending}
/>
</div>
)}
@@ -488,6 +458,7 @@ export function General({ onOpenChange }: GeneralProps) {
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? true}
onCheckedChange={handleSuperUserModeToggle}
disabled={updateSetting.isPending}
/>
</div>
)}
@@ -563,15 +534,6 @@ 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,15 +2,7 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Button,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Button, 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'
@@ -76,6 +68,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
if (subscriptionStatus.isTeam && activeOrgId) {
referenceId = activeOrgId
// Get subscription ID for team/enterprise
subscriptionId = subData?.data?.id
}
@@ -139,12 +132,14 @@ 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
@@ -155,6 +150,7 @@ 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) })
@@ -179,8 +175,10 @@ 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'
}
@@ -198,17 +196,20 @@ 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 className='flex flex-col gap-[2px]'>
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
<div>
<span className='font-medium text-[13px]'>
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
</span>
{isCancelAtPeriodEnd && (
<span className='text-[12px] text-[var(--text-muted)]'>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
You'll keep access until {formatDate(periodEndDate)}
</span>
</p>
)}
</div>
<Button
@@ -216,7 +217,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
className={cn(
'h-8 rounded-[8px] text-[13px]',
'h-8 rounded-[8px] font-medium text-xs',
error && 'border-[var(--text-error)] text-[var(--text-error)]'
)}
>
@@ -230,7 +231,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-muted)]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{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(
@@ -243,8 +244,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
{!isCancelAtPeriodEnd && (
<div className='mt-3'>
<div className='rounded-[8px] bg-[var(--surface-5)] p-3'>
<ul className='space-y-1 text-[12px] text-[var(--text-muted)]'>
<div className='rounded-[8px] bg-[var(--surface-3)] p-3 text-sm'>
<ul className='space-y-1 text-[var(--text-muted)] text-xs'>
<li>• Keep all features until {formatDate(periodEndDate)}</li>
<li>• No more charges</li>
<li>• Data preserved</li>

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