diff --git a/sim/app/api/chat/[subdomain]/otp/route.ts b/sim/app/api/chat/[subdomain]/otp/route.ts new file mode 100644 index 000000000..e5e288a8b --- /dev/null +++ b/sim/app/api/chat/[subdomain]/otp/route.ts @@ -0,0 +1,319 @@ +import { NextRequest } from 'next/server' +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { chat } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { addCorsHeaders, setChatAuthCookie } from '../../utils' +import { sendEmail } from '@/lib/mailer' +import { render } from '@react-email/render' +import OTPVerificationEmail from '@/components/emails/otp-verification-email' +import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis' + +const logger = createLogger('ChatOtpAPI') + +function generateOTP() { + return Math.floor(100000 + Math.random() * 900000).toString() +} + +// OTP storage utility functions using Redis +// We use 15 minutes (900 seconds) expiry for OTPs +const OTP_EXPIRY = 15 * 60 + +// Store OTP in Redis +async function storeOTP(email: string, chatId: string, otp: string): Promise { + const key = `otp:${email}:${chatId}` + const redis = getRedisClient() + + if (redis) { + // Use Redis if available + await redis.set(key, otp, 'EX', OTP_EXPIRY) + } else { + // Use the existing function as fallback to mark that an OTP exists + await markMessageAsProcessed(key, OTP_EXPIRY) + + // For the fallback case, we need to handle storing the OTP value separately + // since markMessageAsProcessed only stores "1" + const valueKey = `${key}:value` + try { + // Access the in-memory cache directly - hacky but works for fallback + const inMemoryCache = (global as any).inMemoryCache + if (inMemoryCache) { + const fullKey = `processed:${valueKey}` + const expiry = OTP_EXPIRY ? Date.now() + OTP_EXPIRY * 1000 : null + inMemoryCache.set(fullKey, { value: otp, expiry }) + } + } catch (error) { + logger.error('Error storing OTP in fallback cache:', error) + } + } +} + +// Get OTP from Redis +async function getOTP(email: string, chatId: string): Promise { + const key = `otp:${email}:${chatId}` + const redis = getRedisClient() + + if (redis) { + // Use Redis if available + return await redis.get(key) + } else { + // Use the existing function as fallback - check if it exists + const exists = await new Promise(resolve => { + try { + // Check the in-memory cache directly - hacky but works for fallback + const inMemoryCache = (global as any).inMemoryCache + const fullKey = `processed:${key}` + const cacheEntry = inMemoryCache?.get(fullKey) + resolve(!!cacheEntry) + } catch { + resolve(false) + } + }) + + if (!exists) return null + + // Try to get the value key + const valueKey = `${key}:value` + try { + const inMemoryCache = (global as any).inMemoryCache + const fullKey = `processed:${valueKey}` + const cacheEntry = inMemoryCache?.get(fullKey) + return cacheEntry?.value || null + } catch { + return null + } + } +} + +// Delete OTP from Redis +async function deleteOTP(email: string, chatId: string): Promise { + const key = `otp:${email}:${chatId}` + const redis = getRedisClient() + + if (redis) { + // Use Redis if available + await redis.del(key) + } else { + // Use the existing function as fallback + await releaseLock(`processed:${key}`) + await releaseLock(`processed:${key}:value`) + } +} + +const otpRequestSchema = z.object({ + email: z.string().email('Invalid email address'), +}) + +const otpVerifySchema = z.object({ + email: z.string().email('Invalid email address'), + otp: z.string().length(6, 'OTP must be 6 digits'), +}) + +// Send OTP endpoint +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ subdomain: string }> } +) { + const { subdomain } = await params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Processing OTP request for subdomain: ${subdomain}`) + + // Parse request body + let body + try { + body = await request.json() + const { email } = otpRequestSchema.parse(body) + + // Find the chat deployment + const deploymentResult = await db + .select({ + id: chat.id, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + title: chat.title, + }) + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } + + const deployment = deploymentResult[0] + + // Verify this is an email-protected chat + if (deployment.authType !== 'email') { + return addCorsHeaders( + createErrorResponse('This chat does not use email authentication', 400), + request + ) + } + + const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) + ? deployment.allowedEmails + : [] + + // Check if the email is allowed + const isEmailAllowed = + allowedEmails.includes(email) || + allowedEmails.some((allowed: string) => { + if (allowed.startsWith('@')) { + const domain = email.split('@')[1] + return domain && allowed === `@${domain}` + } + return false + }) + + if (!isEmailAllowed) { + return addCorsHeaders( + createErrorResponse('Email not authorized for this chat', 403), + request + ) + } + + // Generate OTP + const otp = generateOTP() + + // Store OTP in Redis - AWAIT THIS BEFORE RETURNING RESPONSE + await storeOTP(email, deployment.id, otp) + + // Create the email + const emailContent = OTPVerificationEmail({ + otp, + email, + type: 'chat-access', + chatTitle: deployment.title || 'Chat', + }) + + // await the render function + const emailHtml = await render(emailContent) + + // MAKE SURE TO AWAIT THE EMAIL SENDING + const emailResult = await sendEmail({ + to: email, + subject: `Verification code for ${deployment.title || 'Chat'}`, + html: emailHtml, + }) + + if (!emailResult.success) { + logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) + return addCorsHeaders( + createErrorResponse('Failed to send verification email', 500), + request + ) + } + + // Add a small delay to ensure Redis has fully processed the operation + // This helps with eventual consistency in distributed systems + await new Promise(resolve => setTimeout(resolve, 500)) + + logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`) + return addCorsHeaders( + createSuccessResponse({ message: 'Verification code sent' }), + request + ) + } catch (error: any) { + if (error instanceof z.ZodError) { + return addCorsHeaders( + createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), + request + ) + } + throw error + } + } catch (error: any) { + logger.error(`[${requestId}] Error processing OTP request:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to process request', 500), + request + ) + } +} + +// Verify OTP endpoint +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ subdomain: string }> } +) { + const { subdomain } = await params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Verifying OTP for subdomain: ${subdomain}`) + + // Parse request body + let body + try { + body = await request.json() + const { email, otp } = otpVerifySchema.parse(body) + + // Find the chat deployment + const deploymentResult = await db + .select({ + id: chat.id, + authType: chat.authType, + }) + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } + + const deployment = deploymentResult[0] + + // Check if OTP exists and is valid + const storedOTP = await getOTP(email, deployment.id) + if (!storedOTP) { + return addCorsHeaders( + createErrorResponse('No verification code found, request a new one', 400), + request + ) + } + + // Check if OTP matches + if (storedOTP !== otp) { + return addCorsHeaders( + createErrorResponse('Invalid verification code', 400), + request + ) + } + + // OTP is valid, clean up + await deleteOTP(email, deployment.id) + + // Create success response with auth cookie + const response = addCorsHeaders( + createSuccessResponse({ authenticated: true }), + request + ) + + // Set authentication cookie + setChatAuthCookie(response, deployment.id, deployment.authType) + + return response + } catch (error: any) { + if (error instanceof z.ZodError) { + return addCorsHeaders( + createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), + request + ) + } + throw error + } + } catch (error: any) { + logger.error(`[${requestId}] Error verifying OTP:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to process request', 500), + request + ) + } +} \ No newline at end of file diff --git a/sim/app/api/chat/[subdomain]/route.ts b/sim/app/api/chat/[subdomain]/route.ts new file mode 100644 index 000000000..9d1eaa8be --- /dev/null +++ b/sim/app/api/chat/[subdomain]/route.ts @@ -0,0 +1,221 @@ +import { NextRequest } from 'next/server' +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { chat, workflow } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { addCorsHeaders, validateChatAuth, setChatAuthCookie, validateAuthToken, executeWorkflowForChat } from '../utils' + +const logger = createLogger('ChatSubdomainAPI') + +// This endpoint handles chat interactions via the subdomain +export async function POST(request: NextRequest, { params }: { params: Promise<{ subdomain: string }> }) { + const { subdomain } = await params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Processing chat request for subdomain: ${subdomain}`) + + // Parse the request body once + let parsedBody + try { + parsedBody = await request.json() + } catch (error) { + return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) + } + + // Find the chat deployment for this subdomain + const deploymentResult = await db + .select({ + id: chat.id, + workflowId: chat.workflowId, + userId: chat.userId, + isActive: chat.isActive, + authType: chat.authType, + password: chat.password, + allowedEmails: chat.allowedEmails, + outputBlockId: chat.outputBlockId, + outputPath: chat.outputPath, + }) + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } + + const deployment = deploymentResult[0] + + // Check if the chat is active + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chat is not active: ${subdomain}`) + return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request) + } + + // Validate authentication with the parsed body + const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) + if (!authResult.authorized) { + return addCorsHeaders(createErrorResponse(authResult.error || 'Authentication required', 401), request) + } + + // Use the already parsed body + const { message, password, email } = parsedBody + + // If this is an authentication request (has password or email but no message), + // set auth cookie and return success + if ((password || email) && !message) { + const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + + // Set authentication cookie + setChatAuthCookie(response, deployment.id, deployment.authType) + + return response + } + + // For chat messages, create regular response + if (!message) { + return addCorsHeaders(createErrorResponse('No message provided', 400), request) + } + + // Get the workflow for this chat + const workflowResult = await db + .select({ + isDeployed: workflow.isDeployed, + }) + .from(workflow) + .where(eq(workflow.id, deployment.workflowId)) + .limit(1) + + if (workflowResult.length === 0 || !workflowResult[0].isDeployed) { + logger.warn(`[${requestId}] Workflow not found or not deployed: ${deployment.workflowId}`) + return addCorsHeaders(createErrorResponse('Chat workflow is not available', 503), request) + } + + try { + // Execute the workflow using our helper function + const result = await executeWorkflowForChat(deployment.id, message) + + // Format the result for the client + // If result.content is an object, preserve it for structured handling + // If it's text or another primitive, make sure it's accessible + let formattedResult: any = { output: null } + + if (result && result.content) { + if (typeof result.content === 'object') { + // For objects like { text: "some content" } + if (result.content.text) { + formattedResult.output = result.content.text + } else { + // Keep the original structure but also add an output field + formattedResult = { + ...result, + output: JSON.stringify(result.content) + } + } + } else { + // For direct string content + formattedResult = { + ...result, + output: result.content + } + } + } else { + // Fallback if no content + formattedResult = { + ...result, + output: "No output returned from workflow" + } + } + + logger.info(`[${requestId}] Returning formatted chat response:`, { + hasOutput: !!formattedResult.output, + outputType: typeof formattedResult.output + }) + + // Add CORS headers before returning the response + return addCorsHeaders(createSuccessResponse(formattedResult), request) + } catch (error: any) { + logger.error(`[${requestId}] Error processing chat request:`, error) + return addCorsHeaders(createErrorResponse(error.message || 'Failed to process request', 500), request) + } + } catch (error: any) { + logger.error(`[${requestId}] Error processing chat request:`, error) + return addCorsHeaders(createErrorResponse(error.message || 'Failed to process request', 500), request) + } +} + +// This endpoint returns information about the chat +export async function GET(request: NextRequest, { params }: { params: Promise<{ subdomain: string }> }) { + const { subdomain } = await params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Fetching chat info for subdomain: ${subdomain}`) + + // Find the chat deployment for this subdomain + const deploymentResult = await db + .select({ + id: chat.id, + title: chat.title, + description: chat.description, + customizations: chat.customizations, + isActive: chat.isActive, + workflowId: chat.workflowId, + authType: chat.authType, + password: chat.password, + allowedEmails: chat.allowedEmails, + }) + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } + + const deployment = deploymentResult[0] + + // Check if the chat is active + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chat is not active: ${subdomain}`) + return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request) + } + + // Check for auth cookie first + const cookieName = `chat_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) + + if (deployment.authType !== 'public' && authCookie && validateAuthToken(authCookie.value, deployment.id)) { + // Cookie valid, return chat info + return addCorsHeaders(createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + }), request) + } + + // If no valid cookie, proceed with standard auth check + const authResult = await validateChatAuth(requestId, deployment, request) + if (!authResult.authorized) { + logger.info(`[${requestId}] Authentication required for chat: ${subdomain}, type: ${deployment.authType}`) + return addCorsHeaders(createErrorResponse(authResult.error || 'Authentication required', 401), request) + } + + // Return public information about the chat including auth type + return addCorsHeaders(createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + }), request) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching chat info:`, error) + return addCorsHeaders(createErrorResponse(error.message || 'Failed to fetch chat information', 500), request) + } +} \ No newline at end of file diff --git a/sim/app/api/chat/edit/[id]/route.ts b/sim/app/api/chat/edit/[id]/route.ts new file mode 100644 index 000000000..a540b8f2f --- /dev/null +++ b/sim/app/api/chat/edit/[id]/route.ts @@ -0,0 +1,303 @@ +import { NextRequest } from 'next/server' +import { and, eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { chat } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { z } from 'zod' +import { encryptSecret } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ChatDetailAPI') + +// Schema for updating an existing chat +const chatUpdateSchema = z.object({ + workflowId: z.string().min(1, "Workflow ID is required").optional(), + subdomain: z.string().min(1, "Subdomain is required") + .regex(/^[a-z0-9-]+$/, "Subdomain can only contain lowercase letters, numbers, and hyphens") + .optional(), + title: z.string().min(1, "Title is required").optional(), + description: z.string().optional(), + customizations: z.object({ + primaryColor: z.string(), + welcomeMessage: z.string(), + }).optional(), + authType: z.enum(["public", "password", "email"]).optional(), + password: z.string().optional(), + allowedEmails: z.array(z.string()).optional(), + outputBlockId: z.string().optional(), + outputPath: z.string().optional(), +}) + +/** + * GET endpoint to fetch a specific chat deployment by ID + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const chatId = id + + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + // Get the specific chat deployment + const chatInstance = await db + .select() + .from(chat) + .where(and( + eq(chat.id, chatId), + eq(chat.userId, session.user.id) + )) + .limit(1) + + if (chatInstance.length === 0) { + return createErrorResponse('Chat not found or access denied', 404) + } + + // Create a new result object without the password + const { password, ...safeData } = chatInstance[0] + + // Check if we're in development or production + const isDevelopment = process.env.NODE_ENV === 'development' + const chatUrl = isDevelopment + ? `http://${chatInstance[0].subdomain}.localhost:3000` + : `https://${chatInstance[0].subdomain}.simstudio.ai` + + // For security, don't return the actual password value + const result = { + ...safeData, + chatUrl, + // Include password presence flag but not the actual value + hasPassword: !!password + } + + return createSuccessResponse(result) + } catch (error: any) { + logger.error('Error fetching chat deployment:', error) + return createErrorResponse(error.message || 'Failed to fetch chat deployment', 500) + } +} + +/** + * PATCH endpoint to update an existing chat deployment + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const chatId = id + + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + const body = await request.json() + + try { + const validatedData = chatUpdateSchema.parse(body) + + // Verify the chat exists and belongs to the user + const existingChat = await db + .select() + .from(chat) + .where(and( + eq(chat.id, chatId), + eq(chat.userId, session.user.id) + )) + .limit(1) + + if (existingChat.length === 0) { + return createErrorResponse('Chat not found or access denied', 404) + } + + // Extract validated data + const { + workflowId, + subdomain, + title, + description, + customizations, + authType, + password, + allowedEmails, + outputBlockId, + outputPath + } = validatedData + + // Check if subdomain is changing and if it's available + if (subdomain && subdomain !== existingChat[0].subdomain) { + const existingSubdomain = await db + .select() + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + if (existingSubdomain.length > 0 && existingSubdomain[0].id !== chatId) { + return createErrorResponse('Subdomain already in use', 400) + } + } + + // Handle password update + let encryptedPassword = undefined + + // Only encrypt and update password if one is provided + if (password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + logger.info('Password provided, will be updated') + } else if (authType === 'password' && !password) { + // If switching to password auth but no password provided, + // check if there's an existing password + if (existingChat[0].authType !== 'password' || !existingChat[0].password) { + // If there's no existing password to reuse, return an error + return createErrorResponse('Password is required when using password protection', 400) + } + logger.info('Keeping existing password') + } + + // Prepare update data + const updateData: any = { + updatedAt: new Date(), + } + + // Only include fields that are provided + if (workflowId) updateData.workflowId = workflowId + if (subdomain) updateData.subdomain = subdomain + if (title) updateData.title = title + if (description !== undefined) updateData.description = description + if (customizations) updateData.customizations = customizations + + // Handle auth type update + if (authType) { + updateData.authType = authType + + // Reset auth-specific fields when changing auth types + if (authType === 'public') { + updateData.password = null + updateData.allowedEmails = [] + } + else if (authType === 'password') { + updateData.allowedEmails = [] + // Password handled separately + } + else if (authType === 'email') { + updateData.password = null + // Emails handled separately + } + } + + // Always update password if provided (not just when changing auth type) + if (encryptedPassword) { + updateData.password = encryptedPassword + } + + // Always update allowed emails if provided + if (allowedEmails) { + updateData.allowedEmails = allowedEmails + } + + // Handle output fields + if (outputBlockId !== undefined) updateData.outputBlockId = outputBlockId + if (outputPath !== undefined) updateData.outputPath = outputPath + + logger.info('Updating chat deployment with values:', { + chatId, + authType: updateData.authType, + hasPassword: updateData.password !== undefined, + emailCount: updateData.allowedEmails?.length, + outputBlockId: updateData.outputBlockId, + outputPath: updateData.outputPath + }) + + // Update the chat deployment + await db + .update(chat) + .set(updateData) + .where(eq(chat.id, chatId)) + + // Return success response + const updatedSubdomain = subdomain || existingChat[0].subdomain + // Check if we're in development or production + const isDevelopment = process.env.NODE_ENV === 'development' + const chatUrl = isDevelopment + ? `http://${updatedSubdomain}.localhost:3000` + : `https://${updatedSubdomain}.simstudio.ai` + + logger.info(`Chat "${chatId}" updated successfully`) + + return createSuccessResponse({ + id: chatId, + chatUrl, + message: 'Chat deployment updated successfully' + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid request data' + return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + } + throw validationError + } + } catch (error: any) { + logger.error('Error updating chat deployment:', error) + return createErrorResponse(error.message || 'Failed to update chat deployment', 500) + } +} + +/** + * DELETE endpoint to remove a chat deployment + */ +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const chatId = id + + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + // Verify the chat exists and belongs to the user + const existingChat = await db + .select() + .from(chat) + .where(and( + eq(chat.id, chatId), + eq(chat.userId, session.user.id) + )) + .limit(1) + + if (existingChat.length === 0) { + return createErrorResponse('Chat not found or access denied', 404) + } + + // Delete the chat deployment + await db + .delete(chat) + .where(eq(chat.id, chatId)) + + logger.info(`Chat "${chatId}" deleted successfully`) + + return createSuccessResponse({ + message: 'Chat deployment deleted successfully' + }) + } catch (error: any) { + logger.error('Error deleting chat deployment:', error) + return createErrorResponse(error.message || 'Failed to delete chat deployment', 500) + } +} \ No newline at end of file diff --git a/sim/app/api/chat/route.ts b/sim/app/api/chat/route.ts index 933f6ddb9..96991f7b0 100644 --- a/sim/app/api/chat/route.ts +++ b/sim/app/api/chat/route.ts @@ -1,216 +1,182 @@ -import { NextResponse } from 'next/server' -import { OpenAI } from 'openai' -import { ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import { NextRequest } from 'next/server' +import { and, eq } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' import { createLogger } from '@/lib/logs/console-logger' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { chat, workflow } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { encryptSecret } from '@/lib/utils' const logger = createLogger('ChatAPI') -// Validation schemas -const MessageSchema = z.object({ - role: z.enum(['user', 'assistant', 'system']), - content: z.string(), -}) - -const RequestSchema = z.object({ - messages: z.array(MessageSchema), - workflowState: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), +// Define Zod schema for API request validation +const chatSchema = z.object({ + workflowId: z.string().min(1, "Workflow ID is required"), + subdomain: z.string().min(1, "Subdomain is required") + .regex(/^[a-z0-9-]+$/, "Subdomain can only contain lowercase letters, numbers, and hyphens"), + title: z.string().min(1, "Title is required"), + description: z.string().optional(), + customizations: z.object({ + primaryColor: z.string(), + welcomeMessage: z.string(), }), + authType: z.enum(["public", "password", "email"]).default("public"), + password: z.string().optional(), + allowedEmails: z.array(z.string()).optional().default([]), + outputBlockId: z.string().optional(), + outputPath: z.string().optional(), }) -// Define function schemas with strict typing -const workflowActions = { - addBlock: { - description: 'Add one new block to the workflow', - parameters: { - type: 'object', - required: ['type'], - properties: { - type: { - type: 'string', - enum: ['agent', 'api', 'condition', 'function', 'router'], - description: 'The type of block to add', - }, - name: { - type: 'string', - description: - 'Optional custom name for the block. Do not provide a name unless the user has specified it.', - }, - position: { - type: 'object', - description: - 'Optional position for the block. Do not provide a position unless the user has specified it.', - properties: { - x: { type: 'number' }, - y: { type: 'number' }, - }, - }, - }, - }, - }, - addEdge: { - description: 'Create a connection (edge) between two blocks', - parameters: { - type: 'object', - required: ['sourceId', 'targetId'], - properties: { - sourceId: { - type: 'string', - description: 'ID of the source block', - }, - targetId: { - type: 'string', - description: 'ID of the target block', - }, - sourceHandle: { - type: 'string', - description: 'Optional handle identifier for the source connection point', - }, - targetHandle: { - type: 'string', - description: 'Optional handle identifier for the target connection point', - }, - }, - }, - }, - removeBlock: { - description: 'Remove a block from the workflow', - parameters: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string', description: 'ID of the block to remove' }, - }, - }, - }, - removeEdge: { - description: 'Remove a connection (edge) between blocks', - parameters: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string', description: 'ID of the edge to remove' }, - }, - }, - }, -} - -// System prompt that references workflow state -const getSystemPrompt = (workflowState: any) => { - const blockCount = Object.keys(workflowState.blocks).length - const edgeCount = workflowState.edges.length - - // Create a summary of existing blocks - const blockSummary = Object.values(workflowState.blocks) - .map((block: any) => `- ${block.type} block named "${block.name}" with id ${block.id}`) - .join('\n') - - // Create a summary of existing edges - const edgeSummary = workflowState.edges - .map((edge: any) => `- ${edge.source} -> ${edge.target} with id ${edge.id}`) - .join('\n') - - return `You are a workflow assistant that helps users modify their workflow by adding/removing blocks and connections. - -Current Workflow State: -${ - blockCount === 0 - ? 'The workflow is empty.' - : `${blockSummary} - -Connections: -${edgeCount === 0 ? 'No connections between blocks.' : edgeSummary}` -} - -When users request changes: -- Consider existing blocks when suggesting connections -- Provide clear feedback about what actions you've taken - -Use the following functions to modify the workflow: -1. Use the addBlock function to create a new block -2. Use the addEdge function to connect one block to another -3. Use the removeBlock function to remove a block -4. Use the removeEdge function to remove a connection - -Only use the provided functions and respond naturally to the user's requests.` -} - -export async function POST(request: Request) { - const requestId = crypto.randomUUID().slice(0, 8) - +export async function GET(request: NextRequest) { try { - // Validate API key - const apiKey = request.headers.get('X-OpenAI-Key') - if (!apiKey) { - return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 401 }) + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) } - - // Parse and validate request body - const body = await request.json() - const validatedData = RequestSchema.parse(body) - const { messages, workflowState } = validatedData - - // Initialize OpenAI client - const openai = new OpenAI({ apiKey }) - - // Create message history with workflow context - const messageHistory = [ - { role: 'system', content: getSystemPrompt(workflowState) }, - ...messages, - ] - - // Make OpenAI API call with workflow context - const completion = await openai.chat.completions.create({ - model: 'gpt-4o', - messages: messageHistory as ChatCompletionMessageParam[], - tools: Object.entries(workflowActions).map(([name, config]) => ({ - type: 'function', - function: { - name, - description: config.description, - parameters: config.parameters, - }, - })), - tool_choice: 'auto', - }) - - const message = completion.choices[0].message - - // Process tool calls if present - if (message.tool_calls) { - logger.debug(`[${requestId}] Tool calls:`, { - toolCalls: message.tool_calls, - }) - const actions = message.tool_calls.map((call) => ({ - name: call.function.name, - parameters: JSON.parse(call.function.arguments), - })) - - return NextResponse.json({ - message: message.content || "I've updated the workflow based on your request.", - actions, - }) - } - - // Return response with no actions - return NextResponse.json({ - message: - message.content || - "I'm not sure what changes to make to the workflow. Can you please provide more specific instructions?", - }) - } catch (error) { - logger.error(`[${requestId}] Chat API error:`, { error }) - - // Handle specific error types - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request format', details: error.errors }, - { status: 400 } - ) - } - - return NextResponse.json({ error: 'Failed to process chat message' }, { status: 500 }) + + // Get the user's chat deployments + const deployments = await db + .select() + .from(chat) + .where(eq(chat.userId, session.user.id)) + + return createSuccessResponse({ deployments }) + } catch (error: any) { + logger.error('Error fetching chat deployments:', error) + return createErrorResponse(error.message || 'Failed to fetch chat deployments', 500) } } + +export async function POST(request: NextRequest) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + // Parse and validate request body + const body = await request.json() + + try { + const validatedData = chatSchema.parse(body) + + // Extract validated data + const { + workflowId, + subdomain, + title, + description = '', + customizations, + authType = 'public', + password, + allowedEmails = [], + outputBlockId, + outputPath + } = validatedData + + // Perform additional validation specific to auth types + if (authType === 'password' && !password) { + return createErrorResponse('Password is required when using password protection', 400) + } + + if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse('At least one email or domain is required when using email access control', 400) + } + + // Check if subdomain is available + const existingSubdomain = await db + .select() + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + if (existingSubdomain.length > 0) { + return createErrorResponse('Subdomain already in use', 400) + } + + // Verify the workflow exists and belongs to the user + const workflowExists = await db + .select() + .from(workflow) + .where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id))) + .limit(1) + + if (workflowExists.length === 0) { + return createErrorResponse('Workflow not found or access denied', 404) + } + + // Verify the workflow is deployed (required for chat deployment) + if (!workflowExists[0].isDeployed) { + return createErrorResponse('Workflow must be deployed before creating a chat', 400) + } + + // Encrypt password if provided + let encryptedPassword = null + if (authType === 'password' && password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + } + + // Create the chat deployment + const id = uuidv4() + + // Log the values we're inserting + logger.info('Creating chat deployment with values:', { + workflowId, + subdomain, + title, + authType, + hasPassword: !!encryptedPassword, + emailCount: allowedEmails?.length || 0, + outputBlockId, + outputPath + }) + + await db.insert(chat).values({ + id, + workflowId, + userId: session.user.id, + subdomain, + title, + description: description || '', + customizations: customizations || {}, + isActive: true, + authType, + password: encryptedPassword, + allowedEmails: authType === 'email' ? allowedEmails : [], + outputBlockId: outputBlockId || null, + outputPath: outputPath || null, + createdAt: new Date(), + updatedAt: new Date(), + }) + + // Return successful response with chat URL + // Check if we're in development or production + const isDevelopment = process.env.NODE_ENV === 'development' + const chatUrl = isDevelopment + ? `http://${subdomain}.localhost:3000` + : `https://${subdomain}.simstudio.ai` + + logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) + + return createSuccessResponse({ + id, + chatUrl, + message: 'Chat deployment created successfully' + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid request data' + return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + } + throw validationError + } + } catch (error: any) { + logger.error('Error creating chat deployment:', error) + return createErrorResponse(error.message || 'Failed to create chat deployment', 500) + } +} \ No newline at end of file diff --git a/sim/app/api/chat/subdomain-check/route.ts b/sim/app/api/chat/subdomain-check/route.ts new file mode 100644 index 000000000..42ab8d027 --- /dev/null +++ b/sim/app/api/chat/subdomain-check/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { db } from '@/db' +import { getSession } from '@/lib/auth' +import { chat } from '@/db/schema' +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('SubdomainCheck') + +export async function GET(request: Request) { + // Check if the user is authenticated + const session = await getSession() + if (!session || !session.user) { + return createErrorResponse('Unauthorized', 401) + } + + try { + // Get subdomain from query parameters + const { searchParams } = new URL(request.url) + const subdomain = searchParams.get('subdomain') + + if (!subdomain) { + return createErrorResponse('Missing subdomain parameter', 400) + } + + // Check if subdomain follows allowed pattern (only lowercase letters, numbers, and hyphens) + if (!/^[a-z0-9-]+$/.test(subdomain)) { + return NextResponse.json( + { + available: false, + error: 'Invalid subdomain format' + }, + { status: 400 } + ) + } + + // Query database to see if subdomain already exists + const existingDeployment = await db + .select() + .from(chat) + .where(eq(chat.subdomain, subdomain)) + .limit(1) + + // Return availability status + return createSuccessResponse({ + available: existingDeployment.length === 0, + subdomain + }) + } catch (error) { + logger.error('Error checking subdomain availability:', error) + return createErrorResponse('Failed to check subdomain availability', 500) + } +} \ No newline at end of file diff --git a/sim/app/api/chat/utils.ts b/sim/app/api/chat/utils.ts new file mode 100644 index 000000000..3b1708740 --- /dev/null +++ b/sim/app/api/chat/utils.ts @@ -0,0 +1,469 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' +import { decryptSecret } from '@/lib/utils' +import { eq } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { db } from '@/db' +import { chat, workflow, environment as envTable } from '@/db/schema' +import { WorkflowState } from '@/stores/workflows/workflow/types' +import { Executor } from '@/executor' +import { Serializer } from '@/serializer' +import { mergeSubblockState } from '@/stores/workflows/utils' +import { persistExecutionLogs } from '@/lib/logs/execution-logger' +import { buildTraceSpans } from '@/lib/logs/trace-spans' + +const logger = createLogger('ChatAuthUtils') +const isDevelopment = process.env.NODE_ENV === 'development' + +// Simple encryption for the auth token +export const encryptAuthToken = (subdomainId: string, type: string): string => { + return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64') + } + + // Decrypt and validate the auth token + export const validateAuthToken = (token: string, subdomainId: string): boolean => { + try { + const decoded = Buffer.from(token, 'base64').toString() + const [storedId, type, timestamp] = decoded.split(':') + + // Check if token is for this subdomain + if (storedId !== subdomainId) { + return false + } + + // Check if token is not expired (24 hours) + const createdAt = parseInt(timestamp) + const now = Date.now() + const expireTime = 24 * 60 * 60 * 1000 // 24 hours + + if (now - createdAt > expireTime) { + return false + } + + return true + } catch (e) { + return false + } + } + + // Set cookie helper function + export const setChatAuthCookie = (response: NextResponse, subdomainId: string, type: string): void => { + const token = encryptAuthToken(subdomainId, type) + // Set cookie with HttpOnly and secure flags + response.cookies.set({ + name: `chat_auth_${subdomainId}`, + value: token, + httpOnly: true, + secure: !isDevelopment, + sameSite: 'lax', + path: '/', + // Using subdomain for the domain in production + domain: isDevelopment ? undefined : '.simstudio.ai', + maxAge: 60 * 60 * 24, // 24 hours + }) + } + + // Helper function to add CORS headers to responses + export function addCorsHeaders(response: NextResponse, request: NextRequest) { + // Get the origin from the request + const origin = request.headers.get('origin') || '' + + // In development, allow any localhost subdomain + if (isDevelopment && origin.includes('localhost')) { + response.headers.set('Access-Control-Allow-Origin', origin) + response.headers.set('Access-Control-Allow-Credentials', 'true') + response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + response.headers.set('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With') + } + + return response + } + + // Handle OPTIONS requests for CORS preflight + export async function OPTIONS(request: NextRequest) { + const response = new NextResponse(null, { status: 204 }) + return addCorsHeaders(response, request) + } + + // Validate authentication for chat access + export async function validateChatAuth( + requestId: string, + deployment: any, + request: NextRequest, + parsedBody?: any + ): Promise<{ authorized: boolean, error?: string }> { + const authType = deployment.authType || 'public' + + // Public chats are accessible to everyone + if (authType === 'public') { + return { authorized: true } + } + + // Check for auth cookie first + const cookieName = `chat_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) + + if (authCookie && validateAuthToken(authCookie.value, deployment.id)) { + return { authorized: true } + } + + // For password protection, check the password in the request body + if (authType === 'password') { + // For GET requests, we just notify the client that authentication is required + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_password' } + } + + try { + // Use the parsed body if provided, otherwise the auth check is not applicable + if (!parsedBody) { + return { authorized: false, error: 'Password is required' } + } + + const { password, message } = parsedBody + + // If this is a chat message, not an auth attempt + if (message && !password) { + return { authorized: false, error: 'auth_required_password' } + } + + if (!password) { + return { authorized: false, error: 'Password is required' } + } + + if (!deployment.password) { + logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`) + return { authorized: false, error: 'Authentication configuration error' } + } + + // Decrypt the stored password and compare + const { decrypted } = await decryptSecret(deployment.password) + if (password !== decrypted) { + return { authorized: false, error: 'Invalid password' } + } + + return { authorized: true } + } catch (error) { + logger.error(`[${requestId}] Error validating password:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + // For email access control, check the email in the request body + if (authType === 'email') { + // For GET requests, we just notify the client that authentication is required + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_email' } + } + + try { + // Use the parsed body if provided, otherwise the auth check is not applicable + if (!parsedBody) { + return { authorized: false, error: 'Email is required' } + } + + const { email, message } = parsedBody + + // If this is a chat message, not an auth attempt + if (message && !email) { + return { authorized: false, error: 'auth_required_email' } + } + + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + const allowedEmails = deployment.allowedEmails || [] + + // Check exact email matches + if (allowedEmails.includes(email)) { + // Email is allowed but still needs OTP verification + // Return a special error code that the client will recognize + return { authorized: false, error: 'otp_required' } + } + + // Check domain matches (prefixed with @) + const domain = email.split('@')[1] + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + // Domain is allowed but still needs OTP verification + return { authorized: false, error: 'otp_required' } + } + + return { authorized: false, error: 'Email not authorized' } + } catch (error) { + logger.error(`[${requestId}] Error validating email:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + // Unknown auth type + return { authorized: false, error: 'Unsupported authentication type' } + } + + /** + * Extract a specific output from a block using the blockId and path + * This mimics how the chat panel extracts outputs from blocks + */ +function extractBlockOutput(logs: any[], blockId: string, path?: string) { + // Find the block in logs + const blockLog = logs.find(log => log.blockId === blockId) + if (!blockLog || !blockLog.output) return null + + // If no specific path, return the full output + if (!path) return blockLog.output + + // Navigate the path to extract the specific output + let result = blockLog.output + const pathParts = path.split('.') + + for (const part of pathParts) { + if (result === null || result === undefined || typeof result !== 'object') { + return null + } + result = result[part] + } + + return result +} + +/** + * Executes a workflow for a chat and extracts the specified output. + * This function contains the same logic as the internal chat panel. + */ +export async function executeWorkflowForChat(chatId: string, message: string) { + const requestId = crypto.randomUUID().slice(0, 8) + + logger.debug(`[${requestId}] Executing workflow for chat: ${chatId}`) + + // Find the chat deployment + const deploymentResult = await db + .select({ + id: chat.id, + workflowId: chat.workflowId, + userId: chat.userId, + outputBlockId: chat.outputBlockId, + outputPath: chat.outputPath, + }) + .from(chat) + .where(eq(chat.id, chatId)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found: ${chatId}`) + throw new Error('Chat not found') + } + + const deployment = deploymentResult[0] + const workflowId = deployment.workflowId + + // Find the workflow + const workflowResult = await db + .select({ + state: workflow.state, + deployedState: workflow.deployedState, + isDeployed: workflow.isDeployed, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (workflowResult.length === 0 || !workflowResult[0].isDeployed) { + logger.warn(`[${requestId}] Workflow not found or not deployed: ${workflowId}`) + throw new Error('Workflow not available') + } + + // Use deployed state for execution + const state = (workflowResult[0].deployedState || workflowResult[0].state) as WorkflowState + const { blocks, edges, loops } = state + + // Prepare for execution, similar to use-workflow-execution.ts + const mergedStates = mergeSubblockState(blocks) + const currentBlockStates = Object.entries(mergedStates).reduce( + (acc, [id, block]) => { + acc[id] = Object.entries(block.subBlocks).reduce( + (subAcc, [key, subBlock]) => { + subAcc[key] = subBlock.value + return subAcc + }, + {} as Record + ) + return acc + }, + {} as Record> + ) + + // Get user environment variables for this workflow + let envVars: Record = {} + try { + const envResult = await db + .select() + .from(envTable) + .where(eq(envTable.userId, deployment.userId)) + .limit(1) + + if (envResult.length > 0 && envResult[0].variables) { + envVars = envResult[0].variables as Record + } + } catch (error) { + logger.warn(`[${requestId}] Could not fetch environment variables:`, error) + } + + // Get workflow variables + let workflowVariables = {} + try { + // The workflow state may contain variables + const workflowState = state as any + if (workflowState.variables) { + workflowVariables = typeof workflowState.variables === 'string' + ? JSON.parse(workflowState.variables) + : workflowState.variables + } + } catch (error) { + logger.warn(`[${requestId}] Could not parse workflow variables:`, error) + } + + // Create serialized workflow + const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates, edges, loops) + + // Decrypt environment variables + const decryptedEnvVars: Record = {} + for (const [key, encryptedValue] of Object.entries(envVars)) { + try { + const { decrypted } = await decryptSecret(encryptedValue) + decryptedEnvVars[key] = decrypted + } catch (error: any) { + logger.error(`[${requestId}] Failed to decrypt environment variable "${key}"`, error) + // Log but continue - we don't want to break execution if just one var fails + } + } + + // Process block states to ensure response formats are properly parsed + const processedBlockStates = Object.entries(currentBlockStates).reduce( + (acc, [blockId, blockState]) => { + // Check if this block has a responseFormat that needs to be parsed + if (blockState.responseFormat && typeof blockState.responseFormat === 'string') { + try { + logger.debug(`[${requestId}] Parsing responseFormat for block ${blockId}`) + // Attempt to parse the responseFormat if it's a string + const parsedResponseFormat = JSON.parse(blockState.responseFormat) + + acc[blockId] = { + ...blockState, + responseFormat: parsedResponseFormat, + } + } catch (error) { + logger.warn(`[${requestId}] Failed to parse responseFormat for block ${blockId}`, error) + acc[blockId] = blockState + } + } else { + acc[blockId] = blockState + } + return acc + }, + {} as Record> + ) + + // Create and execute the workflow - mimicking use-workflow-execution.ts + const executor = new Executor( + serializedWorkflow, + processedBlockStates, + decryptedEnvVars, + { input: message }, + workflowVariables + ) + + // Execute and capture the result + const result = await executor.execute(workflowId) + + // Mark as chat execution in metadata + if (result) { + (result as any).metadata = { + ...(result.metadata || {}), + source: 'chat' + } + } + + // Persist execution logs using the 'chat' trigger type + try { + // Build trace spans to enrich the logs (same as in use-workflow-execution.ts) + const { traceSpans, totalDuration } = buildTraceSpans(result) + + // Create enriched result with trace data + const enrichedResult = { + ...result, + traceSpans, + totalDuration, + } + + // Generate a unique execution ID for this chat interaction + const executionId = uuidv4() + + // Persist the logs with 'chat' trigger type + await persistExecutionLogs(workflowId, executionId, enrichedResult, 'chat') + + logger.debug(`[${requestId}] Persisted execution logs for chat with ID: ${executionId}`) + } catch (error) { + // Don't fail the chat response if logging fails + logger.error(`[${requestId}] Failed to persist chat execution logs:`, error) + } + + if (!result.success) { + logger.error(`[${requestId}] Workflow execution failed:`, result.error) + throw new Error(`Workflow execution failed: ${result.error}`) + } + + logger.debug(`[${requestId}] Workflow executed successfully, blocks executed: ${result.logs?.length || 0}`) + + // Get the output based on the selected block + let output + + if (deployment.outputBlockId) { + // Determine appropriate output + const blockId = deployment.outputBlockId + const path = deployment.outputPath + + // This is identical to what the chat panel does to extract outputs + logger.debug(`[${requestId}] Looking for output from block ${blockId} with path ${path || 'none'}`) + + // Extract the specific block output + if (result.logs) { + output = extractBlockOutput(result.logs, blockId, path || undefined) + + if (output !== null && output !== undefined) { + logger.debug(`[${requestId}] Found specific block output`) + } else { + logger.warn(`[${requestId}] Could not find specific block output, falling back to final output`) + output = result.output?.response || result.output + } + } else { + logger.warn(`[${requestId}] No logs found in execution result, using final output`) + output = result.output?.response || result.output + } + } else { + // No specific block selected, use final output + logger.debug(`[${requestId}] No output block specified, using final output`) + output = result.output?.response || result.output + } + + // Format the output the same way ChatMessage does + let formattedOutput + + if (typeof output === 'object' && output !== null) { + // For objects, use the entire object (ChatMessage component handles display) + formattedOutput = output + } else { + // For strings or primitives, format as text + formattedOutput = { text: String(output) } + } + + // Add a timestamp like the chat panel adds to messages + const timestamp = new Date().toISOString() + + // Create a response that mimics the structure in the chat panel + return { + id: uuidv4(), + content: formattedOutput, + timestamp: timestamp, + type: 'workflow' + } +} \ No newline at end of file diff --git a/sim/app/api/copilot/route.ts b/sim/app/api/copilot/route.ts new file mode 100644 index 000000000..804226eec --- /dev/null +++ b/sim/app/api/copilot/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from 'next/server' +import { OpenAI } from 'openai' +import { ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('CopilotAPI') + +// Validation schemas +const MessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system']), + content: z.string(), +}) + +const RequestSchema = z.object({ + messages: z.array(MessageSchema), + workflowState: z.object({ + blocks: z.record(z.any()), + edges: z.array(z.any()), + }), +}) + +// Define function schemas with strict typing +const workflowActions = { + addBlock: { + description: 'Add one new block to the workflow', + parameters: { + type: 'object', + required: ['type'], + properties: { + type: { + type: 'string', + enum: ['agent', 'api', 'condition', 'function', 'router'], + description: 'The type of block to add', + }, + name: { + type: 'string', + description: + 'Optional custom name for the block. Do not provide a name unless the user has specified it.', + }, + position: { + type: 'object', + description: + 'Optional position for the block. Do not provide a position unless the user has specified it.', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + }, + }, + }, + }, + }, + addEdge: { + description: 'Create a connection (edge) between two blocks', + parameters: { + type: 'object', + required: ['sourceId', 'targetId'], + properties: { + sourceId: { + type: 'string', + description: 'ID of the source block', + }, + targetId: { + type: 'string', + description: 'ID of the target block', + }, + sourceHandle: { + type: 'string', + description: 'Optional handle identifier for the source connection point', + }, + targetHandle: { + type: 'string', + description: 'Optional handle identifier for the target connection point', + }, + }, + }, + }, + removeBlock: { + description: 'Remove a block from the workflow', + parameters: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', description: 'ID of the block to remove' }, + }, + }, + }, + removeEdge: { + description: 'Remove a connection (edge) between blocks', + parameters: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', description: 'ID of the edge to remove' }, + }, + }, + }, +} + +// System prompt that references workflow state +const getSystemPrompt = (workflowState: any) => { + const blockCount = Object.keys(workflowState.blocks).length + const edgeCount = workflowState.edges.length + + // Create a summary of existing blocks + const blockSummary = Object.values(workflowState.blocks) + .map((block: any) => `- ${block.type} block named "${block.name}" with id ${block.id}`) + .join('\n') + + // Create a summary of existing edges + const edgeSummary = workflowState.edges + .map((edge: any) => `- ${edge.source} -> ${edge.target} with id ${edge.id}`) + .join('\n') + + return `You are a workflow assistant that helps users modify their workflow by adding/removing blocks and connections. + +Current Workflow State: +${ + blockCount === 0 + ? 'The workflow is empty.' + : `${blockSummary} + +Connections: +${edgeCount === 0 ? 'No connections between blocks.' : edgeSummary}` +} + +When users request changes: +- Consider existing blocks when suggesting connections +- Provide clear feedback about what actions you've taken + +Use the following functions to modify the workflow: +1. Use the addBlock function to create a new block +2. Use the addEdge function to connect one block to another +3. Use the removeBlock function to remove a block +4. Use the removeEdge function to remove a connection + +Only use the provided functions and respond naturally to the user's requests.` +} + +export async function POST(request: Request) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + // Validate API key + const apiKey = request.headers.get('X-OpenAI-Key') + if (!apiKey) { + return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 401 }) + } + + // Parse and validate request body + const body = await request.json() + const validatedData = RequestSchema.parse(body) + const { messages, workflowState } = validatedData + + // Initialize OpenAI client + const openai = new OpenAI({ apiKey }) + + // Create message history with workflow context + const messageHistory = [ + { role: 'system', content: getSystemPrompt(workflowState) }, + ...messages, + ] + + // Make OpenAI API call with workflow context + const completion = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: messageHistory as ChatCompletionMessageParam[], + tools: Object.entries(workflowActions).map(([name, config]) => ({ + type: 'function', + function: { + name, + description: config.description, + parameters: config.parameters, + }, + })), + tool_choice: 'auto', + }) + + const message = completion.choices[0].message + + // Process tool calls if present + if (message.tool_calls) { + logger.debug(`[${requestId}] Tool calls:`, { + toolCalls: message.tool_calls, + }) + const actions = message.tool_calls.map((call) => ({ + name: call.function.name, + parameters: JSON.parse(call.function.arguments), + })) + + return NextResponse.json({ + message: message.content || "I've updated the workflow based on your request.", + actions, + }) + } + + // Return response with no actions + return NextResponse.json({ + message: + message.content || + "I'm not sure what changes to make to the workflow. Can you please provide more specific instructions?", + }) + } catch (error) { + logger.error(`[${requestId}] Copilot API error:`, { error }) + + // Handle specific error types + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request format', details: error.errors }, + { status: 400 } + ) + } + + return NextResponse.json({ error: 'Failed to process copilot message' }, { status: 500 }) + } +} diff --git a/sim/app/api/workflows/[id]/chat/status/route.ts b/sim/app/api/workflows/[id]/chat/status/route.ts new file mode 100644 index 000000000..098ffcf03 --- /dev/null +++ b/sim/app/api/workflows/[id]/chat/status/route.ts @@ -0,0 +1,49 @@ +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { chat } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('ChatStatusAPI') + +/** + * GET endpoint to check if a workflow has an active chat deployment + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Checking chat deployment status for workflow: ${id}`) + + // Find any active chat deployments for this workflow + const deploymentResults = await db + .select({ + id: chat.id, + subdomain: chat.subdomain, + isActive: chat.isActive, + }) + .from(chat) + .where(eq(chat.workflowId, id)) + .limit(1) + + const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive + const deploymentInfo = deploymentResults.length > 0 + ? { + id: deploymentResults[0].id, + subdomain: deploymentResults[0].subdomain, + } + : null + + return createSuccessResponse({ + isDeployed, + deployment: deploymentInfo, + }) + } catch (error: any) { + logger.error(`[${requestId}] Error checking chat deployment status:`, error) + return createErrorResponse(error.message || 'Failed to check chat deployment status', 500) + } +} \ No newline at end of file diff --git a/sim/app/api/workflows/[id]/log/route.ts b/sim/app/api/workflows/[id]/log/route.ts index 1eb654329..eba5ecf1a 100644 --- a/sim/app/api/workflows/[id]/log/route.ts +++ b/sim/app/api/workflows/[id]/log/route.ts @@ -30,8 +30,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ success: result.success, }) + // Check if this execution is from chat using only the explicit source flag + const isChatExecution = result.metadata?.source === 'chat' + // Use persistExecutionLogs which handles tool call extraction - await persistExecutionLogs(id, executionId, result, 'manual') + // Use 'chat' trigger type for chat executions, otherwise 'manual' + await persistExecutionLogs(id, executionId, result, isChatExecution ? 'chat' : 'manual') return createSuccessResponse({ message: 'Execution logs persisted successfully', diff --git a/sim/app/chat/[subdomain]/components/chat-client.tsx b/sim/app/chat/[subdomain]/components/chat-client.tsx new file mode 100644 index 000000000..74bd0b252 --- /dev/null +++ b/sim/app/chat/[subdomain]/components/chat-client.tsx @@ -0,0 +1,693 @@ +'use client' + +import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowUp, Loader2, Lock, Mail } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { OTPInputForm } from '@/components/ui/input-otp-form' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' + +// Define message type +interface ChatMessage { + id: string + content: string + type: 'user' | 'assistant' + timestamp: Date +} + +// Define chat config type +interface ChatConfig { + id: string + title: string + description: string + customizations: { + primaryColor?: string + logoUrl?: string + welcomeMessage?: string + headerText?: string + } + authType?: 'public' | 'password' | 'email' +} + +// ChatGPT-style message component +function ClientChatMessage({ message }: { message: ChatMessage }) { + // Check if content is a JSON object + const isJsonObject = useMemo(() => { + return typeof message.content === 'object' && message.content !== null + }, [message.content]) + + // For user messages (on the right) + if (message.type === 'user') { + return ( +
+
+
+
+
+ {isJsonObject ? ( +
{JSON.stringify(message.content, null, 2)}
+ ) : ( + {message.content} + )} +
+
+
+
+
+ ) + } + + // For assistant messages (on the left) + return ( +
+
+
+
+
+ {isJsonObject ? ( +
{JSON.stringify(message.content, null, 2)}
+ ) : ( + {message.content} + )} +
+
+
+
+
+ ) +} + +export default function ChatClient({ subdomain }: { subdomain: string }) { + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [chatConfig, setChatConfig] = useState(null) + const [error, setError] = useState(null) + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + const inputRef = useRef(null) + + // Authentication state + const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null) + const [password, setPassword] = useState('') + const [email, setEmail] = useState('') + const [authError, setAuthError] = useState(null) + const [isAuthenticating, setIsAuthenticating] = useState(false) + + // OTP verification state + const [showOtpVerification, setShowOtpVerification] = useState(false) + const [otpValue, setOtpValue] = useState('') + const [isSendingOtp, setIsSendingOtp] = useState(false) + const [isVerifyingOtp, setIsVerifyingOtp] = useState(false) + + // Fetch chat config function + const fetchChatConfig = async () => { + try { + // Use relative URL instead of absolute URL with process.env.NEXT_PUBLIC_APP_URL + const response = await fetch(`/api/chat/${subdomain}`, { + credentials: 'same-origin', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }) + + if (!response.ok) { + // Check if auth is required + if (response.status === 401) { + const errorData = await response.json() + + if (errorData.error === 'auth_required_password') { + setAuthRequired('password') + return + } else if (errorData.error === 'auth_required_email') { + setAuthRequired('email') + return + } + } + + throw new Error(`Failed to load chat configuration: ${response.status}`) + } + + const data = await response.json() + + // The API returns the data directly without a wrapper + setChatConfig(data) + + // Add welcome message if configured + if (data?.customizations?.welcomeMessage) { + setMessages([ + { + id: 'welcome', + content: data.customizations.welcomeMessage, + type: 'assistant', + timestamp: new Date(), + }, + ]) + } + } catch (error) { + console.error('Error fetching chat config:', error) + setError('This chat is currently unavailable. Please try again later.') + } + } + + // Fetch chat config on mount + useEffect(() => { + fetchChatConfig() + }, [subdomain]) + + // Handle keyboard input for message sending + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // Handle keyboard input for auth forms + const handleAuthKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAuthenticate() + } + } + + // Handle authentication + const handleAuthenticate = async () => { + if (authRequired === 'password') { + // Password auth remains the same + setAuthError(null) + setIsAuthenticating(true) + + try { + const payload = { password } + + const response = await fetch(`/api/chat/${subdomain}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Authentication failed') + return + } + + await response.json() + + // Authentication successful, fetch config again + await fetchChatConfig() + + // Reset auth state + setAuthRequired(null) + setPassword('') + } catch (error) { + console.error('Authentication error:', error) + setAuthError('An error occurred during authentication') + } finally { + setIsAuthenticating(false) + } + } else if (authRequired === 'email') { + // For email auth, we now send an OTP first + if (!showOtpVerification) { + // Step 1: User has entered email, send OTP + setAuthError(null) + setIsSendingOtp(true) + + try { + const response = await fetch(`/api/chat/${subdomain}/otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email }), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Failed to send verification code') + return + } + + // OTP sent successfully, show OTP input + setShowOtpVerification(true) + } catch (error) { + console.error('Error sending OTP:', error) + setAuthError('An error occurred while sending the verification code') + } finally { + setIsSendingOtp(false) + } + } else { + // Step 2: User has entered OTP, verify it + setAuthError(null) + setIsVerifyingOtp(true) + + try { + const response = await fetch(`/api/chat/${subdomain}/otp`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email, otp: otpValue }), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Invalid verification code') + return + } + + await response.json() + + // OTP verified successfully, fetch config again + await fetchChatConfig() + + // Reset auth state + setAuthRequired(null) + setEmail('') + setOtpValue('') + setShowOtpVerification(false) + } catch (error) { + console.error('Error verifying OTP:', error) + setAuthError('An error occurred during verification') + } finally { + setIsVerifyingOtp(false) + } + } + } + } + + // Add this function to handle resending OTP + const handleResendOtp = async () => { + setAuthError(null) + setIsSendingOtp(true) + + try { + const response = await fetch(`/api/chat/${subdomain}/otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email }), + }) + + if (!response.ok) { + const errorData = await response.json() + setAuthError(errorData.error || 'Failed to resend verification code') + return + } + + // Show a message that OTP was sent + setAuthError('Verification code sent. Please check your email.') + } catch (error) { + console.error('Error resending OTP:', error) + setAuthError('An error occurred while resending the verification code') + } finally { + setIsSendingOtp(false) + } + } + + // Add a function to handle email input key down + const handleEmailKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAuthenticate() + } + } + + // Add a function to handle OTP input key down + const handleOtpKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAuthenticate() + } + } + + // Scroll to bottom of messages + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [messages]) + + // Handle sending a message + const handleSendMessage = async () => { + if (!inputValue.trim() || isLoading) return + + const userMessage: ChatMessage = { + id: crypto.randomUUID(), + content: inputValue, + type: 'user', + timestamp: new Date(), + } + + setMessages((prev) => [...prev, userMessage]) + setInputValue('') + setIsLoading(true) + + // Ensure focus remains on input field + if (inputRef.current) { + inputRef.current.focus() + } + + try { + // Use relative URL with credentials + const response = await fetch(`/api/chat/${subdomain}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ message: userMessage.content }), + }) + + if (!response.ok) { + throw new Error('Failed to get response') + } + + const responseData = await response.json() + console.log('Message response:', responseData) + + // Extract content from the response - could be in content or output + let messageContent = responseData.output + + // Handle different response formats from API + if (!messageContent && responseData.content) { + // Content could be an object or a string + if (typeof responseData.content === 'object') { + // If it's an object with a text property, use that + if (responseData.content.text) { + messageContent = responseData.content.text + } else { + // Try to convert to string for display + try { + messageContent = JSON.stringify(responseData.content) + } catch (e) { + messageContent = 'Received structured data response' + } + } + } else { + // Direct string content + messageContent = responseData.content + } + } + + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + content: messageContent || "Sorry, I couldn't process your request.", + type: 'assistant', + timestamp: new Date(), + } + + setMessages((prev) => [...prev, assistantMessage]) + } catch (error) { + console.error('Error sending message:', error) + + const errorMessage: ChatMessage = { + id: crypto.randomUUID(), + content: 'Sorry, there was an error processing your message. Please try again.', + type: 'assistant', + timestamp: new Date(), + } + + setMessages((prev) => [...prev, errorMessage]) + } finally { + setIsLoading(false) + // Ensure focus remains on input field even after the response + if (inputRef.current) { + inputRef.current.focus() + } + } + } + + // If error, show error message + if (error) { + return ( +
+
+

Error

+

{error}

+
+
+ ) + } + + // If authentication is required, show auth form + if (authRequired) { + // Get title and description from the URL params or use defaults + const title = new URLSearchParams(window.location.search).get('title') || 'chat' + const primaryColor = new URLSearchParams(window.location.search).get('color') || '#802FFF' + + return ( +
+
+
+

{title}

+

+ {authRequired === 'password' + ? 'This chat is password-protected. Please enter the password to continue.' + : 'This chat requires email verification. Please enter your email to continue.'} +

+
+ + {authError && ( +
+ {authError} +
+ )} + +
+ {authRequired === 'password' ? ( +
+ + setPassword(e.target.value)} + onKeyDown={handleAuthKeyDown} + placeholder="Enter password" + className="pl-10" + disabled={isAuthenticating} + /> +
+ ) : ( +
+
+
+
+ +
+
+ +

Email Verification

+ + {!showOtpVerification ? ( + // Step 1: Email Input + <> +

+ Enter your email address to access this chat +

+ +
+
+ + setEmail(e.target.value)} + onKeyDown={handleEmailKeyDown} + disabled={isSendingOtp || isAuthenticating} + className="w-full" + /> +
+ + {authError && ( +
{authError}
+ )} + + +
+ + ) : ( + // Step 2: OTP Verification with OTPInputForm + <> +

+ Enter the verification code sent to +

+

{email}

+ + { + setOtpValue(value) + handleAuthenticate() + }} + isLoading={isVerifyingOtp} + error={authError} + /> + +
+ + • + +
+ + )} +
+
+ )} +
+
+
+ ) + } + + // Loading state while fetching config + if (!chatConfig) { + return ( +
+
+
+
+
+
+ ) + } + + return ( +
+ + + {/* Header with title */} +
+

+ {chatConfig.customizations?.headerText || chatConfig.title || 'Chat'} +

+ {chatConfig.customizations?.logoUrl && ( + {`${chatConfig.title} + )} +
+ + {/* Messages container */} +
+
+ {messages.length === 0 ? ( +
+
+

How can I help you today?

+

+ {chatConfig.description || 'Ask me anything.'} +

+
+
+ ) : ( + messages.map((message) => ) + )} + + {/* Loading indicator (shows only when executing) */} + {isLoading && ( +
+
+
+
+
+
+
+
+
+
+
+ )} + +
+
+
+ + {/* Input area (fixed at bottom) */} +
+
+
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Message..." + className="flex-1 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 py-7 pr-16 bg-transparent pl-6 text-base min-h-[50px] rounded-2xl" + /> + +
+
+
+
+ ) +} diff --git a/sim/app/chat/[subdomain]/page.tsx b/sim/app/chat/[subdomain]/page.tsx new file mode 100644 index 000000000..9cc8195a4 --- /dev/null +++ b/sim/app/chat/[subdomain]/page.tsx @@ -0,0 +1,10 @@ +import { createLogger } from '@/lib/logs/console-logger' +import ChatClient from './components/chat-client' + +const logger = createLogger('ChatPage') + +export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) { + const { subdomain } = await params + logger.info(`[ChatPage] subdomain: ${subdomain}`) + return +} diff --git a/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx new file mode 100644 index 000000000..6bd004b74 --- /dev/null +++ b/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -0,0 +1,1282 @@ +'use client' + +import { FormEvent, useEffect, useRef, useState } from 'react' +import { + AlertTriangle, + Check, + Circle, + Copy, + Eye, + EyeOff, + Loader2, + Plus, + RefreshCw, + Trash2, +} from 'lucide-react' +import { z } from 'zod' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { CopyButton } from '@/components/ui/copy-button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { Textarea } from '@/components/ui/textarea' +import { createLogger } from '@/lib/logs/console-logger' +import { cn } from '@/lib/utils' +import { useNotificationStore } from '@/stores/notifications/store' +import { OutputSelect } from '@/app/w/[id]/components/panel/components/chat/components/output-select/output-select' + +const logger = createLogger('ChatDeploy') + +interface ChatDeployProps { + workflowId: string + onClose: () => void + deploymentInfo: { + apiKey: string + } | null + onChatExistsChange?: (exists: boolean) => void + showDeleteConfirmation?: boolean + setShowDeleteConfirmation?: (show: boolean) => void +} + +type AuthType = 'public' | 'password' | 'email' + +const isDevelopment = process.env.NODE_ENV === 'development' + +// Define Zod schema for API request validation +const chatSchema = z.object({ + workflowId: z.string().min(1, 'Workflow ID is required'), + subdomain: z + .string() + .min(1, 'Subdomain is required') + .regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens'), + title: z.string().min(1, 'Title is required'), + description: z.string().optional(), + customizations: z.object({ + primaryColor: z.string(), + welcomeMessage: z.string(), + }), + authType: z.enum(['public', 'password', 'email']), + password: z.string().optional(), + allowedEmails: z.array(z.string()).optional(), + outputBlockId: z.string().nullish(), + outputPath: z.string().nullish(), +}) + +export function ChatDeploy({ + workflowId, + onClose, + deploymentInfo, + onChatExistsChange, + showDeleteConfirmation: externalShowDeleteConfirmation, + setShowDeleteConfirmation: externalSetShowDeleteConfirmation, +}: ChatDeployProps) { + // Store hooks + const { addNotification } = useNotificationStore() + + // Form state + const [subdomain, setSubdomain] = useState('') + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [isDeploying, setIsDeploying] = useState(false) + const [subdomainError, setSubdomainError] = useState('') + const [deployedChatUrl, setDeployedChatUrl] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const [isCheckingSubdomain, setIsCheckingSubdomain] = useState(false) + const [subdomainAvailable, setSubdomainAvailable] = useState(null) + const subdomainCheckTimeoutRef = useRef(null) + + // Authentication options + const [authType, setAuthType] = useState('public') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [emails, setEmails] = useState([]) + const [newEmail, setNewEmail] = useState('') + const [emailError, setEmailError] = useState('') + const [copySuccess, setCopySuccess] = useState(false) + + // Existing chat state + const [existingChat, setExistingChat] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [dataFetched, setDataFetched] = useState(false) + + // Track original values to detect changes + const [originalValues, setOriginalValues] = useState<{ + subdomain: string + title: string + description: string + authType: AuthType + emails: string[] + outputBlockId: string | null + } | null>(null) + + // State to track if any changes have been made + const [hasChanges, setHasChanges] = useState(false) + + // Confirmation dialogs + const [showEditConfirmation, setShowEditConfirmation] = useState(false) + const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false) + + // Output block selection + const [selectedOutputBlock, setSelectedOutputBlock] = useState(null) + + // Track manual submission state + const [chatSubmitting, setChatSubmitting] = useState(false) + + // Set up a ref for the form element + const formRef = useRef(null) + + // Use external state for delete confirmation if provided + const showDeleteConfirmation = + externalShowDeleteConfirmation !== undefined + ? externalShowDeleteConfirmation + : internalShowDeleteConfirmation + + const setShowDeleteConfirmation = + externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation + + // Welcome message state + const [welcomeMessage, setWelcomeMessage] = useState('Hi there! How can I help you today?') + + // Expose a method to handle external submission requests + useEffect(() => { + // This will run when the component mounts + // Ensure hidden input for API deployment is set up + if (formRef.current) { + let deployApiInput = formRef.current.querySelector('#deployApiEnabled') as HTMLInputElement + if (!deployApiInput) { + deployApiInput = document.createElement('input') + deployApiInput.type = 'hidden' + deployApiInput.id = 'deployApiEnabled' + deployApiInput.name = 'deployApiEnabled' + deployApiInput.value = 'true' + formRef.current.appendChild(deployApiInput) + } + } + + // Clean up any loading states + return () => { + setIsDeploying(false) + setChatSubmitting(false) + } + }, []) + + // Fetch existing chat data when component mounts + useEffect(() => { + if (workflowId) { + setIsLoading(true) + setDataFetched(false) + fetchExistingChat() + } + }, [workflowId]) + + // Check for changes when form values update + useEffect(() => { + if (originalValues && existingChat) { + const currentAuthTypeChanged = authType !== originalValues.authType + const subdomainChanged = subdomain !== originalValues.subdomain + const titleChanged = title !== originalValues.title + const descriptionChanged = description !== originalValues.description + const outputBlockChanged = selectedOutputBlock !== originalValues.outputBlockId + const welcomeMessageChanged = + welcomeMessage !== + (existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?') + + // Check if emails have changed + const emailsChanged = + emails.length !== originalValues.emails.length || + emails.some((email) => !originalValues.emails.includes(email)) + + // Check if password has changed - any value in password field means change + const passwordChanged = password.length > 0 + + // Determine if any changes have been made + const changed = + subdomainChanged || + titleChanged || + descriptionChanged || + currentAuthTypeChanged || + emailsChanged || + passwordChanged || + outputBlockChanged || + welcomeMessageChanged + + setHasChanges(changed) + } + }, [ + subdomain, + title, + description, + authType, + emails, + password, + selectedOutputBlock, + welcomeMessage, + originalValues, + ]) + + // Fetch existing chat data for this workflow + const fetchExistingChat = async () => { + try { + const response = await fetch(`/api/workflows/${workflowId}/chat/status`) + + if (response.ok) { + const data = await response.json() + + if (data.isDeployed && data.deployment) { + // Get detailed chat info + const detailResponse = await fetch(`/api/chat/edit/${data.deployment.id}`) + + if (detailResponse.ok) { + const chatDetail = await detailResponse.json() + setExistingChat(chatDetail) + + // Notify parent component that a chat exists + if (onChatExistsChange) { + onChatExistsChange(true) + } + + // Populate form with existing data + setSubdomain(chatDetail.subdomain || '') + setTitle(chatDetail.title || '') + setDescription(chatDetail.description || '') + setAuthType(chatDetail.authType || 'public') + + // Store original values for change detection + setOriginalValues({ + subdomain: chatDetail.subdomain || '', + title: chatDetail.title || '', + description: chatDetail.description || '', + authType: chatDetail.authType || 'public', + emails: Array.isArray(chatDetail.allowedEmails) ? [...chatDetail.allowedEmails] : [], + outputBlockId: chatDetail.outputBlockId || null, + }) + + // Set emails if using email auth + if (chatDetail.authType === 'email' && Array.isArray(chatDetail.allowedEmails)) { + setEmails(chatDetail.allowedEmails) + } + + // For security, we don't populate password - user will need to enter a new one if changing it + + // Inside the fetchExistingChat function - after loading other form values + if (chatDetail.outputBlockId && chatDetail.outputPath) { + const combinedOutputId = `${chatDetail.outputBlockId}_${chatDetail.outputPath}` + setSelectedOutputBlock(combinedOutputId) + } + + // Set welcome message if it exists + if (chatDetail.customizations?.welcomeMessage) { + setWelcomeMessage(chatDetail.customizations.welcomeMessage) + } + } else { + logger.error('Failed to fetch chat details') + } + } else { + setExistingChat(null) + setOriginalValues(null) + + // Notify parent component that no chat exists + if (onChatExistsChange) { + onChatExistsChange(false) + } + } + } + } catch (error) { + logger.error('Error fetching chat status:', error) + } finally { + setIsLoading(false) + setDataFetched(true) + setHasChanges(false) // Reset changes detection after loading + } + } + + // Validate subdomain format on input change and check availability + const handleSubdomainChange = (value: string) => { + const lowercaseValue = value.toLowerCase() + setSubdomain(lowercaseValue) + setSubdomainAvailable(null) + + // Clear any existing timeout + if (subdomainCheckTimeoutRef.current) { + clearTimeout(subdomainCheckTimeoutRef.current) + } + + // Validate subdomain format + if (lowercaseValue && !/^[a-z0-9-]+$/.test(lowercaseValue)) { + setSubdomainError('Subdomain can only contain lowercase letters, numbers, and hyphens') + // Reset deploying states when validation errors occur + setIsDeploying(false) + setChatSubmitting(false) + return + } else { + setSubdomainError('') + } + + // Skip check if empty or same as original (for updates) + if (!lowercaseValue || (originalValues && lowercaseValue === originalValues.subdomain)) { + return + } + + // Debounce check to avoid unnecessary API calls + subdomainCheckTimeoutRef.current = setTimeout(() => { + checkSubdomainAvailability(lowercaseValue) + }, 500) + } + + // Check if subdomain is available + const checkSubdomainAvailability = async (domain: string) => { + if (!domain) return + + setIsCheckingSubdomain(true) + + try { + const response = await fetch( + `/api/chat/subdomain-check?subdomain=${encodeURIComponent(domain)}` + ) + const data = await response.json() + + // Only update if this is still the current subdomain + if (domain === subdomain) { + if (response.ok) { + setSubdomainAvailable(data.available) + if (!data.available) { + setSubdomainError('This subdomain is already in use') + // Reset deploying states when subdomain is unavailable + setIsDeploying(false) + setChatSubmitting(false) + } else { + setSubdomainError('') + } + } else { + setSubdomainError('Error checking subdomain availability') + // Reset deploying states on API error + setIsDeploying(false) + setChatSubmitting(false) + } + } + } catch (error) { + logger.error('Error checking subdomain availability:', error) + setSubdomainError('Error checking subdomain availability') + // Reset deploying states on error + setIsDeploying(false) + setChatSubmitting(false) + } finally { + setIsCheckingSubdomain(false) + } + } + + // Validate and add email + const handleAddEmail = () => { + // Basic email validation + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) && !newEmail.startsWith('@')) { + setEmailError('Please enter a valid email or domain (e.g., user@example.com or @example.com)') + return + } + + // Add email if it's not already in the list + if (!emails.includes(newEmail)) { + setEmails([...emails, newEmail]) + setNewEmail('') + setEmailError('') + } else { + setEmailError('This email or domain is already in the list') + } + } + + // Remove email from the list + const handleRemoveEmail = (email: string) => { + setEmails(emails.filter((e) => e !== email)) + } + + // Password generation and copy functionality + const generatePassword = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+=' + let result = '' + const length = 24 + + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + + setPassword(result) + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + setCopySuccess(true) + setTimeout(() => { + setCopySuccess(false) + }, 2000) + } + + const handleDelete = async () => { + if (!existingChat || !existingChat.id) return + + try { + setIsDeleting(true) + + const response = await fetch(`/api/chat/edit/${existingChat.id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to delete chat') + } + + // Close modal after successful deletion + onClose() + } catch (error: any) { + logger.error('Failed to delete chat:', error) + setErrorMessage(error.message || 'An unexpected error occurred while deleting') + } finally { + setIsDeleting(false) + setShowDeleteConfirmation(false) + } + } + + // Deploy or update chat + const handleSubmit = async (e?: FormEvent) => { + if (e) e.preventDefault() + + // If already submitting, don't process again + if (chatSubmitting) return + + setChatSubmitting(true) + setErrorMessage(null) + + // Log form state to help debug + logger.info('Form submission triggered with values:', { + subdomain, + title, + authType, + hasOutputBlockSelection: !!selectedOutputBlock, + }) + + // Basic validation + if (!workflowId || !subdomain.trim() || !title.trim()) { + logger.error('Missing required fields', { workflowId, subdomain, title }) + setChatSubmitting(false) + setErrorMessage('Please fill out all required fields') + return + } + + // Check subdomain availability before submission if it's different from original + if ( + (!existingChat || subdomain !== existingChat.subdomain) && + (!originalValues || subdomain !== originalValues.subdomain) + ) { + setIsCheckingSubdomain(true) + try { + const response = await fetch( + `/api/chat/subdomain-check?subdomain=${encodeURIComponent(subdomain)}` + ) + const data = await response.json() + + if (!response.ok || !data.available) { + setSubdomainError('This subdomain is already in use') + setChatSubmitting(false) + setIsCheckingSubdomain(false) + return + } + } catch (error) { + logger.error('Error checking subdomain availability:', error) + setSubdomainError('Error checking subdomain availability') + setChatSubmitting(false) + setIsCheckingSubdomain(false) + return + } + setIsCheckingSubdomain(false) + } + + // Verify output selection if it's set + if (selectedOutputBlock) { + const firstUnderscoreIndex = selectedOutputBlock.indexOf('_') + if (firstUnderscoreIndex === -1) { + logger.error('Invalid output block format', { selectedOutputBlock }) + setErrorMessage('Invalid output block format. Please select a valid output.') + setChatSubmitting(false) + return + } + } + + if (subdomainError) { + setChatSubmitting(false) + return + } + + // Validate authentication options + if (authType === 'password' && !password.trim() && !existingChat) { + setErrorMessage('Password is required when using password protection') + setChatSubmitting(false) + return + } + + if (authType === 'email' && emails.length === 0) { + setErrorMessage('At least one email or domain is required when using email access control') + setChatSubmitting(false) + return + } + + // If editing an existing chat, check if we should show confirmation + if (existingChat && existingChat.isActive) { + const majorChanges = + subdomain !== existingChat.subdomain || + authType !== existingChat.authType || + (authType === 'email' && + JSON.stringify(emails) !== JSON.stringify(existingChat.allowedEmails)) + + if (majorChanges) { + setShowEditConfirmation(true) + setChatSubmitting(false) + return + } + } + + // Proceed with create/update + await deployOrUpdateChat() + } + + // Actual deployment/update logic + const deployOrUpdateChat = async () => { + setErrorMessage(null) + + try { + // Create request payload + const payload: any = { + workflowId, + subdomain: subdomain.trim(), + title: title.trim(), + description: description.trim(), + customizations: { + primaryColor: '#802FFF', + welcomeMessage: welcomeMessage.trim(), + }, + authType: authType, + } + + // Always include auth specific fields regardless of authType + // This ensures they're always properly handled + if (authType === 'password') { + // For password auth, only send the password if: + // 1. It's a new chat, or + // 2. Creating a new password for an existing chat, or + // 3. Changing from another auth type to password + if (password) { + payload.password = password + } else if (existingChat && existingChat.authType !== 'password') { + // If changing to password auth but no password provided for an existing chat, + // this is an error - server will reject it + setErrorMessage('Password is required when using password protection') + setChatSubmitting(false) + return // Stop the submission + } + + payload.allowedEmails = [] // Clear emails when using password auth + } else if (authType === 'email') { + payload.allowedEmails = emails + } else if (authType === 'public') { + // Explicitly set empty values for public access + payload.allowedEmails = [] + } + + // Add output block configuration if selected + if (selectedOutputBlock) { + const firstUnderscoreIndex = selectedOutputBlock.indexOf('_') + if (firstUnderscoreIndex !== -1) { + const blockId = selectedOutputBlock.substring(0, firstUnderscoreIndex) + const path = selectedOutputBlock.substring(firstUnderscoreIndex + 1) + + payload.outputBlockId = blockId + payload.outputPath = path + + logger.info('Added output configuration to payload:', { + outputBlockId: blockId, + outputPath: path, + }) + } + } else { + // No output block selected - explicitly set to null + payload.outputBlockId = null + payload.outputPath = null + } + + // Pass the API key from workflow deployment + if (deploymentInfo?.apiKey) { + payload.apiKey = deploymentInfo.apiKey + } + + // For existing chat updates, ensure API gets redeployed too + if (existingChat && existingChat.id) { + // First ensure the API deployment is up-to-date + try { + // Make a direct call to redeploy the API + const redeployResponse = await fetch(`/api/workflows/${workflowId}/deploy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + deployApiEnabled: true, + deployChatEnabled: false, + }), + }) + + if (!redeployResponse.ok) { + logger.warn('API redeployment failed, continuing with chat update') + } else { + logger.info('API successfully redeployed alongside chat update') + } + } catch (error) { + logger.warn('Error redeploying API, continuing with chat update:', error) + } + } else { + // For new chat deployments, set the flag for API deployment + payload.deployApiEnabled = true + } + + // Log the final payload (minus sensitive data) for debugging + logger.info('Submitting chat deployment with values:', { + workflowId: payload.workflowId, + subdomain: payload.subdomain, + title: payload.title, + authType: payload.authType, + hasPassword: !!payload.password, + emailCount: payload.allowedEmails?.length || 0, + hasOutputConfig: !!payload.outputBlockId, + deployApiEnabled: payload.deployApiEnabled, + }) + + // Make API request - different endpoints for create vs update + let endpoint = '/api/chat' + let method = 'POST' + + // If updating existing chat, use the edit/ID endpoint with PATCH method + if (existingChat && existingChat.id) { + endpoint = `/api/chat/edit/${existingChat.id}` + method = 'PATCH' + // Ensure deployApiEnabled is included in updates too + payload.deployApiEnabled = true + } + + // Validate with Zod + try { + chatSchema.parse(payload) + } catch (validationError: any) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid form data' + setErrorMessage(errorMessage) + setChatSubmitting(false) + return + } + } + + const response = await fetch(endpoint, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(result.error || `Failed to ${existingChat ? 'update' : 'deploy'} chat`) + } + + const { chatUrl } = result + + if (chatUrl) { + logger.info(`Chat ${existingChat ? 'updated' : 'deployed'} successfully:`, chatUrl) + setDeployedChatUrl(chatUrl) + } else { + throw new Error('Response missing chatUrl') + } + } catch (error: any) { + logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error) + setErrorMessage(error.message || 'An unexpected error occurred') + addNotification('error', `Failed to deploy chat: ${error.message}`, workflowId) + } finally { + setChatSubmitting(false) + setShowEditConfirmation(false) + } + } + + // Determine button label based on state + const getSubmitButtonLabel = () => { + return existingChat ? 'Update Chat' : 'Deploy Chat' + } + + // Check if form submission is possible + const isFormSubmitDisabled = () => { + return ( + chatSubmitting || + isDeleting || + !subdomain || + !title || + !!subdomainError || + isCheckingSubdomain || + (authType === 'password' && !password && !existingChat) || + (authType === 'email' && emails.length === 0) || + (existingChat && !hasChanges) + ) + } + + if (isLoading) { + return ( +
+ {/* Subdomain section */} +
+ + +
+ + {/* Title section */} +
+ + +
+ + {/* Description section */} +
+ + +
+ + {/* Output configuration section */} +
+ + +
+ + {/* Access control section */} +
+ +
+ + + +
+ +
+ + {/* Submit button */} + +
+ ) + } + + if (deployedChatUrl) { + // Extract just the subdomain from the URL + const url = new URL(deployedChatUrl) + const hostname = url.hostname + const isDevelopmentUrl = hostname.includes('localhost') + const domainSuffix = isDevelopmentUrl ? '.localhost:3000' : '.simstudio.ai' + const subdomainPart = isDevelopmentUrl + ? hostname.split('.')[0] + : hostname.split('.simstudio.ai')[0] + + // Success view - simplified with no buttons + return ( +
+
+ +
+ + {subdomainPart} + +
+ {domainSuffix} +
+
+

Your chat is now live at this URL

+
+
+ ) + } + + if (errorMessage) { + return ( +
+
+
Chat Deployment Error
+
{errorMessage}
+
+ + {/* Add button to try again */} +
+ +
+
+ ) + } + + // Form view + return ( + <> +
{ + e.preventDefault() // Prevent default form submission + handleSubmit(e) // Call our submit handler directly + }} + className="space-y-4 chat-deploy-form overflow-y-auto px-1 -mx-1" + > +
+ {errorMessage && ( + + + {errorMessage} + + )} + +
+
+
+ +
+ handleSubdomainChange(e.target.value)} + required + className={cn( + 'rounded-r-none border-r-0 focus-visible:ring-0 focus-visible:ring-offset-0', + subdomainAvailable === true && + 'border-green-500 focus-visible:border-green-500', + subdomainAvailable === false && + 'border-destructive focus-visible:border-destructive' + )} + disabled={isDeploying} + /> +
+ {isDevelopment ? '.localhost:3000' : '.simstudio.ai'} +
+ {!isCheckingSubdomain && subdomainAvailable === true && subdomain && ( +
+ +
+ )} +
+ {subdomainError && ( +

{subdomainError}

+ )} + {!subdomainError && subdomainAvailable === true && subdomain && ( +

Subdomain is available

+ )} +
+ +
+ + setTitle(e.target.value)} + required + disabled={isDeploying} + /> +
+
+ +
+ +