From 345cf61c965b444bcb7d778ca25a16f84f5049a2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 16 Apr 2025 13:08:50 -0700 Subject: [PATCH 01/18] added chatbot table with fk to workflows, added modal to deploy and delpoy to subdomain of *.simstudio.ai --- sim/app/api/chatbot/[subdomain]/route.ts | 250 ++++ sim/app/api/chatbot/route.ts | 159 +++ .../workflows/[id]/chatbot/status/route.ts | 49 + sim/app/chatbot/[subdomain]/page.tsx | 254 ++++ .../components/panel/components/chat/chat.tsx | 222 ++- .../chat-deployment-modal.tsx | 560 ++++++++ sim/components/ui/radio-group.tsx | 44 + sim/db/migrations/0029_grey_barracuda.sql | 1 - sim/db/migrations/meta/0029_snapshot.json | 1263 ----------------- sim/db/migrations/meta/_journal.json | 2 +- sim/middleware.ts | 33 +- sim/package-lock.json | 305 ++++ sim/package.json | 1 + 13 files changed, 1799 insertions(+), 1344 deletions(-) create mode 100644 sim/app/api/chatbot/[subdomain]/route.ts create mode 100644 sim/app/api/chatbot/route.ts create mode 100644 sim/app/api/workflows/[id]/chatbot/status/route.ts create mode 100644 sim/app/chatbot/[subdomain]/page.tsx create mode 100644 sim/app/w/[id]/components/panel/components/chat/components/chat-deployment-modal/chat-deployment-modal.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 diff --git a/sim/app/api/chatbot/[subdomain]/route.ts b/sim/app/api/chatbot/[subdomain]/route.ts new file mode 100644 index 00000000000..3ef33d6279b --- /dev/null +++ b/sim/app/api/chatbot/[subdomain]/route.ts @@ -0,0 +1,250 @@ +import { NextRequest } from 'next/server' +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { chatbotDeployment, workflow, apiKey as apiKeyTable } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { decryptSecret } from '@/lib/utils' + +const logger = createLogger('ChatbotSubdomainAPI') + +// Validate authentication for chatbot access +async function validateChatbotAuth( + requestId: string, + deployment: any, + request: NextRequest +): Promise<{ authorized: boolean; error?: string }> { + const authType = deployment.authType || 'public' + + // Public chatbots are accessible to everyone + if (authType === 'public') { + 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 { + const body = await request.json() + const { password } = body + + if (!password) { + return { authorized: false, error: 'Password is required' } + } + + if (!deployment.password) { + logger.error(`[${requestId}] No password set for password-protected chatbot: ${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 { + const body = await request.json() + const { email } = body + + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + const allowedEmails = deployment.allowedEmails || [] + + // Check exact email matches + if (allowedEmails.includes(email)) { + return { authorized: true } + } + + // Check domain matches (prefixed with @) + const domain = email.split('@')[1] + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + return { authorized: true } + } + + 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' } +} + +// This endpoint handles chat interactions via the subdomain +export async function POST(request: NextRequest, { params }: { params: { subdomain: string } }) { + const { subdomain } = params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Processing chatbot request for subdomain: ${subdomain}`) + + // Find the chatbot deployment for this subdomain + const deploymentResult = await db + .select({ + id: chatbotDeployment.id, + workflowId: chatbotDeployment.workflowId, + userId: chatbotDeployment.userId, + isActive: chatbotDeployment.isActive, + authType: chatbotDeployment.authType, + password: chatbotDeployment.password, + allowedEmails: chatbotDeployment.allowedEmails, + }) + .from(chatbotDeployment) + .where(eq(chatbotDeployment.subdomain, subdomain)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chatbot not found for subdomain: ${subdomain}`) + return createErrorResponse('Chatbot not found', 404) + } + + const deployment = deploymentResult[0] + + // Check if the chatbot is active + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chatbot is not active: ${subdomain}`) + return createErrorResponse('This chatbot is currently unavailable', 403) + } + + // Validate authentication + const authResult = await validateChatbotAuth(requestId, deployment, request) + if (!authResult.authorized) { + return createErrorResponse(authResult.error || 'Authentication required', 401) + } + + // Get the workflow for this chatbot + 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 createErrorResponse('Chatbot workflow is not available', 503) + } + + // Get the API key for the user + const apiKeyResult = await db + .select({ + key: apiKeyTable.key, + }) + .from(apiKeyTable) + .where(eq(apiKeyTable.userId, deployment.userId)) + .limit(1) + + if (apiKeyResult.length === 0) { + logger.warn(`[${requestId}] No API key found for user: ${deployment.userId}`) + return createErrorResponse('Unable to process request', 500) + } + + const apiKey = apiKeyResult[0].key + + // Get the chat message from the request + const body = await request.json() + const { message } = body + + if (!message) { + return createErrorResponse('No message provided', 400) + } + + // Forward the message to the workflow execution endpoint + const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${deployment.workflowId}/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + }, + body: JSON.stringify({ input: message }), + }) + + if (!response.ok) { + const errorData = await response.json() + logger.error(`[${requestId}] Workflow execution failed:`, errorData) + return createErrorResponse('Failed to process message', response.status) + } + + // Get the response from the workflow + const result = await response.json() + + return createSuccessResponse(result) + } catch (error: any) { + logger.error(`[${requestId}] Error processing chatbot request:`, error) + return createErrorResponse(error.message || 'Failed to process request', 500) + } +} + +// This endpoint returns information about the chatbot +export async function GET(request: NextRequest, { params }: { params: { subdomain: string } }) { + const { subdomain } = params + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.debug(`[${requestId}] Fetching chatbot info for subdomain: ${subdomain}`) + + // Find the chatbot deployment for this subdomain + const deploymentResult = await db + .select({ + id: chatbotDeployment.id, + title: chatbotDeployment.title, + description: chatbotDeployment.description, + customizations: chatbotDeployment.customizations, + isActive: chatbotDeployment.isActive, + workflowId: chatbotDeployment.workflowId, + authType: chatbotDeployment.authType, + }) + .from(chatbotDeployment) + .where(eq(chatbotDeployment.subdomain, subdomain)) + .limit(1) + + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chatbot not found for subdomain: ${subdomain}`) + return createErrorResponse('Chatbot not found', 404) + } + + const deployment = deploymentResult[0] + + // Check if the chatbot is active + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chatbot is not active: ${subdomain}`) + return createErrorResponse('This chatbot is currently unavailable', 403) + } + + // Return public information about the chatbot including auth type + return createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + }) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching chatbot info:`, error) + return createErrorResponse(error.message || 'Failed to fetch chatbot information', 500) + } +} \ No newline at end of file diff --git a/sim/app/api/chatbot/route.ts b/sim/app/api/chatbot/route.ts new file mode 100644 index 00000000000..d800176416f --- /dev/null +++ b/sim/app/api/chatbot/route.ts @@ -0,0 +1,159 @@ +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 { chatbotDeployment, workflow } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { encryptSecret } from '@/lib/utils' + +const logger = createLogger('ChatbotAPI') + +// Define Zod schema for API request validation +const chatbotDeploymentSchema = 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([]), +}) + +export async function GET(request: NextRequest) { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) + } + + // Get the user's chatbot deployments + const deployments = await db + .select() + .from(chatbotDeployment) + .where(eq(chatbotDeployment.userId, session.user.id)) + + return createSuccessResponse({ deployments }) + } catch (error: any) { + logger.error('Error fetching chatbot deployments:', error) + return createErrorResponse(error.message || 'Failed to fetch chatbot 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 = chatbotDeploymentSchema.parse(body) + + // Extract validated data + const { + workflowId, + subdomain, + title, + description = '', + customizations, + authType = 'public', + password, + allowedEmails = [] + } = 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(chatbotDeployment) + .where(eq(chatbotDeployment.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 chatbot deployment) + if (!workflowExists[0].isDeployed) { + return createErrorResponse('Workflow must be deployed before creating a chatbot', 400) + } + + // Encrypt password if provided + let encryptedPassword = null + if (authType === 'password' && password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + } + + // Create the chatbot deployment + const id = uuidv4() + await db.insert(chatbotDeployment).values({ + id, + workflowId, + userId: session.user.id, + subdomain, + title, + description: description || '', + customizations: customizations || {}, + isActive: true, + authType, + password: encryptedPassword, + allowedEmails: authType === 'email' ? allowedEmails : [], + createdAt: new Date(), + updatedAt: new Date(), + }) + + // Return successful response with chatbot URL + const chatbotUrl = `https://${subdomain}.simstudio.ai` + + logger.info(`Chatbot "${title}" deployed successfully at ${chatbotUrl}`) + + return createSuccessResponse({ + id, + chatbotUrl, + message: 'Chatbot 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 chatbot deployment:', error) + return createErrorResponse(error.message || 'Failed to create chatbot deployment', 500) + } +} \ No newline at end of file diff --git a/sim/app/api/workflows/[id]/chatbot/status/route.ts b/sim/app/api/workflows/[id]/chatbot/status/route.ts new file mode 100644 index 00000000000..3286accd9fd --- /dev/null +++ b/sim/app/api/workflows/[id]/chatbot/status/route.ts @@ -0,0 +1,49 @@ +import { eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { chatbotDeployment } from '@/db/schema' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' + +const logger = createLogger('ChatbotStatusAPI') + +/** + * GET endpoint to check if a workflow has an active chatbot 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 chatbot deployment status for workflow: ${id}`) + + // Find any active chatbot deployments for this workflow + const deploymentResults = await db + .select({ + id: chatbotDeployment.id, + subdomain: chatbotDeployment.subdomain, + isActive: chatbotDeployment.isActive, + }) + .from(chatbotDeployment) + .where(eq(chatbotDeployment.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 chatbot deployment status:`, error) + return createErrorResponse(error.message || 'Failed to check chatbot deployment status', 500) + } +} \ No newline at end of file diff --git a/sim/app/chatbot/[subdomain]/page.tsx b/sim/app/chatbot/[subdomain]/page.tsx new file mode 100644 index 00000000000..bfa94e27498 --- /dev/null +++ b/sim/app/chatbot/[subdomain]/page.tsx @@ -0,0 +1,254 @@ +'use client' + +import { KeyboardEvent, useEffect, useRef, useState } from 'react' +import { ArrowUp } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +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 chatbot config type +interface ChatbotConfig { + id: string + title: string + description: string + customizations: { + primaryColor?: string + logoUrl?: string + welcomeMessage?: string + headerText?: string + } +} + +export default function ChatbotPage({ params }: { params: { subdomain: string } }) { + const { subdomain } = params + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [chatbotConfig, setChatbotConfig] = useState(null) + const [error, setError] = useState(null) + const messagesEndRef = useRef(null) + + // Fetch chatbot config on mount + useEffect(() => { + async function fetchChatbotConfig() { + try { + const response = await fetch(`/api/chatbot/${subdomain}`) + + if (!response.ok) { + throw new Error('Failed to load chatbot configuration') + } + + const data = await response.json() + setChatbotConfig(data.data) + + // Add welcome message if configured + if (data.data?.customizations?.welcomeMessage) { + setMessages([ + { + id: 'welcome', + content: data.data.customizations.welcomeMessage, + type: 'assistant', + timestamp: new Date(), + }, + ]) + } + } catch (error) { + console.error('Error fetching chatbot config:', error) + setError('This chatbot is currently unavailable. Please try again later.') + } + } + + fetchChatbotConfig() + }, [subdomain]) + + // 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) + + try { + const response = await fetch(`/api/chatbot/${subdomain}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message: userMessage.content }), + }) + + if (!response.ok) { + throw new Error('Failed to get response') + } + + const data = await response.json() + + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + content: data.data.output || '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) + } + } + + // Handle keyboard input + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // If error, show error message + if (error) { + return ( +
+
+

Error

+

{error}

+
+
+ ) + } + + // Loading state while fetching config + if (!chatbotConfig) { + return ( +
+
+
+
+
+
+ ) + } + + // Get primary color from customizations or use default + const primaryColor = chatbotConfig.customizations?.primaryColor || '#802FFF' + + return ( +
+ {/* Header */} +
+ {chatbotConfig.customizations?.logoUrl && ( + {`${chatbotConfig.title} + )} +

+ {chatbotConfig.customizations?.headerText || chatbotConfig.title} +

+
+ + {/* Chat area */} +
+ +
+ {messages.map((message) => ( +
+
{message.content}
+
+ {message.timestamp.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +
+
+ ))} + {isLoading && ( +
+
+
+
+
+
+
+ )} +
+
+ +
+ + {/* Input area */} +
+
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a message..." + className="flex-1 focus-visible:ring-0 focus-visible:ring-offset-0" + disabled={isLoading} + /> + +
+
+
+ ) +} \ No newline at end of file diff --git a/sim/app/w/[id]/components/panel/components/chat/chat.tsx b/sim/app/w/[id]/components/panel/components/chat/chat.tsx index 87c24d4c128..40341c4247e 100644 --- a/sim/app/w/[id]/components/panel/components/chat/chat.tsx +++ b/sim/app/w/[id]/components/panel/components/chat/chat.tsx @@ -1,10 +1,11 @@ 'use client' import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp, ChevronDown } from 'lucide-react' +import { ArrowUp, ChevronDown, MessageSquareShare } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useExecutionStore } from '@/stores/execution/store' import { useChatStore } from '@/stores/panel/chat/store' @@ -14,6 +15,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { getBlock } from '@/blocks' import { useWorkflowExecution } from '../../../../hooks/use-workflow-execution' import { ChatMessage } from './components/chat-message/chat-message' +import { ChatbotDeploymentModal } from './components/chat-deployment-modal/chat-deployment-modal' interface ChatProps { panelWidth: number @@ -30,6 +32,9 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { const blocks = useWorkflowStore((state) => state.blocks) const messagesEndRef = useRef(null) const dropdownRef = useRef(null) + const [isChatbotModalOpen, setIsChatbotModalOpen] = useState(false) + const { isDeployed } = useWorkflowStore() + const [hasChatbotDeployment, setHasChatbotDeployment] = useState(false) // Use the execution store state to track if a workflow is executing const { isExecuting } = useExecutionStore() @@ -273,86 +278,152 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { return blockConfig?.bgColor || '#2F55FF' // Default blue if not found } + // Check if this workflow has an active chatbot deployment + useEffect(() => { + if (!activeWorkflowId || !isDeployed) { + setHasChatbotDeployment(false) + return + } + + const checkChatbotDeployment = async () => { + try { + const response = await fetch(`/api/workflows/${activeWorkflowId}/chatbot/status`) + if (response.ok) { + const data = await response.json() + setHasChatbotDeployment(data.isDeployed || false) + } else { + setHasChatbotDeployment(false) + } + } catch (error) { + console.error('Error checking chatbot deployment status:', error) + setHasChatbotDeployment(false) + } + } + + checkChatbotDeployment() + }, [activeWorkflowId, isDeployed, isChatbotModalOpen]) + return (
{/* Output Source Dropdown */}
-
- + + {isOutputDropdownOpen && workflowOutputs.length > 0 && ( +
+
+ {Object.entries(groupedOutputs).map(([blockName, outputs]) => ( +
+
+ {blockName} +
+
+ {outputs.map((output) => ( + + ))} +
+
+ ))}
- {selectedOutputDisplayName}
- ) : ( - {selectedOutputDisplayName} )} - - - - {isOutputDropdownOpen && workflowOutputs.length > 0 && ( -
-
- {Object.entries(groupedOutputs).map(([blockName, outputs]) => ( -
-
- {blockName} -
-
- {outputs.map((output) => ( - - ))} +
+ + {/* Chatbot Deploy Button */} + + +
+ + + {/* Active chatbot deployment indicator */} + {hasChatbotDeployment && ( +
+
+
+
+ Active Chatbot
- ))} + )}
-
- )} + + + {!isDeployed + ? 'Deploy workflow first to enable chatbot' + : hasChatbotDeployment + ? 'Manage Chatbot Deployment' + : 'Deploy as Chatbot'} + +
@@ -398,6 +469,15 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
+ + {/* Chatbot Deployment Modal */} + {activeWorkflowId && ( + setIsChatbotModalOpen(false)} + workflowId={activeWorkflowId} + /> + )}
) } diff --git a/sim/app/w/[id]/components/panel/components/chat/components/chat-deployment-modal/chat-deployment-modal.tsx b/sim/app/w/[id]/components/panel/components/chat/components/chat-deployment-modal/chat-deployment-modal.tsx new file mode 100644 index 00000000000..867d9ee6649 --- /dev/null +++ b/sim/app/w/[id]/components/panel/components/chat/components/chat-deployment-modal/chat-deployment-modal.tsx @@ -0,0 +1,560 @@ +'use client' + +import { FormEvent, useState } from 'react' +import { z } from 'zod' +import { AlertTriangle, Check, Circle, Copy, Eye, EyeOff, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { createLogger } from '@/lib/logs/console-logger' +import { Card, CardContent } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +const logger = createLogger('ChatbotDeploymentModal') + +interface ChatDeploymentModalProps { + isOpen: boolean + onClose: () => void + workflowId: string +} + +type AuthType = 'public' | 'password' | 'email' + +// Define Zod schema for API request validation +const chatbotSchema = 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(), +}) + +export function ChatbotDeploymentModal({ isOpen, onClose, workflowId }: ChatDeploymentModalProps) { + // Form state + const [subdomain, setSubdomain] = useState('') + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [isDeploying, setIsDeploying] = useState(false) + const [subdomainError, setSubdomainError] = useState('') + const [deployedChatbotUrl, setDeployedChatbotUrl] = useState(null) + const [errorMessage, setErrorMessage] = useState(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('') + + // Reset form state when modal opens/closes + const handleOpenChange = (open: boolean) => { + if (!open) { + // Reset form when closing + setSubdomain('') + setTitle('') + setDescription('') + setSubdomainError('') + setDeployedChatbotUrl(null) + setErrorMessage(null) + setIsDeploying(false) + setAuthType('public') + setPassword('') + setShowPassword(false) + setEmails([]) + setNewEmail('') + setEmailError('') + onClose() + } + } + + // Validate subdomain format on input change + const handleSubdomainChange = (value: string) => { + const lowercaseValue = value.toLowerCase() + setSubdomain(lowercaseValue) + + // Validate subdomain format + if (lowercaseValue && !/^[a-z0-9-]+$/.test(lowercaseValue)) { + setSubdomainError('Subdomain can only contain lowercase letters, numbers, and hyphens') + } else { + setSubdomainError('') + } + } + + // 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) + setShowPassword(false) + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + } + + // Deploy chatbot + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + + if (!workflowId || !subdomain.trim() || !title.trim()) { + return + } + + if (subdomainError) { + return + } + + // Validate authentication options + if (authType === 'password' && !password.trim()) { + setErrorMessage('Password is required when using password protection') + return + } + + if (authType === 'email' && emails.length === 0) { + setErrorMessage('At least one email or domain is required when using email access control') + return + } + + setErrorMessage(null) + + try { + setIsDeploying(true) + + // Create request payload and validate with Zod + const payload = { + workflowId, + subdomain: subdomain.trim(), + title: title.trim(), + description: description.trim(), + customizations: { + primaryColor: '#802FFF', + welcomeMessage: 'Hi there! How can I help you today?', + }, + authType, + password: authType === 'password' ? password : undefined, + allowedEmails: authType === 'email' ? emails : [], + } + + try { + chatbotSchema.parse(payload) + } catch (validationError: any) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid form data' + setErrorMessage(errorMessage) + return + } + } + + const response = await fetch('/api/chatbot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + const result = await response.json() + + if (!response.ok) { + throw new Error(result.error || 'Failed to deploy chatbot') + } + + const { chatbotUrl } = result + + if (chatbotUrl) { + logger.info('Chatbot deployed successfully:', chatbotUrl) + setDeployedChatbotUrl(chatbotUrl) + } else { + throw new Error('Response missing chatbotUrl') + } + } catch (error: any) { + logger.error('Failed to deploy chatbot:', error) + setErrorMessage(error.message || 'An unexpected error occurred') + } finally { + setIsDeploying(false) + } + } + + return ( + + + + Deploy Workflow as Chatbot + + Create a chatbot interface for your workflow that others can access via a custom URL. + + + + {deployedChatbotUrl ? ( + // Success view +
+ + +

Chatbot Deployment Successful

+

Your chatbot is now available at:

+ +
+
+ +
+

Next Steps

+
    +
  • Share this URL with your users
  • +
  • Visit the URL to test your chatbot
  • +
  • Manage your chatbots from the Deployments page
  • +
+
+
+ ) : ( + // Form view +
+
+ {errorMessage && ( + + + {errorMessage} + + )} + +
+
+
+ +
+ handleSubdomainChange(e.target.value)} + required + className="rounded-r-none border-r-0" + disabled={isDeploying} + /> +
+ .simstudio.ai +
+
+ {subdomainError && ( +

{subdomainError}

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