diff --git a/apps/sim/.cursorrules b/apps/sim/.cursorrules index 0233f7e4d..73ae78123 100644 --- a/apps/sim/.cursorrules +++ b/apps/sim/.cursorrules @@ -563,6 +563,32 @@ logger.error('Operation failed', { error }) --- +## Linting and Formatting + +### Automated Linting + +**Do not manually fix linting errors.** The project uses automated linting tools that should handle formatting and style issues. + +### Rules + +1. **No Manual Fixes**: Do not attempt to manually reorder CSS classes, fix formatting, or address linter warnings +2. **Use Automated Tools**: If linting errors need to be fixed, run `bun run lint` to let the automated tools handle it +3. **Focus on Logic**: Concentrate on functionality, TypeScript correctness, and architectural patterns +4. **Let Tools Handle Style**: Biome and other linters will automatically format code according to project standards + +### When Linting Matters + +- **Syntax Errors**: Fix actual syntax errors that prevent compilation +- **Type Errors**: Address TypeScript type errors that indicate logic issues +- **Ignore Style Warnings**: CSS class order, formatting preferences, etc. will be handled by tooling + +```bash +# If linting is required +bun run lint +``` + +--- + ## Code Quality Checklist Before considering a component/hook complete, verify: diff --git a/apps/sim/app/api/memory/[id]/route.ts b/apps/sim/app/api/memory/[id]/route.ts index d1658607a..4408ad3d0 100644 --- a/apps/sim/app/api/memory/[id]/route.ts +++ b/apps/sim/app/api/memory/[id]/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { memory } from '@sim/db/schema' +import { memory, workflowBlocks } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -8,6 +8,43 @@ import { generateRequestId } from '@/lib/utils' const logger = createLogger('MemoryByIdAPI') +/** + * Parse memory key into conversationId and blockId + * Key format: conversationId:blockId + */ +function parseMemoryKey(key: string): { conversationId: string; blockId: string } | null { + const parts = key.split(':') + if (parts.length !== 2) { + return null + } + return { + conversationId: parts[0], + blockId: parts[1], + } +} + +/** + * Lookup block name from block ID + */ +async function getBlockName(blockId: string, workflowId: string): Promise { + try { + const result = await db + .select({ name: workflowBlocks.name }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (result.length === 0) { + return undefined + } + + return result[0].name + } catch (error) { + logger.error('Error looking up block name', { error, blockId, workflowId }) + return undefined + } +} + const memoryQuerySchema = z.object({ workflowId: z.string().uuid('Invalid workflow ID format'), }) @@ -41,7 +78,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ try { logger.info(`[${requestId}] Processing memory get request for ID: ${id}`) - // Get workflowId from query parameter (required) const url = new URL(request.url) const workflowId = url.searchParams.get('workflowId') @@ -65,7 +101,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { workflowId: validatedWorkflowId } = validation.data - // Query the database for the memory const memories = await db .select() .from(memory) @@ -86,13 +121,36 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ ) } + const mem = memories[0] + const parsed = parseMemoryKey(mem.key) + + let enrichedMemory + if (!parsed) { + enrichedMemory = { + conversationId: mem.key, + blockId: 'unknown', + blockName: 'unknown', + data: mem.data, + } + } else { + const { conversationId, blockId } = parsed + const blockName = (await getBlockName(blockId, validatedWorkflowId)) || 'unknown' + + enrichedMemory = { + conversationId, + blockId, + blockName, + data: mem.data, + } + } + logger.info( `[${requestId}] Memory retrieved successfully: ${id} for workflow: ${validatedWorkflowId}` ) return NextResponse.json( { success: true, - data: memories[0], + data: enrichedMemory, }, { status: 200 } ) @@ -122,7 +180,6 @@ export async function DELETE( try { logger.info(`[${requestId}] Processing memory delete request for ID: ${id}`) - // Get workflowId from query parameter (required) const url = new URL(request.url) const workflowId = url.searchParams.get('workflowId') @@ -146,7 +203,6 @@ export async function DELETE( const { workflowId: validatedWorkflowId } = validation.data - // Verify memory exists before attempting to delete const existingMemory = await db .select({ id: memory.id }) .from(memory) @@ -166,7 +222,6 @@ export async function DELETE( ) } - // Hard delete the memory await db .delete(memory) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId))) @@ -241,7 +296,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } - // Verify memory exists before attempting to update const existingMemories = await db .select() .from(memory) @@ -261,47 +315,68 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } - const existingMemory = existingMemories[0] - - // Additional validation for agent memory type - if (existingMemory.type === 'agent') { - const agentValidation = agentMemoryDataSchema.safeParse(validatedData) - if (!agentValidation.success) { - const errorMessage = agentValidation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - logger.warn(`[${requestId}] Agent memory validation error: ${errorMessage}`) - return NextResponse.json( - { - success: false, - error: { - message: `Invalid agent memory data: ${errorMessage}`, - }, + const agentValidation = agentMemoryDataSchema.safeParse(validatedData) + if (!agentValidation.success) { + const errorMessage = agentValidation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + logger.warn(`[${requestId}] Agent memory validation error: ${errorMessage}`) + return NextResponse.json( + { + success: false, + error: { + message: `Invalid agent memory data: ${errorMessage}`, }, - { status: 400 } - ) - } + }, + { status: 400 } + ) } - // Update the memory with new data + const now = new Date() await db - .delete(memory) + .update(memory) + .set({ + data: validatedData, + updatedAt: now, + }) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId))) - // Fetch the updated memory const updatedMemories = await db .select() .from(memory) .where(and(eq(memory.key, id), eq(memory.workflowId, validatedWorkflowId))) .limit(1) + const mem = updatedMemories[0] + const parsed = parseMemoryKey(mem.key) + + let enrichedMemory + if (!parsed) { + enrichedMemory = { + conversationId: mem.key, + blockId: 'unknown', + blockName: 'unknown', + data: mem.data, + } + } else { + const { conversationId, blockId } = parsed + const blockName = (await getBlockName(blockId, validatedWorkflowId)) || 'unknown' + + enrichedMemory = { + conversationId, + blockId, + blockName, + data: mem.data, + } + } + logger.info( `[${requestId}] Memory updated successfully: ${id} for workflow: ${validatedWorkflowId}` ) return NextResponse.json( { success: true, - data: updatedMemories[0], + data: enrichedMemory, }, { status: 200 } ) diff --git a/apps/sim/app/api/memory/route.ts b/apps/sim/app/api/memory/route.ts index 8ce962be6..3b7c62142 100644 --- a/apps/sim/app/api/memory/route.ts +++ b/apps/sim/app/api/memory/route.ts @@ -1,15 +1,34 @@ import { db } from '@sim/db' -import { memory } from '@sim/db/schema' -import { and, eq, isNull, like } from 'drizzle-orm' +import { memory, workflowBlocks } from '@sim/db/schema' +import { and, eq, inArray, isNull, like } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { getWorkflowAccessContext } from '@/lib/workflows/utils' const logger = createLogger('MemoryAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +/** + * Parse memory key into conversationId and blockId + * Key format: conversationId:blockId + * @param key The memory key to parse + * @returns Object with conversationId and blockId, or null if invalid + */ +function parseMemoryKey(key: string): { conversationId: string; blockId: string } | null { + const parts = key.split(':') + if (parts.length !== 2) { + return null + } + return { + conversationId: parts[0], + blockId: parts[1], + } +} + /** * GET handler for searching and retrieving memories * Supports query parameters: @@ -22,16 +41,28 @@ export async function GET(request: NextRequest) { const requestId = generateRequestId() try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized memory access attempt`) + return NextResponse.json( + { + success: false, + error: { + message: authResult.error || 'Authentication required', + }, + }, + { status: 401 } + ) + } + logger.info(`[${requestId}] Processing memory search request`) - // Extract workflowId from query parameters const url = new URL(request.url) const workflowId = url.searchParams.get('workflowId') const searchQuery = url.searchParams.get('query') - const type = url.searchParams.get('type') + const blockNameFilter = url.searchParams.get('blockName') const limit = Number.parseInt(url.searchParams.get('limit') || '50') - // Require workflowId for security if (!workflowId) { logger.warn(`[${requestId}] Missing required parameter: workflowId`) return NextResponse.json( @@ -45,38 +76,148 @@ export async function GET(request: NextRequest) { ) } - // Build query conditions - const conditions = [] - - // Only include non-deleted memories - conditions.push(isNull(memory.deletedAt)) - - // Filter by workflow ID (required) - conditions.push(eq(memory.workflowId, workflowId)) - - // Add type filter if provided - if (type) { - conditions.push(eq(memory.type, type)) + const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId) + if (!accessContext) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for user ${authResult.userId}`) + return NextResponse.json( + { + success: false, + error: { + message: 'Workflow not found', + }, + }, + { status: 404 } + ) + } + + const { workspacePermission, isOwner } = accessContext + + if (!isOwner && !workspacePermission) { + logger.warn( + `[${requestId}] User ${authResult.userId} denied access to workflow ${workflowId}` + ) + return NextResponse.json( + { + success: false, + error: { + message: 'Access denied to this workflow', + }, + }, + { status: 403 } + ) + } + + logger.info( + `[${requestId}] User ${authResult.userId} (${authResult.authType}) accessing memories for workflow ${workflowId}` + ) + + const conditions = [] + + conditions.push(isNull(memory.deletedAt)) + + conditions.push(eq(memory.workflowId, workflowId)) + + let blockIdsToFilter: string[] | null = null + if (blockNameFilter) { + const blocks = await db + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where( + and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.name, blockNameFilter)) + ) + + if (blocks.length === 0) { + logger.info( + `[${requestId}] No blocks found with name "${blockNameFilter}" for workflow: ${workflowId}` + ) + return NextResponse.json( + { + success: true, + data: { memories: [] }, + }, + { status: 200 } + ) + } + + blockIdsToFilter = blocks.map((b) => b.id) } - // Add search query if provided (leverages index on key field) if (searchQuery) { conditions.push(like(memory.key, `%${searchQuery}%`)) } - // Execute the query - const memories = await db + const rawMemories = await db .select() .from(memory) .where(and(...conditions)) .orderBy(memory.createdAt) .limit(limit) - logger.info(`[${requestId}] Found ${memories.length} memories for workflow: ${workflowId}`) + const filteredMemories = blockIdsToFilter + ? rawMemories.filter((mem) => { + const parsed = parseMemoryKey(mem.key) + return parsed && blockIdsToFilter.includes(parsed.blockId) + }) + : rawMemories + + const blockIds = new Set() + const parsedKeys = new Map() + + for (const mem of filteredMemories) { + const parsed = parseMemoryKey(mem.key) + if (parsed) { + blockIds.add(parsed.blockId) + parsedKeys.set(mem.key, parsed) + } + } + + const blockNameMap = new Map() + if (blockIds.size > 0) { + const blocks = await db + .select({ id: workflowBlocks.id, name: workflowBlocks.name }) + .from(workflowBlocks) + .where( + and( + eq(workflowBlocks.workflowId, workflowId), + inArray(workflowBlocks.id, Array.from(blockIds)) + ) + ) + + for (const block of blocks) { + blockNameMap.set(block.id, block.name) + } + } + + const enrichedMemories = filteredMemories.map((mem) => { + const parsed = parsedKeys.get(mem.key) + + if (!parsed) { + return { + conversationId: mem.key, + blockId: 'unknown', + blockName: 'unknown', + data: mem.data, + } + } + + const { conversationId, blockId } = parsed + const blockName = blockNameMap.get(blockId) || 'unknown' + + return { + conversationId, + blockId, + blockName, + data: mem.data, + } + }) + + logger.info( + `[${requestId}] Found ${enrichedMemories.length} memories for workflow: ${workflowId}` + ) return NextResponse.json( { success: true, - data: { memories }, + data: { memories: enrichedMemories }, }, { status: 200 } ) @@ -105,13 +246,25 @@ export async function POST(request: NextRequest) { const requestId = generateRequestId() try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized memory creation attempt`) + return NextResponse.json( + { + success: false, + error: { + message: authResult.error || 'Authentication required', + }, + }, + { status: 401 } + ) + } + logger.info(`[${requestId}] Processing memory creation request`) - // Parse request body const body = await request.json() - const { key, type, data, workflowId } = body + const { key, data, workflowId } = body - // Validate required fields if (!key) { logger.warn(`[${requestId}] Missing required field: key`) return NextResponse.json( @@ -125,19 +278,6 @@ export async function POST(request: NextRequest) { ) } - if (!type || type !== 'agent') { - logger.warn(`[${requestId}] Invalid memory type: ${type}`) - return NextResponse.json( - { - success: false, - error: { - message: 'Memory type must be "agent"', - }, - }, - { status: 400 } - ) - } - if (!data) { logger.warn(`[${requestId}] Missing required field: data`) return NextResponse.json( @@ -164,28 +304,67 @@ export async function POST(request: NextRequest) { ) } - // Additional validation for agent type - if (type === 'agent') { - if (!data.role || !data.content) { - logger.warn(`[${requestId}] Missing agent memory fields`) + const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId) + if (!accessContext) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for user ${authResult.userId}`) + return NextResponse.json( + { + success: false, + error: { + message: 'Workflow not found', + }, + }, + { status: 404 } + ) + } + + const { workspacePermission, isOwner } = accessContext + + const hasWritePermission = + isOwner || workspacePermission === 'write' || workspacePermission === 'admin' + + if (!hasWritePermission) { + logger.warn( + `[${requestId}] User ${authResult.userId} denied write access to workflow ${workflowId}` + ) + return NextResponse.json( + { + success: false, + error: { + message: 'Write access denied to this workflow', + }, + }, + { status: 403 } + ) + } + + logger.info( + `[${requestId}] User ${authResult.userId} (${authResult.authType}) creating memory for workflow ${workflowId}` + ) + + const dataToValidate = Array.isArray(data) ? data : [data] + + for (const msg of dataToValidate) { + if (!msg || typeof msg !== 'object' || !msg.role || !msg.content) { + logger.warn(`[${requestId}] Missing required message fields`) return NextResponse.json( { success: false, error: { - message: 'Agent memory requires role and content', + message: 'Memory requires messages with role and content', }, }, { status: 400 } ) } - if (!['user', 'assistant', 'system'].includes(data.role)) { - logger.warn(`[${requestId}] Invalid agent role: ${data.role}`) + if (!['user', 'assistant', 'system'].includes(msg.role)) { + logger.warn(`[${requestId}] Invalid message role: ${msg.role}`) return NextResponse.json( { success: false, error: { - message: 'Agent role must be user, assistant, or system', + message: 'Message role must be user, assistant, or system', }, }, { status: 400 } @@ -193,77 +372,34 @@ export async function POST(request: NextRequest) { } } - // Check if memory with the same key already exists for this workflow - const existingMemory = await db - .select() - .from(memory) - .where(and(eq(memory.key, key), eq(memory.workflowId, workflowId), isNull(memory.deletedAt))) - .limit(1) + const initialData = Array.isArray(data) ? data : [data] + const now = new Date() + const id = `mem_${crypto.randomUUID().replace(/-/g, '')}` - let statusCode = 201 // Default status code for new memory + const { sql } = await import('drizzle-orm') - if (existingMemory.length > 0) { - logger.info(`[${requestId}] Memory with key ${key} exists, checking if we can append`) - - // Check if types match - if (existingMemory[0].type !== type) { - logger.warn( - `[${requestId}] Memory type mismatch: existing=${existingMemory[0].type}, new=${type}` - ) - return NextResponse.json( - { - success: false, - error: { - message: `Cannot append memory of type '${type}' to existing memory of type '${existingMemory[0].type}'`, - }, - }, - { status: 400 } - ) - } - - // Handle appending for agent type - let updatedData - - // For agent type - const newMessage = data - const existingData = existingMemory[0].data - - // If existing data is an array, append to it - if (Array.isArray(existingData)) { - updatedData = [...existingData, newMessage] - } - // If existing data is a single message object, convert to array - else { - updatedData = [existingData, newMessage] - } - - // Update the existing memory with appended data - await db - .update(memory) - .set({ - data: updatedData, - updatedAt: new Date(), - }) - .where(and(eq(memory.key, key), eq(memory.workflowId, workflowId))) - - statusCode = 200 // Status code for updated memory - } else { - // Insert the new memory - const newMemory = { - id: `mem_${crypto.randomUUID().replace(/-/g, '')}`, + await db + .insert(memory) + .values({ + id, workflowId, key, - type, - data: Array.isArray(data) ? data : [data], - createdAt: new Date(), - updatedAt: new Date(), - } + data: initialData, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [memory.workflowId, memory.key], + set: { + data: sql`${memory.data} || ${JSON.stringify(initialData)}::jsonb`, + updatedAt: now, + }, + }) - await db.insert(memory).values(newMemory) - logger.info(`[${requestId}] Memory created successfully: ${key} for workflow: ${workflowId}`) - } + logger.info( + `[${requestId}] Memory operation successful (atomic): ${key} for workflow: ${workflowId}` + ) - // Fetch all memories with the same key for this workflow to return the complete list const allMemories = await db .select() .from(memory) @@ -271,7 +407,6 @@ export async function POST(request: NextRequest) { .orderBy(memory.createdAt) if (allMemories.length === 0) { - // This shouldn't happen but handle it just in case logger.warn(`[${requestId}] No memories found after creating/updating memory: ${key}`) return NextResponse.json( { @@ -284,19 +419,44 @@ export async function POST(request: NextRequest) { ) } - // Get the memory object to return const memoryRecord = allMemories[0] + const parsed = parseMemoryKey(memoryRecord.key) + + let enrichedMemory + if (!parsed) { + enrichedMemory = { + conversationId: memoryRecord.key, + blockId: 'unknown', + blockName: 'unknown', + data: memoryRecord.data, + } + } else { + const { conversationId, blockId } = parsed + const blockName = await (async () => { + const blocks = await db + .select({ name: workflowBlocks.name }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + return blocks.length > 0 ? blocks[0].name : 'unknown' + })() + + enrichedMemory = { + conversationId, + blockId, + blockName, + data: memoryRecord.data, + } + } - logger.info(`[${requestId}] Memory operation successful: ${key} for workflow: ${workflowId}`) return NextResponse.json( { success: true, - data: memoryRecord, + data: enrichedMemory, }, - { status: statusCode } + { status: 200 } ) } catch (error: any) { - // Handle unique constraint violation if (error.code === '23505') { logger.warn(`[${requestId}] Duplicate key violation`) return NextResponse.json( @@ -321,3 +481,215 @@ export async function POST(request: NextRequest) { ) } } + +/** + * DELETE handler for pattern-based memory deletion + * Supports query parameters: + * - workflowId: Required + * - conversationId: Optional - delete all memories for this conversation + * - blockId: Optional - delete all memories for this block + * - blockName: Optional - delete all memories for blocks with this name + */ +export async function DELETE(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkHybridAuth(request) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized memory deletion attempt`) + return NextResponse.json( + { + success: false, + error: { + message: authResult.error || 'Authentication required', + }, + }, + { status: 401 } + ) + } + + logger.info(`[${requestId}] Processing memory deletion request`) + + const url = new URL(request.url) + const workflowId = url.searchParams.get('workflowId') + const conversationId = url.searchParams.get('conversationId') + const blockId = url.searchParams.get('blockId') + const blockName = url.searchParams.get('blockName') + + if (!workflowId) { + logger.warn(`[${requestId}] Missing required parameter: workflowId`) + return NextResponse.json( + { + success: false, + error: { + message: 'workflowId parameter is required', + }, + }, + { status: 400 } + ) + } + + if (!conversationId && !blockId && !blockName) { + logger.warn(`[${requestId}] No filter parameters provided`) + return NextResponse.json( + { + success: false, + error: { + message: 'At least one of conversationId, blockId, or blockName must be provided', + }, + }, + { status: 400 } + ) + } + + const accessContext = await getWorkflowAccessContext(workflowId, authResult.userId) + if (!accessContext) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for user ${authResult.userId}`) + return NextResponse.json( + { + success: false, + error: { + message: 'Workflow not found', + }, + }, + { status: 404 } + ) + } + + const { workspacePermission, isOwner } = accessContext + + const hasWritePermission = + isOwner || workspacePermission === 'write' || workspacePermission === 'admin' + + if (!hasWritePermission) { + logger.warn( + `[${requestId}] User ${authResult.userId} denied delete access to workflow ${workflowId}` + ) + return NextResponse.json( + { + success: false, + error: { + message: 'Write access denied to this workflow', + }, + }, + { status: 403 } + ) + } + + logger.info( + `[${requestId}] User ${authResult.userId} (${authResult.authType}) deleting memories for workflow ${workflowId}` + ) + + let deletedCount = 0 + + if (conversationId && blockId) { + const key = `${conversationId}:${blockId}` + const result = await db + .delete(memory) + .where(and(eq(memory.key, key), eq(memory.workflowId, workflowId))) + .returning({ id: memory.id }) + + deletedCount = result.length + } else if (conversationId && blockName) { + const blocks = await db + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.name, blockName))) + + if (blocks.length === 0) { + return NextResponse.json( + { + success: true, + data: { + message: `No blocks found with name "${blockName}"`, + deletedCount: 0, + }, + }, + { status: 200 } + ) + } + + for (const block of blocks) { + const key = `${conversationId}:${block.id}` + const result = await db + .delete(memory) + .where(and(eq(memory.key, key), eq(memory.workflowId, workflowId))) + .returning({ id: memory.id }) + + deletedCount += result.length + } + } else if (conversationId) { + const pattern = `${conversationId}:%` + const result = await db + .delete(memory) + .where(and(like(memory.key, pattern), eq(memory.workflowId, workflowId))) + .returning({ id: memory.id }) + + deletedCount = result.length + } else if (blockId) { + const pattern = `%:${blockId}` + const result = await db + .delete(memory) + .where(and(like(memory.key, pattern), eq(memory.workflowId, workflowId))) + .returning({ id: memory.id }) + + deletedCount = result.length + } else if (blockName) { + const blocks = await db + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.name, blockName))) + + if (blocks.length === 0) { + return NextResponse.json( + { + success: true, + data: { + message: `No blocks found with name "${blockName}"`, + deletedCount: 0, + }, + }, + { status: 200 } + ) + } + + for (const block of blocks) { + const pattern = `%:${block.id}` + const result = await db + .delete(memory) + .where(and(like(memory.key, pattern), eq(memory.workflowId, workflowId))) + .returning({ id: memory.id }) + + deletedCount += result.length + } + } + + logger.info( + `[${requestId}] Successfully deleted ${deletedCount} memories for workflow: ${workflowId}` + ) + return NextResponse.json( + { + success: true, + data: { + message: + deletedCount > 0 + ? `Successfully deleted ${deletedCount} memories` + : 'No memories found matching the criteria', + deletedCount, + }, + }, + { status: 200 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error deleting memories`, { error }) + return NextResponse.json( + { + success: false, + error: { + message: error.message || 'Failed to delete memories', + }, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx index 2208ec312..594a83b82 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx @@ -2,17 +2,16 @@ import { useState } from 'react' import { Loader2 } from 'lucide-react' -import { Trash } from '@/components/emcn/icons/trash' import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' + Button, + Modal, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, +} from '@/components/emcn' +import { Trash } from '@/components/emcn/icons/trash' import { createLogger } from '@/lib/logs/console/logger' import type { ChunkData } from '@/stores/knowledge/store' @@ -76,20 +75,30 @@ export function DeleteChunkModal({ if (!chunk) return null return ( - - - - Delete Chunk - - Are you sure you want to delete this chunk? This action cannot be undone. - - - - Cancel - + + + Delete Chunk + + Are you sure you want to delete this chunk?{' '} + + This action cannot be undone. + + + + + + + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 03e9323ad..c6e2168b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -625,16 +625,6 @@ export function Chat() { style={{ width: '110px', minWidth: '110px' }} > - { - e.stopPropagation() - if (activeWorkflowId) exportChatCSV(activeWorkflowId) - }} - disabled={messages.length === 0} - > - - Download - { e.stopPropagation() @@ -645,6 +635,16 @@ export function Chat() { Clear + { + e.stopPropagation() + if (activeWorkflowId) exportChatCSV(activeWorkflowId) + }} + disabled={messages.length === 0} + > + + Download + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index f43747b20..3f18289b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -23,6 +23,11 @@ interface OutputSelectProps { disabled?: boolean placeholder?: string valueMode?: 'id' | 'label' + /** + * When true, renders the underlying popover content inline instead of in a portal. + * Useful when used inside dialogs or other portalled components that manage scroll locking. + */ + disablePopoverPortal?: boolean align?: 'start' | 'end' | 'center' maxHeight?: number } @@ -34,6 +39,7 @@ export function OutputSelect({ disabled = false, placeholder = 'Select outputs', valueMode = 'id', + disablePopoverPortal = false, align = 'start', maxHeight = 300, }: OutputSelectProps) { @@ -374,6 +380,7 @@ export function OutputSelect({ maxHeight={maxHeight} maxWidth={160} minWidth={160} + disablePortal={disablePopoverPortal} onKeyDown={handleKeyDown} tabIndex={0} style={{ outline: 'none' }} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx deleted file mode 100644 index ece397cf2..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx +++ /dev/null @@ -1,458 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Check, Copy, Info, Loader2, Plus } from 'lucide-react' -import { useParams } from 'next/navigation' -import { Tooltip } from '@/components/emcn' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Button, - Input, - Label, - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui' -import { createLogger } from '@/lib/logs/console/logger' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' - -const logger = createLogger('ApiKeySelector') - -export interface ApiKey { - id: string - name: string - key: string - displayKey?: string - lastUsed?: string - createdAt: string - expiresAt?: string - createdBy?: string -} - -interface ApiKeysData { - workspace: ApiKey[] - personal: ApiKey[] -} - -interface ApiKeySelectorProps { - value: string - onChange: (keyId: string) => void - disabled?: boolean - apiKeys?: ApiKey[] - onApiKeyCreated?: () => void - showLabel?: boolean - label?: string - isDeployed?: boolean - deployedApiKeyDisplay?: string -} - -export function ApiKeySelector({ - value, - onChange, - disabled = false, - apiKeys = [], - onApiKeyCreated, - showLabel = true, - label = 'API Key', - isDeployed = false, - deployedApiKeyDisplay, -}: ApiKeySelectorProps) { - const params = useParams() - const workspaceId = (params?.workspaceId as string) || '' - const userPermissions = useUserPermissionsContext() - const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin - - const [apiKeysData, setApiKeysData] = useState(null) - const [isCreatingKey, setIsCreatingKey] = useState(false) - const [newKeyName, setNewKeyName] = useState('') - const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal') - const [newKey, setNewKey] = useState(null) - const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) - const [copySuccess, setCopySuccess] = useState(false) - const [isSubmittingCreate, setIsSubmittingCreate] = useState(false) - const [keysLoaded, setKeysLoaded] = useState(false) - const [createError, setCreateError] = useState(null) - const [justCreatedKeyId, setJustCreatedKeyId] = useState(null) - - useEffect(() => { - fetchApiKeys() - }, [workspaceId]) - - const fetchApiKeys = async () => { - try { - setKeysLoaded(false) - const [workspaceRes, personalRes] = await Promise.all([ - fetch(`/api/workspaces/${workspaceId}/api-keys`), - fetch('/api/users/me/api-keys'), - ]) - - const workspaceData = workspaceRes.ok ? await workspaceRes.json() : { keys: [] } - const personalData = personalRes.ok ? await personalRes.json() : { keys: [] } - - setApiKeysData({ - workspace: workspaceData.keys || [], - personal: personalData.keys || [], - }) - setKeysLoaded(true) - } catch (error) { - logger.error('Error fetching API keys:', { error }) - setKeysLoaded(true) - } - } - - const handleCreateKey = async () => { - if (!newKeyName.trim()) { - setCreateError('Please enter a name for the API key') - return - } - - try { - setIsSubmittingCreate(true) - setCreateError(null) - - const endpoint = - keyType === 'workspace' - ? `/api/workspaces/${workspaceId}/api-keys` - : '/api/users/me/api-keys' - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newKeyName }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to create API key') - } - - const data = await response.json() - setNewKey(data.key) - setJustCreatedKeyId(data.key.id) - setShowNewKeyDialog(true) - setIsCreatingKey(false) - setNewKeyName('') - - // Refresh API keys - await fetchApiKeys() - onApiKeyCreated?.() - } catch (error: any) { - setCreateError(error.message || 'Failed to create API key') - } finally { - setIsSubmittingCreate(false) - } - } - - const handleCopyKey = async () => { - if (newKey?.key) { - await navigator.clipboard.writeText(newKey.key) - setCopySuccess(true) - setTimeout(() => setCopySuccess(false), 2000) - } - } - - if (isDeployed && deployedApiKeyDisplay) { - return ( -
- {showLabel && ( -
- - - - - - - -

Owner is billed for usage

-
-
-
- )} -
-
-
-              {(() => {
-                const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
-                if (match) {
-                  return match[1].trim()
-                }
-                return deployedApiKeyDisplay
-              })()}
-            
- {(() => { - const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/) - if (match) { - const type = match[2] - return ( -
- - {type} - -
- ) - } - return null - })()} -
-
-
- ) - } - - return ( - <> -
- {showLabel && ( -
-
- - - - - - - -

Key Owner is Billed

-
-
-
- {!disabled && ( - - )} -
- )} - -
- - {/* Create Key Dialog */} - - - - Create new API key - - {keyType === 'workspace' - ? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again." - : "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."} - - - -
- {canCreateWorkspaceKeys && ( -
-

API Key Type

-
- - -
-
- )} - -
- - { - setNewKeyName(e.target.value) - if (createError) setCreateError(null) - }} - disabled={isSubmittingCreate} - /> - {createError &&

{createError}

} -
-
- - - { - setNewKeyName('') - setCreateError(null) - }} - > - Cancel - - { - e.preventDefault() - handleCreateKey() - }} - > - {isSubmittingCreate ? ( - <> - - Creating... - - ) : ( - 'Create' - )} - - -
-
- - {/* New Key Dialog */} - { - setShowNewKeyDialog(open) - if (!open) { - setNewKey(null) - setCopySuccess(false) - if (justCreatedKeyId) { - onChange(justCreatedKeyId) - setJustCreatedKeyId(null) - } - } - }} - > - - - Your API key has been created - - This is the only time you will see your API key.{' '} - Copy it now and store it securely. - - - - {newKey && ( -
-
- - {newKey.key} - -
- -
- )} -
-
- - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 836c5b119..734e9b42a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { AlertTriangle, Loader2 } from 'lucide-react' +import { Input, Label, Textarea } from '@/components/emcn' import { Alert, AlertDescription, @@ -16,10 +17,7 @@ import { Card, CardContent, ImageUpload, - Input, - Label, Skeleton, - Textarea, } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' import { getEmailDomain } from '@/lib/urls/utils' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx index e11a1962b..3923a1982 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' import { Check, Copy, Eye, EyeOff, Plus, RefreshCw } from 'lucide-react' +import { Input, Label } from '@/components/emcn' import { Trash } from '@/components/emcn/icons/trash' -import { Button, Card, CardContent, Input, Label } from '@/components/ui' +import { Button, Card, CardContent } from '@/components/ui' import { getEnv, isTruthy } from '@/lib/env' import { cn, generatePassword } from '@/lib/utils' import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input.tsx index cfbeb4d41..91a8abe56 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { Input, Label } from '@/components/ui' +import { Input, Label } from '@/components/emcn' import { getEmailDomain } from '@/lib/urls/utils' import { cn } from '@/lib/utils' import { useIdentifierValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view.tsx index 8e37e4330..4a361022c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view.tsx @@ -1,4 +1,4 @@ -import { Label } from '@/components/ui' +import { Label } from '@/components/emcn' import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils' interface ExistingChat { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx index 850b99347..c18800c12 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx @@ -1,7 +1,7 @@ 'use client' +import { Label } from '@/components/emcn' import { CopyButton } from '@/components/ui/copy-button' -import { Label } from '@/components/ui/label' interface ApiEndpointProps { endpoint: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx deleted file mode 100644 index 0651146c4..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import { Label } from '@/components/ui/label' - -interface ApiKeyProps { - apiKey: string - showLabel?: boolean -} - -export function ApiKey({ apiKey, showLabel = true }: ApiKeyProps) { - // Extract key name and type from the API response format "Name (type)" - const getKeyInfo = (keyInfo: string) => { - if (!keyInfo || keyInfo.includes('No API key found')) { - return { name: keyInfo, type: null } - } - - const match = keyInfo.match(/^(.*?)\s+\(([^)]+)\)$/) - if (match) { - return { name: match[1].trim(), type: match[2] } - } - - return { name: keyInfo, type: null } - } - - const { name, type } = getKeyInfo(apiKey) - - return ( -
- {showLabel && ( -
- -
- )} -
-
-
{name}
- {type && ( -
- - {type} - -
- )} -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx index 642e76ad4..5797d6151 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import { ChevronDown } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { Button, Label } from '@/components/emcn' +import { Button as UIButton } from '@/components/ui/button' import { CopyButton } from '@/components/ui/copy-button' import { DropdownMenu, @@ -10,7 +11,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { Label } from '@/components/ui/label' import { getEnv, isTruthy } from '@/lib/env' import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' @@ -147,46 +147,31 @@ export function ExampleCommand({ {showLabel && }
{isAsyncEnabled && ( <> - + { if (!workflowId) { @@ -130,41 +128,28 @@ export function DeploymentInfo({
- {deploymentInfo.needsRedeployment && ( - )} - - - - - - - Undeploy API - - Are you sure you want to undeploy this workflow? This will remove the API - endpoint and make it unavailable to external users. - - - - Cancel - - Undeploy - - - - +
@@ -179,6 +164,42 @@ export function DeploymentInfo({ onLoadDeploymentComplete={onLoadDeploymentComplete} /> )} + + {/* Undeploy Confirmation Modal */} + + + + Undeploy API + + Are you sure you want to undeploy this workflow? This will remove the API endpoint and + make it unavailable to external users.{' '} + + This action cannot be undone. + + + + + + + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index fdf1c3cac..75bddcd39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react' import { Loader2, MoreVertical, X } from 'lucide-react' +import { Badge, Button } from '@/components/emcn' import { - Button, Dialog, DialogContent, DialogHeader, @@ -12,10 +12,10 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Button as UIButton, } from '@/components/ui' import { getEnv } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { cn } from '@/lib/utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers' import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers' import { @@ -658,19 +658,17 @@ export function DeployModal({
Deploy Workflow {needsRedeployment && versions.length > 0 && versionToActivate === null && ( - + {versions.find((v) => v.isActive)?.name || `v${versions.find((v) => v.isActive)?.version}`}{' '} active - + )}
- @@ -680,46 +678,30 @@ export function DeployModal({
- - - - +
@@ -743,17 +725,7 @@ export function DeployModal({ } to production.`}
- +
- - +
)} @@ -1001,28 +973,16 @@ export function DeployModal({ } }} disabled={chatSubmitting} - className={cn( - 'gap-2 font-medium', - 'bg-red-500 hover:bg-red-600', - 'shadow-[0_0_0_0_rgb(239,68,68)] hover:shadow-[0_0_0_4px_rgba(239,68,68,0.15)]', - 'text-white transition-all duration-200', - 'disabled:opacity-50 disabled:hover:bg-red-500 disabled:hover:shadow-none' - )} + className='bg-red-500 text-white hover:bg-red-600' > Delete )} + + +
+ {(['system', 'user', 'assistant'] as const).map((role) => ( + { + updateMessageRole(index, role) + setOpenPopoverIndex(null) + }} + > + {formatRole(role)} + + ))} +
+
+ + + {!isPreview && !disabled && ( +
+ {currentMessages.length > 1 && ( + <> + + + + + )} + +
+ )} + + + {/* Content Input with overlay for variable highlighting */} +
+