From fc101c3d65ef9ef45222b9d686458bc169eeeefb Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Sat, 26 Apr 2025 19:16:15 -0700 Subject: [PATCH] feat(chat-deploy) (#277) * added chatbot table with fk to workflows, added modal to deploy and delpoy to subdomain of *.simstudio.ai * fixed styling, added delete and edit routes for chatbot * use loading-agent animation for editing existing chatbot * add base_url so that we can delpoy in dev as well * fixed CORS issue, fixed password verification, can deploy chatbot and access it at subdomain. still need to fix the actual chat request to match the same format as the chat in the panel * fix: renamed chatbot to chat and changed chat to copilot * feat(chat-deploy): refactored api deploy flow * feat(chat-deploy): added chat to deploy flow * added output selector to chat deploy, deployment works and we can get a response from subdomain. need to fix UI + form submission of deploy modal but the core functionality works * add missing dependencies, fix build errors, remove old unused route * error disappeared for block output selection, need to update UI, add the ability to delete/view chat deployment, and test emails/email domain * added otp for email verification on chat deploy * feat(chat-deploy): ux improvements with chat-deploy modal * improvement(ui/ux): chat display improvement * improvement(ui/ux): deploy modal * added logging category for chat panel & chat deploy executions * improvement(ui/ux): finished chat-deploy flow * fix: deleted migrations --------- Co-authored-by: Waleed Latif --- sim/app/api/chat/[subdomain]/otp/route.ts | 319 ++++ sim/app/api/chat/[subdomain]/route.ts | 221 +++ sim/app/api/chat/edit/[id]/route.ts | 303 ++++ sim/app/api/chat/route.ts | 374 +++-- sim/app/api/chat/subdomain-check/route.ts | 54 + sim/app/api/chat/utils.ts | 469 ++++++ sim/app/api/copilot/route.ts | 216 +++ .../api/workflows/[id]/chat/status/route.ts | 49 + sim/app/api/workflows/[id]/log/route.ts | 6 +- .../[subdomain]/components/chat-client.tsx | 693 +++++++++ sim/app/chat/[subdomain]/page.tsx | 10 + .../components/chat-deploy/chat-deploy.tsx | 1282 +++++++++++++++++ .../components/deploy-form/deploy-form.tsx | 342 +++++ .../components/api-endpoint/api-endpoint.tsx | 25 + .../components/api-key/api-key.tsx | 41 + .../deploy-status/deploy-status.tsx | 40 + .../example-command/example-command.tsx | 42 + .../deployment-info/deployment-info.tsx | 128 ++ .../components/deploy-modal/deploy-modal.tsx | 732 ++++++++++ .../deployment-controls.tsx | 268 +--- .../notification-dropdown-item.tsx | 16 +- .../{chat/chat.tsx => copilot/copilot.tsx} | 6 +- .../notifications/notifications.tsx | 1 + .../components/panel/components/chat/chat.tsx | 267 +--- .../chat/components/chat-modal/chat-modal.tsx | 26 +- .../output-select/output-select.tsx | 282 ++++ .../w/[id]/hooks/use-workflow-execution.ts | 15 + sim/app/w/[id]/layout.tsx | 10 +- sim/app/w/logs/logs.tsx | 2 + .../emails/otp-verification-email.tsx | 33 +- sim/components/ui/input-otp-form.tsx | 73 + sim/components/ui/radio-group.tsx | 44 + sim/db/migrations/0029_grey_barracuda.sql | 1 - sim/db/migrations/meta/0029_snapshot.json | 1263 ---------------- sim/lib/logs/execution-logger.ts | 16 +- sim/middleware.ts | 39 +- sim/package-lock.json | 559 +++++-- sim/package.json | 1 + sim/stores/{chat => copilot}/store.ts | 16 +- sim/stores/{chat => copilot}/types.ts | 12 +- sim/stores/{chat => copilot}/utils.ts | 0 sim/stores/index.ts | 8 +- 42 files changed, 6175 insertions(+), 2129 deletions(-) create mode 100644 sim/app/api/chat/[subdomain]/otp/route.ts create mode 100644 sim/app/api/chat/[subdomain]/route.ts create mode 100644 sim/app/api/chat/edit/[id]/route.ts create mode 100644 sim/app/api/chat/subdomain-check/route.ts create mode 100644 sim/app/api/chat/utils.ts create mode 100644 sim/app/api/copilot/route.ts create mode 100644 sim/app/api/workflows/[id]/chat/status/route.ts create mode 100644 sim/app/chat/[subdomain]/components/chat-client.tsx create mode 100644 sim/app/chat/[subdomain]/page.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx create mode 100644 sim/app/w/[id]/components/control-bar/components/deploy-modal/deploy-modal.tsx rename sim/app/w/[id]/components/{chat/chat.tsx => copilot/copilot.tsx} (95%) create mode 100644 sim/app/w/[id]/components/panel/components/chat/components/output-select/output-select.tsx create mode 100644 sim/components/ui/input-otp-form.tsx create mode 100644 sim/components/ui/radio-group.tsx delete mode 100644 sim/db/migrations/0029_grey_barracuda.sql delete mode 100644 sim/db/migrations/meta/0029_snapshot.json rename sim/stores/{chat => copilot}/store.ts (91%) rename sim/stores/{chat => copilot}/types.ts (52%) rename sim/stores/{chat => copilot}/utils.ts (100%) 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} + /> +
+
+ +
+ +