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 <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2025-04-26 19:16:15 -07:00
committed by GitHub
parent f2e5d67a4a
commit fc101c3d65
42 changed files with 6175 additions and 2129 deletions

View File

@@ -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<void> {
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<string | null> {
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<void> {
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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

469
sim/app/api/chat/utils.ts Normal file
View File

@@ -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<string, any>
)
return acc
},
{} as Record<string, Record<string, any>>
)
// Get user environment variables for this workflow
let envVars: Record<string, string> = {}
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<string, string>
}
} 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<string, string> = {}
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<string, Record<string, any>>
)
// 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'
}
}

View File

@@ -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 })
}
}

View File

@@ -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)
}
}

View File

@@ -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',

View File

@@ -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 (
<div className="py-5 px-4">
<div className="max-w-3xl mx-auto">
<div className="flex justify-end">
<div className="bg-[#F4F4F4] dark:bg-gray-600 rounded-3xl max-w-[80%] py-3 px-4">
<div className="whitespace-pre-wrap break-words text-base leading-relaxed text-[#0D0D0D]">
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
) : (
<span>{message.content}</span>
)}
</div>
</div>
</div>
</div>
</div>
)
}
// For assistant messages (on the left)
return (
<div className="py-5 px-4">
<div className="max-w-3xl mx-auto">
<div className="flex">
<div className="max-w-[80%]">
<div className="whitespace-pre-wrap break-words text-base leading-relaxed">
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
) : (
<span>{message.content}</span>
)}
</div>
</div>
</div>
</div>
</div>
)
}
export default function ChatClient({ subdomain }: { subdomain: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Authentication state
const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null)
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [authError, setAuthError] = useState<string | null>(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<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
// Handle keyboard input for auth forms
const handleAuthKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAuthenticate()
}
}
// Add a function to handle OTP input key down
const handleOtpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
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 (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="p-6 max-w-md mx-auto bg-white rounded-xl shadow-md">
<h2 className="text-xl font-bold text-red-500 mb-2">Error</h2>
<p className="text-gray-700">{error}</p>
</div>
</div>
)
}
// 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 (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="p-6 max-w-md w-full mx-auto bg-white rounded-xl shadow-md">
<div className="text-center mb-6">
<h2 className="text-xl font-bold mb-2">{title}</h2>
<p className="text-gray-600">
{authRequired === 'password'
? 'This chat is password-protected. Please enter the password to continue.'
: 'This chat requires email verification. Please enter your email to continue.'}
</p>
</div>
{authError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-600 rounded-md">
{authError}
</div>
)}
<div className="space-y-4">
{authRequired === 'password' ? (
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleAuthKeyDown}
placeholder="Enter password"
className="pl-10"
disabled={isAuthenticating}
/>
</div>
) : (
<div className="w-full max-w-sm mx-auto">
<div className="bg-white dark:bg-black/10 rounded-lg shadow-md p-6 space-y-4 border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-center">
<div className="p-2 rounded-full bg-primary/10 text-primary">
<Mail className="h-5 w-5" />
</div>
</div>
<h2 className="text-lg font-medium text-center">Email Verification</h2>
{!showOtpVerification ? (
// Step 1: Email Input
<>
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
Enter your email address to access this chat
</p>
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="email" className="text-sm font-medium sr-only">
Email
</label>
<Input
id="email"
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleEmailKeyDown}
disabled={isSendingOtp || isAuthenticating}
className="w-full"
/>
</div>
{authError && (
<div className="text-sm text-red-600 dark:text-red-500">{authError}</div>
)}
<Button
onClick={handleAuthenticate}
disabled={!email || isSendingOtp || isAuthenticating}
className="w-full"
style={{
backgroundColor: chatConfig?.customizations?.primaryColor || '#802FFF',
}}
>
{isSendingOtp ? (
<div className="flex items-center justify-center">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending Code...
</div>
) : (
'Continue'
)}
</Button>
</div>
</>
) : (
// Step 2: OTP Verification with OTPInputForm
<>
<p className="text-neutral-500 dark:text-neutral-400 text-sm text-center">
Enter the verification code sent to
</p>
<p className="text-center font-medium text-sm break-all mb-3">{email}</p>
<OTPInputForm
onSubmit={(value) => {
setOtpValue(value)
handleAuthenticate()
}}
isLoading={isVerifyingOtp}
error={authError}
/>
<div className="flex items-center justify-center pt-3">
<button
type="button"
onClick={() => handleResendOtp()}
disabled={isSendingOtp}
className="text-sm text-primary hover:underline disabled:opacity-50"
>
{isSendingOtp ? 'Sending...' : 'Resend code'}
</button>
<span className="mx-2 text-neutral-300 dark:text-neutral-600"></span>
<button
type="button"
onClick={() => {
setShowOtpVerification(false)
setOtpValue('')
setAuthError(null)
}}
className="text-sm text-primary hover:underline"
>
Change email
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
// Loading state while fetching config
if (!chatConfig) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="animate-pulse text-center">
<div className="h-8 w-48 bg-gray-200 rounded mx-auto mb-4"></div>
<div className="h-4 w-64 bg-gray-200 rounded mx-auto"></div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-[100] bg-background flex flex-col">
<style jsx>{`
@keyframes growShrink {
0%,
100% {
transform: scale(0.9);
}
50% {
transform: scale(1.1);
}
}
.loading-dot {
animation: growShrink 1.5s infinite ease-in-out;
}
`}</style>
{/* Header with title */}
<div className="flex items-center justify-between px-4 py-3">
<h2 className="text-lg font-medium">
{chatConfig.customizations?.headerText || chatConfig.title || 'Chat'}
</h2>
{chatConfig.customizations?.logoUrl && (
<img
src={chatConfig.customizations.logoUrl}
alt={`${chatConfig.title} logo`}
className="h-6 w-6 object-contain"
/>
)}
</div>
{/* Messages container */}
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full py-10 px-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">How can I help you today?</h3>
<p className="text-muted-foreground text-sm">
{chatConfig.description || 'Ask me anything.'}
</p>
</div>
</div>
) : (
messages.map((message) => <ClientChatMessage key={message.id} message={message} />)
)}
{/* Loading indicator (shows only when executing) */}
{isLoading && (
<div className="py-5 px-4">
<div className="max-w-3xl mx-auto">
<div className="flex">
<div className="max-w-[80%]">
<div className="flex items-center h-6">
<div className="w-3 h-3 rounded-full bg-black dark:bg-black loading-dot"></div>
</div>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} className="h-1" />
</div>
</div>
{/* Input area (fixed at bottom) */}
<div className="bg-background p-6">
<div className="max-w-3xl mx-auto">
<div className="relative rounded-2xl border bg-background shadow-sm">
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => 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"
/>
<Button
onClick={handleSendMessage}
size="icon"
disabled={!inputValue.trim() || isLoading}
className="absolute right-3 top-1/2 -translate-y-1/2 h-10 w-10 p-0 rounded-xl bg-black text-white hover:bg-gray-800"
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 <ChatClient subdomain={subdomain} />
}

View File

@@ -0,0 +1,342 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, Copy, KeySquare, Loader2, Plus, X } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('DeployForm')
interface ApiKey {
id: string
name: string
key: string
lastUsed?: string
createdAt: string
expiresAt?: string
}
// Form schema for API key selection or creation
const deployFormSchema = z.object({
apiKey: z.string().min(1, 'Please select an API key'),
newKeyName: z.string().optional(),
})
type DeployFormValues = z.infer<typeof deployFormSchema>
interface DeployFormProps {
apiKeys: ApiKey[]
keysLoaded: boolean
endpointUrl: string
workflowId: string
onSubmit: (data: DeployFormValues) => void
getInputFormatExample: () => string
onApiKeyCreated?: () => void
}
export function DeployForm({
apiKeys,
keysLoaded,
endpointUrl,
workflowId,
onSubmit,
getInputFormatExample,
onApiKeyCreated,
}: DeployFormProps) {
// State
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [isCreating, setIsCreating] = useState(false)
// Initialize form with react-hook-form
const form = useForm<DeployFormValues>({
resolver: zodResolver(deployFormSchema),
defaultValues: {
apiKey: apiKeys.length > 0 ? apiKeys[0].key : '',
newKeyName: '',
},
})
// Update on dependency changes beyond the initial load
useEffect(() => {
if (keysLoaded && apiKeys.length > 0) {
// Ensure that form has a value after loading
form.setValue('apiKey', form.getValues().apiKey || apiKeys[0].key)
}
}, [keysLoaded, apiKeys, form])
// Generate a new API key
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
setIsCreating(true)
try {
const response = await fetch('/api/user/api-keys', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newKeyName.trim(),
}),
})
if (!response.ok) {
throw new Error('Failed to create new API key')
}
const data = await response.json()
// Show the new key dialog with the API key (only shown once)
setNewKey(data.key)
setShowNewKeyDialog(true)
// Reset form
setNewKeyName('')
// Close the create dialog
setIsCreatingKey(false)
// Update the form with the new key
form.setValue('apiKey', data.key.key)
// Trigger a refresh of the keys list in the parent component
if (onApiKeyCreated) {
onApiKeyCreated()
}
} catch (error) {
logger.error('Error creating API key:', { error })
} finally {
setIsCreating(false)
}
}
// Copy API key to clipboard
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
return (
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit(form.getValues())
}}
className="space-y-6"
>
{/* API Key selection */}
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem className="space-y-1.5">
<div className="flex items-center justify-between">
<FormLabel className="font-medium text-sm">Select API Key</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-primary"
onClick={() => setIsCreatingKey(true)}
>
<Plus className="h-3.5 w-3.5" />
<span>Create new</span>
</Button>
</div>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className={!keysLoaded ? 'opacity-70' : ''}>
{!keysLoaded ? (
<div className="flex items-center space-x-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>Loading API keys...</span>
</div>
) : (
<SelectValue placeholder="Select an API key" className="text-sm" />
)}
</SelectTrigger>
</FormControl>
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)] py-1">
{apiKeys.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.key}
className="flex items-center px-3 py-2.5 my-0.5 rounded-sm cursor-pointer data-[state=checked]:bg-muted [&>span.absolute]:hidden"
>
<div className="flex items-center w-full">
<div className="flex items-center justify-between w-full">
<span className="text-sm truncate mr-2">{apiKey.name}</span>
<span className="text-xs text-muted-foreground font-mono flex-shrink-0 bg-muted px-1.5 py-0.5 mt-[1px] rounded">
{apiKey.key.slice(-5)}
</span>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Create API Key Dialog */}
<Dialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
<DialogContent className="sm:max-w-md flex flex-col p-0 gap-0" hideCloseButton>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">Create new API key</DialogTitle>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
onClick={() => setIsCreatingKey(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</DialogHeader>
<div className="pt-4 px-6 pb-6 flex-1">
<div className="space-y-2">
<Label htmlFor="keyName">API Key Name</Label>
<Input
id="keyName"
placeholder="e.g., Development, Production, etc."
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="focus-visible:ring-primary"
/>
</div>
</div>
<div className="border-t px-6 py-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsCreatingKey(false)}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
Creating...
</>
) : (
'Create'
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* New API Key Dialog */}
<Dialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) setNewKey(null)
}}
>
<DialogContent className="sm:max-w-md flex flex-col p-0 gap-0" hideCloseButton>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">
Your API key has been created
</DialogTitle>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
<DialogDescription className="pt-2">
This is the only time you will see your API key. Copy it now and store it securely.
</DialogDescription>
</DialogHeader>
{newKey && (
<div className="pt-4 px-6 pb-6 flex-1">
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
readOnly
value={newKey.key}
className="font-mono text-sm pr-10 bg-muted/50 border-slate-300"
/>
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="sr-only">Copy to clipboard</span>
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
For security, we don&apos;t store the complete key. You won&apos;t be able to
view it again.
</p>
</div>
</div>
)}
<div className="border-t px-6 py-4 flex justify-end">
<Button
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
</form>
</Form>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { CopyButton } from '@/components/ui/copy-button'
import { Label } from '@/components/ui/label'
interface ApiEndpointProps {
endpoint: string
showLabel?: boolean
}
export function ApiEndpoint({ endpoint, showLabel = true }: ApiEndpointProps) {
return (
<div className="space-y-1.5">
{showLabel && (
<div className="flex items-center gap-1.5">
<Label className="font-medium text-sm">API Endpoint</Label>
</div>
)}
<div className="relative group rounded-md border bg-background hover:bg-muted/50 transition-colors">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">{endpoint}</pre>
<CopyButton text={endpoint} />
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useState } from 'react'
import { CopyButton } from '@/components/ui/copy-button'
import { Label } from '@/components/ui/label'
interface ApiKeyProps {
apiKey: string
showLabel?: boolean
}
export function ApiKey({ apiKey, showLabel = true }: ApiKeyProps) {
const [showKey, setShowKey] = useState(false)
// Function to mask API key with asterisks but keep first and last 4 chars visible
const maskApiKey = (key: string) => {
if (!key || key.includes('No API key found')) return key
if (key.length <= 8) return key
return `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}`
}
return (
<div className="space-y-1.5">
{showLabel && (
<div className="flex items-center gap-1.5">
<Label className="font-medium text-sm">API Key</Label>
</div>
)}
<div className="relative group rounded-md border bg-background hover:bg-muted/50 transition-colors">
<pre
className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto cursor-pointer"
onClick={() => setShowKey(!showKey)}
title={showKey ? 'Click to hide API Key' : 'Click to reveal API Key'}
>
{showKey ? apiKey : maskApiKey(apiKey)}
</pre>
<CopyButton text={apiKey} />
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { cn } from '@/lib/utils'
interface DeployStatusProps {
needsRedeployment: boolean
}
export function DeployStatus({ needsRedeployment }: DeployStatusProps) {
return (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">Status:</span>
<div className="flex items-center gap-1.5">
<div className="relative flex items-center justify-center">
{needsRedeployment ? (
<>
<div className="absolute h-3 w-3 rounded-full bg-amber-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-amber-500"></div>
</>
) : (
<>
<div className="absolute h-3 w-3 rounded-full bg-green-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-green-500"></div>
</>
)}
</div>
<span
className={cn(
'text-xs font-medium',
needsRedeployment
? 'text-amber-600 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400'
: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400'
)}
>
{needsRedeployment ? 'Changes Detected' : 'Active'}
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { CopyButton } from '@/components/ui/copy-button'
import { Label } from '@/components/ui/label'
interface ExampleCommandProps {
command: string
apiKey: string
showLabel?: boolean
}
export function ExampleCommand({ command, apiKey, showLabel = true }: ExampleCommandProps) {
// Format the curl command to use a placeholder for the API key
const formatCurlCommand = (command: string, apiKey: string) => {
if (!command.includes('curl')) return command
// Replace the actual API key with a placeholder in the command
const sanitizedCommand = command.replace(apiKey, 'SIM_API_KEY')
// Format the command with line breaks for better readability
return sanitizedCommand
.replace(' -H ', '\n -H ')
.replace(' -d ', '\n -d ')
.replace(' http', '\n http')
}
return (
<div className="space-y-1.5">
{showLabel && (
<div className="flex items-center gap-1.5">
<Label className="font-medium text-sm">Example Command</Label>
</div>
)}
<div className="relative group rounded-md border bg-background hover:bg-muted/50 transition-colors">
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{formatCurlCommand(command, apiKey)}
</pre>
<CopyButton text={command} />
</div>
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import { useState } from 'react'
import { Info, Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ApiEndpoint } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint'
import { ApiKey } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key'
import { DeployStatus } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status'
import { ExampleCommand } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command'
interface DeploymentInfoProps {
isLoading: boolean
deploymentInfo: {
isDeployed: boolean
deployedAt?: string
apiKey: string
endpoint: string
exampleCommand: string
needsRedeployment: boolean
} | null
onRedeploy: () => void
onUndeploy: () => void
isSubmitting: boolean
isUndeploying: boolean
}
export function DeploymentInfo({
isLoading,
deploymentInfo,
onRedeploy,
onUndeploy,
isSubmitting,
isUndeploying,
}: DeploymentInfoProps) {
if (isLoading || !deploymentInfo) {
return (
<div className="space-y-4 px-1 overflow-y-auto">
{/* API Endpoint skeleton */}
<div className="space-y-3">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-10 w-full" />
</div>
{/* API Key skeleton */}
<div className="space-y-3">
<Skeleton className="h-5 w-20" />
<Skeleton className="h-10 w-full" />
</div>
{/* Example Command skeleton */}
<div className="space-y-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-24 w-full rounded-md" />
</div>
{/* Deploy Status and buttons skeleton */}
<div className="flex items-center justify-between pt-2 mt-4">
<Skeleton className="h-6 w-32" />
<div className="flex gap-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-24" />
</div>
</div>
</div>
)
}
return (
<div className="space-y-4 px-1 overflow-y-auto">
<div className="space-y-4">
<ApiEndpoint endpoint={deploymentInfo.endpoint} />
<ApiKey apiKey={deploymentInfo.apiKey} />
<ExampleCommand command={deploymentInfo.exampleCommand} apiKey={deploymentInfo.apiKey} />
</div>
<div className="flex items-center justify-between pt-2 mt-4">
<DeployStatus needsRedeployment={deploymentInfo.needsRedeployment} />
<div className="flex gap-2">
{deploymentInfo.needsRedeployment && (
<Button variant="outline" size="sm" onClick={onRedeploy} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
{isSubmitting ? 'Redeploying...' : 'Redeploy'}
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isUndeploying}>
{isUndeploying ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> : null}
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undeploy API</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to undeploy this workflow? This will remove the API endpoint
and make it unavailable to external users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onUndeploy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Undeploy
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,732 @@
'use client'
import { useEffect, useState } from 'react'
import { Info, Loader2, X } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { TabsContent } from '@/components/ui/tabs'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ChatDeploy } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
import { DeployForm } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form'
import { DeploymentInfo } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info'
const logger = createLogger('DeployModal')
interface DeployModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string | null
needsRedeployment: boolean
setNeedsRedeployment: (value: boolean) => void
}
interface ApiKey {
id: string
name: string
key: string
lastUsed?: string
createdAt: string
expiresAt?: string
}
interface DeploymentInfo {
isDeployed: boolean
deployedAt?: string
apiKey: string
endpoint: string
exampleCommand: string
needsRedeployment: boolean
}
interface DeployFormValues {
apiKey: string
newKeyName?: string
}
type TabView = 'api' | 'chat'
export function DeployModal({
open,
onOpenChange,
workflowId,
needsRedeployment,
setNeedsRedeployment,
}: DeployModalProps) {
// Store hooks
const { addNotification } = useNotificationStore()
const { isDeployed, setDeploymentStatus } = useWorkflowStore()
// Local state
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<DeploymentInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [keysLoaded, setKeysLoaded] = useState(false)
const [activeTab, setActiveTab] = useState<TabView>('api')
const [isChatDeploying, setIsChatDeploying] = useState(false)
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false)
const [deployedChatUrl, setDeployedChatUrl] = useState<string | null>(null)
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false)
// Generate an example input format for the API request
const getInputFormatExample = () => {
let inputFormatExample = ''
try {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const starterBlock = blocks.find((block) => block.type === 'starter')
if (starterBlock) {
const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat')
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
const exampleData: Record<string, any> = {}
inputFormat.forEach((field: any) => {
if (field.name) {
switch (field.type) {
case 'string':
exampleData[field.name] = 'example'
break
case 'number':
exampleData[field.name] = 42
break
case 'boolean':
exampleData[field.name] = true
break
case 'object':
exampleData[field.name] = { key: 'value' }
break
case 'array':
exampleData[field.name] = [1, 2, 3]
break
}
}
})
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
}
}
} catch (error) {
logger.error('Error generating input format example:', error)
}
return inputFormatExample
}
// Fetch API keys when modal opens
const fetchApiKeys = async () => {
if (!open) return
try {
setKeysLoaded(false)
const response = await fetch('/api/user/api-keys')
if (response.ok) {
const data = await response.json()
setApiKeys(data.keys || [])
setKeysLoaded(true)
}
} catch (error) {
logger.error('Error fetching API keys:', { error })
addNotification('error', 'Failed to fetch API keys', workflowId)
setKeysLoaded(true)
}
}
// Fetch chat deployment info when modal opens
const fetchChatDeploymentInfo = async () => {
if (!open || !workflowId) return
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.deployment && data.deployment.chatUrl) {
setDeployedChatUrl(data.deployment.chatUrl)
setChatExists(true)
} else {
setDeployedChatUrl(null)
setChatExists(false)
}
} else {
setDeployedChatUrl(null)
setChatExists(false)
}
} catch (error) {
logger.error('Error fetching chat deployment info:', { error })
setDeployedChatUrl(null)
setChatExists(false)
} finally {
setIsLoading(false)
}
}
// Call fetchApiKeys when the modal opens
useEffect(() => {
if (open) {
// Set loading state immediately when modal opens
setIsLoading(true)
fetchApiKeys()
fetchChatDeploymentInfo()
setActiveTab('api')
}
}, [open, workflowId])
// Fetch deployment info when the modal opens and the workflow is deployed
useEffect(() => {
async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
// Only reset loading if modal is closed
if (!open) {
setIsLoading(false)
}
return
}
try {
setIsLoading(true)
// Get deployment info
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
if (!response.ok) {
throw new Error('Failed to fetch deployment information')
}
const data = await response.json()
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
setDeploymentInfo({
isDeployed: data.isDeployed,
deployedAt: data.deployedAt,
apiKey: data.apiKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment,
})
} catch (error) {
logger.error('Error fetching deployment info:', { error })
addNotification('error', 'Failed to fetch deployment information', workflowId)
} finally {
setIsLoading(false)
}
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, addNotification, needsRedeployment])
// Handle form submission for deployment
const onDeploy = async (data: DeployFormValues) => {
if (!workflowId) {
addNotification('error', 'No active workflow to deploy', null)
return
}
// Reset any previous errors
setApiDeployError(null)
try {
setIsSubmitting(true)
// Deploy the workflow with the selected API key
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
apiKey: data.apiKey,
deployChatEnabled: false, // Separate chat deployment
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
// Update the store with the deployment status
setDeploymentStatus(newDeployStatus, deployedAt ? new Date(deployedAt) : undefined)
// Reset the needs redeployment flag
setNeedsRedeployment(false)
// Update the local deployment info
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
const newDeploymentInfo = {
isDeployed: true,
deployedAt: deployedAt,
apiKey: data.apiKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: false,
}
setDeploymentInfo(newDeploymentInfo)
// No notification on successful deploy
} catch (error: any) {
logger.error('Error deploying workflow:', { error })
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
} finally {
setIsSubmitting(false)
}
}
// Handle workflow undeployment
const handleUndeploy = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to undeploy', null)
return
}
try {
setIsUndeploying(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
// Update deployment status in the store
setDeploymentStatus(false)
// Reset chat deployment info
setDeployedChatUrl(null)
setChatExists(false)
// Add a success notification
addNotification('info', 'Workflow successfully undeployed', workflowId)
// Close the modal
onOpenChange(false)
} catch (error: any) {
logger.error('Error undeploying workflow:', { error })
addNotification('error', `Failed to undeploy workflow: ${error.message}`, workflowId)
} finally {
setIsUndeploying(false)
}
}
// Handle redeployment of workflow
const handleRedeploy = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to redeploy', null)
return
}
try {
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false, // Separate chat deployment
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to redeploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
// Update deployment status in the store
setDeploymentStatus(newDeployStatus, deployedAt ? new Date(deployedAt) : undefined)
// Reset the needs redeployment flag
setNeedsRedeployment(false)
// Add a success notification
addNotification('info', 'Workflow successfully redeployed', workflowId)
} catch (error: any) {
logger.error('Error redeploying workflow:', { error })
addNotification('error', `Failed to redeploy workflow: ${error.message}`, workflowId)
} finally {
setIsSubmitting(false)
}
}
// Custom close handler to ensure we clean up loading states
const handleCloseModal = () => {
// Reset all loading states
setIsSubmitting(false)
setIsChatDeploying(false)
setChatSubmitting(false)
onOpenChange(false)
}
// Add a new handler for chat undeploy
const handleChatUndeploy = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to undeploy chat', null)
return
}
try {
setIsUndeploying(true)
// First get the chat deployment info
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to get chat info')
}
const data = await response.json()
if (!data.isDeployed || !data.deployment || !data.deployment.id) {
throw new Error('No active chat deployment found')
}
// Delete the chat
const deleteResponse = await fetch(`/api/chat/edit/${data.deployment.id}`, {
method: 'DELETE',
})
if (!deleteResponse.ok) {
const errorData = await deleteResponse.json()
throw new Error(errorData.error || 'Failed to undeploy chat')
}
// Reset chat deployment info
setDeployedChatUrl(null)
setChatExists(false)
// Add a success notification
addNotification('info', 'Chat successfully undeployed', workflowId)
// Close the modal
onOpenChange(false)
} catch (error: any) {
logger.error('Error undeploying chat:', { error })
addNotification('error', `Failed to undeploy chat: ${error.message}`, workflowId)
} finally {
setIsUndeploying(false)
setShowDeleteConfirmation(false)
}
}
// Find or create appropriate method to handle chat deployment
const handleChatSubmit = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to deploy', null)
return
}
// Check if workflow is deployed
if (!isDeployed) {
// Deploy workflow first
try {
setChatSubmitting(true)
// Call the API to deploy the workflow
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployApiEnabled: true,
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
// Update the store with the deployment status
setDeploymentStatus(newDeployStatus, deployedAt ? new Date(deployedAt) : undefined)
logger.info('Workflow automatically deployed for chat deployment')
} catch (error: any) {
logger.error('Error auto-deploying workflow for chat:', { error })
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
setChatSubmitting(false)
return
}
}
// Now submit the chat deploy form
const form = document.querySelector('.chat-deploy-form') as HTMLFormElement
if (form) {
form.requestSubmit()
}
}
// Render deployed chat view
const renderDeployedChatView = () => {
if (!deployedChatUrl) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Info className="h-5 w-5" />
<p className="text-sm">No chat deployment information available</p>
</div>
</div>
)
}
return (
<div className="space-y-4">
<Card className="border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-900/20">
<CardContent className="p-6 text-green-800 dark:text-green-400">
<h3 className="text-base font-medium mb-2">Chat Deployment Active</h3>
<p className="mb-3">Your chat is available at:</p>
<div className="bg-white/50 dark:bg-gray-900/50 p-3 rounded-md border border-green-200 dark:border-green-900/50 relative group">
<a
href={deployedChatUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-primary underline break-all block pr-8"
>
{deployedChatUrl}
</a>
<CopyButton text={deployedChatUrl || ''} />
</div>
</CardContent>
</Card>
</div>
)
}
return (
<Dialog open={open} onOpenChange={handleCloseModal}>
<DialogContent
className="sm:max-w-[600px] max-h-[78vh] flex flex-col p-0 gap-0 overflow-hidden"
hideCloseButton
>
<DialogHeader className="px-6 py-4 border-b flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">Deploy Workflow</DialogTitle>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0" onClick={handleCloseModal}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-none flex items-center h-14 px-6 border-b">
<div className="flex gap-2">
<button
onClick={() => setActiveTab('api')}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'api'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
API
</button>
<button
onClick={() => setActiveTab('chat')}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'chat'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
Chat
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-6">
{activeTab === 'api' && (
<>
{isDeployed ? (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={deploymentInfo}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
/>
) : (
<>
{apiDeployError && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
<div className="font-semibold">API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<div className="px-1 -mx-1">
<DeployForm
apiKeys={apiKeys}
keysLoaded={keysLoaded}
endpointUrl={`${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`}
workflowId={workflowId || ''}
onSubmit={onDeploy}
getInputFormatExample={getInputFormatExample}
onApiKeyCreated={fetchApiKeys}
/>
</div>
</>
)}
</>
)}
{activeTab === 'chat' && (
<ChatDeploy
workflowId={workflowId || ''}
onClose={() => onOpenChange(false)}
deploymentInfo={deploymentInfo}
onChatExistsChange={setChatExists}
showDeleteConfirmation={showDeleteConfirmation}
setShowDeleteConfirmation={setShowDeleteConfirmation}
/>
)}
</div>
</div>
</div>
{/* Footer buttons */}
{activeTab === 'api' && !isDeployed && (
<div className="border-t px-6 py-4 flex justify-between flex-shrink-0">
<Button variant="outline" onClick={handleCloseModal}>
Cancel
</Button>
<Button
type="button"
onClick={() => onDeploy({ apiKey: apiKeys.length > 0 ? apiKeys[0].key : '' })}
disabled={isSubmitting || (!keysLoaded && !apiKeys.length) || isChatDeploying}
className={cn(
'gap-2 font-medium',
'bg-[#802FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
)}
>
{isSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
Deploying...
</>
) : (
'Deploy API'
)}
</Button>
</div>
)}
{activeTab === 'chat' && (
<div className="border-t px-6 py-4 flex justify-between flex-shrink-0">
<Button variant="outline" onClick={handleCloseModal}>
Cancel
</Button>
<div className="flex gap-2">
{chatExists && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={chatSubmitting || isUndeploying}>
{isUndeploying ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
Undeploying...
</>
) : (
'Delete'
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this chat? This will remove the chat
interface and make it unavailable to external users.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleChatUndeploy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button
type="button"
onClick={handleChatSubmit}
disabled={chatSubmitting}
className={cn(
'gap-2 font-medium',
'bg-[#802FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
)}
>
{chatSubmitting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
{isDeployed
? chatExists
? 'Updating...'
: 'Deploying...'
: 'Deploying Workflow...'}
</>
) : chatExists ? (
'Update'
) : (
'Deploy Chat'
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -6,9 +6,8 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { DeployModal } from '../deploy-modal/deploy-modal'
const logger = createLogger('DeploymentControls')
@@ -24,228 +23,69 @@ export function DeploymentControls({
setNeedsRedeployment,
}: DeploymentControlsProps) {
// Store hooks
const { addNotification, showNotification, removeNotification, notifications } =
useNotificationStore()
const { isDeployed, setDeploymentStatus } = useWorkflowStore()
const { isDeployed } = useWorkflowStore()
// Local state
const [isDeploying, setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
/**
* Get an example of the input format for the workflow
* Open the deployment modal
*/
const getInputFormatExample = () => {
let inputFormatExample = ''
try {
// Find the starter block in the workflow
const blocks = Object.values(useWorkflowStore.getState().blocks)
const starterBlock = blocks.find((block) => block.type === 'starter')
if (starterBlock) {
const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat')
// If input format is defined, create an example
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
const exampleData: Record<string, any> = {}
// Create example values for each field
inputFormat.forEach((field: any) => {
if (field.name) {
switch (field.type) {
case 'string':
exampleData[field.name] = 'example'
break
case 'number':
exampleData[field.name] = 42
break
case 'boolean':
exampleData[field.name] = true
break
case 'object':
exampleData[field.name] = { key: 'value' }
break
case 'array':
exampleData[field.name] = [1, 2, 3]
break
}
}
})
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
}
}
} catch (error) {
logger.error('Error generating input format example:', error)
}
return inputFormatExample
}
/**
* Helper to create API notification with consistent format
*/
const createApiNotification = (
message: string,
workflowId: string,
apiKey: string,
needsRedeployment = false
) => {
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
return addNotification('api', message, workflowId, {
isPersistent: true,
sections: [
{
label: 'API Endpoint',
content: endpoint,
},
{
label: 'x-api-key',
content: apiKey,
},
{
label: 'Example curl command',
content: `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
},
],
needsRedeployment,
})
}
/**
* Workflow deployment handler
*/
const handleDeploy = async () => {
if (!activeWorkflowId) return
// If already deployed, show the API info
if (isDeployed) {
// Try to find an existing API notification
const apiNotification = notifications.find(
(n) => n.type === 'api' && n.workflowId === activeWorkflowId
)
if (apiNotification) {
// Before showing existing notification, check if we need to update it with current status
if (apiNotification.options?.needsRedeployment !== needsRedeployment) {
// Remove old notification
removeNotification(apiNotification.id)
// Fetch API key from the existing notification
const apiKey =
apiNotification.options?.sections?.find((s) => s.label === 'x-api-key')?.content || ''
createApiNotification(
needsRedeployment
? 'Workflow changes detected - Redeploy needed'
: 'Workflow deployment information',
activeWorkflowId,
apiKey,
needsRedeployment
)
} else {
// Show existing notification if status hasn't changed
showNotification(apiNotification.id)
}
return
}
// If notification not found but workflow is deployed, fetch deployment info
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`)
if (!response.ok) throw new Error('Failed to fetch deployment info')
// Get needsRedeployment info from status endpoint
const statusResponse = await fetch(`/api/workflows/${activeWorkflowId}/status`)
const statusData = await statusResponse.json()
const needsRedeployment = statusData.needsRedeployment || false
const { apiKey } = await response.json()
// Create a new notification with the deployment info
createApiNotification(
needsRedeployment
? 'Workflow changes detected - Redeploy needed'
: 'Workflow deployment information',
activeWorkflowId,
apiKey,
needsRedeployment
)
} catch (error) {
addNotification('error', 'Failed to fetch deployment information', activeWorkflowId)
} finally {
setIsDeploying(false)
}
return
}
// If not deployed, proceed with deployment
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to deploy workflow')
const { apiKey, isDeployed: newDeployStatus, deployedAt } = await response.json()
// Update the store with the deployment status
setDeploymentStatus(newDeployStatus, deployedAt ? new Date(deployedAt) : undefined)
// Reset the needs redeployment flag since we just deployed
setNeedsRedeployment(false)
createApiNotification('Workflow successfully deployed', activeWorkflowId, apiKey)
} catch (error) {
addNotification('error', 'Failed to deploy workflow. Please try again.', activeWorkflowId)
} finally {
setIsDeploying(false)
}
const handleOpenModal = () => {
setIsModalOpen(true)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="relative">
<Button
variant="ghost"
size="icon"
onClick={handleDeploy}
disabled={isDeploying}
className={cn('hover:text-[#802FFF]', isDeployed && 'text-[#802FFF]')}
>
{isDeploying ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Rocket className="h-5 w-5" />
)}
<span className="sr-only">Deploy API</span>
</Button>
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative">
<Button
variant="ghost"
size="icon"
onClick={handleOpenModal}
disabled={isDeploying}
className={cn('hover:text-[#802FFF]', isDeployed && 'text-[#802FFF]')}
>
{isDeploying ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Rocket className="h-5 w-5" />
)}
<span className="sr-only">Deploy API</span>
</Button>
{/* Improved redeploy indicator with animation */}
{isDeployed && needsRedeployment && (
<div className="absolute top-0.5 right-0.5 flex items-center justify-center">
<div className="relative">
<div className="absolute inset-0 w-2 h-2 rounded-full bg-amber-500/50 animate-ping"></div>
<div className="relative w-2 h-2 rounded-full bg-amber-500 ring-1 ring-background animate-in zoom-in fade-in duration-300"></div>
{/* Improved redeploy indicator with animation */}
{isDeployed && needsRedeployment && (
<div className="absolute top-0.5 right-0.5 flex items-center justify-center">
<div className="relative">
<div className="absolute inset-0 w-2 h-2 rounded-full bg-amber-500/50 animate-ping"></div>
<div className="relative w-2 h-2 rounded-full bg-amber-500 ring-1 ring-background animate-in zoom-in fade-in duration-300"></div>
</div>
<span className="sr-only">Needs Redeployment</span>
</div>
<span className="sr-only">Needs Redeployment</span>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{isDeploying
? 'Deploying...'
: isDeployed && needsRedeployment
? 'Workflow changes detected'
: 'Deployment Settings'}
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{isDeploying
? 'Deploying...'
: isDeployed && needsRedeployment
? 'Workflow changes detected'
: isDeployed
? 'Deployment Settings'
: 'Deploy as API'}
</TooltipContent>
</Tooltip>
<DeployModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
workflowId={activeWorkflowId}
needsRedeployment={needsRedeployment}
setNeedsRedeployment={setNeedsRedeployment}
/>
</>
)
}

View File

@@ -19,17 +19,17 @@ interface NotificationDropdownItemProps {
const NotificationIcon = {
error: ErrorIcon,
console: Terminal,
api: Rocket,
marketplace: Store,
info: AlertCircle,
api: Rocket,
}
const NotificationColors = {
error: 'text-destructive',
console: 'text-foreground',
api: 'text-[#802FFF]',
marketplace: 'text-foreground',
info: 'text-foreground',
api: 'text-foreground',
}
export function NotificationDropdownItem({
@@ -89,13 +89,11 @@ export function NotificationDropdownItem({
<span className="text-xs font-medium">
{type === 'error'
? 'Error'
: type === 'api'
? 'API'
: type === 'marketplace'
? 'Marketplace'
: type === 'info'
? 'Info'
: 'Console'}
: type === 'marketplace'
? 'Marketplace'
: type === 'info'
? 'Info'
: 'Console'}
</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>

View File

@@ -5,10 +5,10 @@ import { MessageCircle, Send, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useChatStore } from '@/stores/chat/store'
import { useCopilotStore } from '@/stores/copilot/store'
export function Chat() {
const { sendMessage } = useChatStore()
export function Copilot() {
const { sendMessage } = useCopilotStore()
const [isOpen, setIsOpen] = useState(false)
const [message, setMessage] = useState('')

View File

@@ -1,3 +1,4 @@
// NOTE: API NOTIFICATIONS NO LONGER EXIST, BUT IF YOU DELETE THEM FROM THIS FILE THE APPLICATION WILL BREAK
import { useEffect, useState } from 'react'
import { Info, Rocket, Store, Terminal, X } from 'lucide-react'
import { ErrorIcon } from '@/components/icons'

View File

@@ -1,19 +1,18 @@
'use client'
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, ChevronDown } from 'lucide-react'
import { KeyboardEvent, useEffect, useMemo, useRef } 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'
import { useExecutionStore } from '@/stores/execution/store'
import { useChatStore } from '@/stores/panel/chat/store'
import { useConsoleStore } from '@/stores/panel/console/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
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 { OutputSelect } from './components/output-select/output-select'
interface ChatProps {
panelWidth: number
@@ -22,73 +21,18 @@ interface ChatProps {
}
export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { messages, addMessage, selectedWorkflowOutputs, setSelectedWorkflowOutput } =
useChatStore()
const { entries } = useConsoleStore()
const blocks = useWorkflowStore((state) => state.blocks)
const messagesEndRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Use the execution store state to track if a workflow is executing
const { isExecuting } = useExecutionStore()
// Get workflow execution functionality
const { handleRunWorkflow, executionResult } = useWorkflowExecution()
// Get workflow outputs for the dropdown
const workflowOutputs = useMemo(() => {
const outputs: {
id: string
label: string
blockId: string
blockName: string
blockType: string
path: string
}[] = []
if (!activeWorkflowId) return outputs
// Process blocks to extract outputs
Object.values(blocks).forEach((block) => {
// Skip starter/start blocks
if (block.type === 'starter') return
const blockName = block.name.replace(/\s+/g, '').toLowerCase()
// Add response outputs
if (block.outputs && typeof block.outputs === 'object') {
const addOutput = (path: string, outputObj: any, prefix = '') => {
const fullPath = prefix ? `${prefix}.${path}` : path
if (typeof outputObj === 'object' && outputObj !== null) {
// For objects, recursively add each property
Object.entries(outputObj).forEach(([key, value]) => {
addOutput(key, value, fullPath)
})
} else {
// Add leaf node as output option
outputs.push({
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name,
blockType: block.type,
path: fullPath,
})
}
}
// Start with the response object
if (block.outputs.response) {
addOutput('response', block.outputs.response)
}
}
})
return outputs
}, [blocks, activeWorkflowId])
const { handleRunWorkflow } = useWorkflowExecution()
// Get output entries from console for the dropdown
const outputEntries = useMemo(() => {
@@ -112,29 +56,6 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
return selectedId
}, [selectedWorkflowOutputs, activeWorkflowId, outputEntries])
// Get selected output display name
const selectedOutputDisplayName = useMemo(() => {
if (!selectedOutput) return 'Select output source'
const output = workflowOutputs.find((o) => o.id === selectedOutput)
return output
? `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
: 'Select output source'
}, [selectedOutput, workflowOutputs])
// Get selected output block info
const selectedOutputInfo = useMemo(() => {
if (!selectedOutput) return null
const output = workflowOutputs.find((o) => o.id === selectedOutput)
if (!output) return null
return {
blockName: output.blockName,
blockId: output.blockId,
blockType: output.blockType,
path: output.path,
}
}, [selectedOutput, workflowOutputs])
// Auto-scroll to bottom when new messages are added
useEffect(() => {
if (messagesEndRef.current) {
@@ -142,20 +63,6 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
}
}, [workflowMessages])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOutputDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
// Handle send message
const handleSendMessage = async () => {
if (!chatMessage.trim() || !activeWorkflowId || isExecuting) return
@@ -190,170 +97,20 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
const handleOutputSelection = (value: string) => {
if (activeWorkflowId) {
setSelectedWorkflowOutput(activeWorkflowId, value)
setIsOutputDropdownOpen(false)
}
}
// Group output options by block
const groupedOutputs = useMemo(() => {
const groups: Record<string, typeof workflowOutputs> = {}
const blockDistances: Record<string, number> = {}
const edges = useWorkflowStore.getState().edges
// Find the starter block
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
const starterBlockId = starterBlock?.id
// Calculate distances from starter block if it exists
if (starterBlockId) {
// Build an adjacency list for faster traversal
const adjList: Record<string, string[]> = {}
for (const edge of edges) {
if (!adjList[edge.source]) {
adjList[edge.source] = []
}
adjList[edge.source].push(edge.target)
}
// BFS to find distances from starter block
const visited = new Set<string>()
const queue: [string, number][] = [[starterBlockId, 0]] // [nodeId, distance]
while (queue.length > 0) {
const [currentNodeId, distance] = queue.shift()!
if (visited.has(currentNodeId)) continue
visited.add(currentNodeId)
blockDistances[currentNodeId] = distance
// Get all outgoing edges from the adjacency list
const outgoingNodeIds = adjList[currentNodeId] || []
// Add all target nodes to the queue with incremented distance
for (const targetId of outgoingNodeIds) {
queue.push([targetId, distance + 1])
}
}
}
// Group by block name
workflowOutputs.forEach((output) => {
if (!groups[output.blockName]) {
groups[output.blockName] = []
}
groups[output.blockName].push(output)
})
// Convert to array of [blockName, outputs] for sorting
const groupsArray = Object.entries(groups).map(([blockName, outputs]) => {
// Find the blockId for this group (using the first output's blockId)
const blockId = outputs[0]?.blockId
// Get the distance for this block (or default to 0 if not found)
const distance = blockId ? blockDistances[blockId] || 0 : 0
return { blockName, outputs, distance }
})
// Sort by distance (descending - furthest first)
groupsArray.sort((a, b) => b.distance - a.distance)
// Convert back to record
return groupsArray.reduce(
(acc, { blockName, outputs }) => {
acc[blockName] = outputs
return acc
},
{} as Record<string, typeof workflowOutputs>
)
}, [workflowOutputs, blocks])
// Get block color for an output
const getOutputColor = (blockId: string, blockType: string) => {
// Try to get the block's color from its configuration
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF' // Default blue if not found
}
return (
<div className="flex flex-col h-full">
{/* Output Source Dropdown */}
<div className="flex-none border-b px-4 py-2" ref={dropdownRef}>
<div className="relative">
<button
onClick={() => setIsOutputDropdownOpen(!isOutputDropdownOpen)}
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm rounded-md transition-colors ${
isOutputDropdownOpen
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
disabled={workflowOutputs.length === 0}
>
{selectedOutputInfo ? (
<div className="flex items-center gap-2 w-[calc(100%-24px)] overflow-hidden">
<div
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
style={{
backgroundColor: getOutputColor(
selectedOutputInfo.blockId,
selectedOutputInfo.blockType
),
}}
>
<span className="w-3 h-3 text-white font-bold text-xs">
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{selectedOutputDisplayName}</span>
</div>
) : (
<span className="truncate w-[calc(100%-24px)]">{selectedOutputDisplayName}</span>
)}
<ChevronDown
className={`h-4 w-4 transition-transform ml-1 flex-shrink-0 ${
isOutputDropdownOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOutputDropdownOpen && workflowOutputs.length > 0 && (
<div className="absolute z-50 mt-1 pt-1 w-full bg-popover rounded-md border shadow-md overflow-hidden">
<div className="max-h-[240px] overflow-y-auto">
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground border-t first:border-t-0">
{blockName}
</div>
<div>
{outputs.map((output) => (
<button
key={output.id}
onClick={() => handleOutputSelection(output.id)}
className={cn(
'flex items-center gap-2 text-sm text-left w-full px-3 py-1.5',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
selectedOutput === output.id && 'bg-accent text-accent-foreground'
)}
>
<div
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
style={{
backgroundColor: getOutputColor(output.blockId, output.blockType),
}}
>
<span className="w-3 h-3 text-white font-bold text-xs">
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate max-w-[calc(100%-28px)]">{output.path}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex-none border-b px-4 py-2">
<OutputSelect
workflowId={activeWorkflowId}
selectedOutput={selectedOutput}
onOutputSelect={handleOutputSelection}
disabled={!activeWorkflowId}
placeholder="Select output source"
/>
</div>
{/* Main layout with fixed heights to ensure input stays visible */}

View File

@@ -29,8 +29,8 @@ function ModalChatMessage({ message }: ChatMessageProps) {
<div className="py-5 px-4">
<div className="max-w-3xl mx-auto">
<div className="flex justify-end">
<div className="bg-[#F4F4F4] dark:bg-gray-600 rounded-3xl max-w-[80%] py-3 px-4">
<div className="whitespace-pre-wrap break-words text-base leading-relaxed text-[#0D0D0D]">
<div className="bg-[#F4F4F4] dark:bg-primary/10 rounded-3xl max-w-[80%] py-3 px-4 shadow-sm">
<div className="whitespace-pre-wrap break-words text-base leading-relaxed text-[#0D0D0D] dark:text-white">
{isJsonObject ? (
<JSONView data={message.content} initiallyExpanded={false} />
) : (
@@ -124,8 +124,18 @@ export function ChatModal({ open, onOpenChange, chatMessage, setChatMessage }: C
// Clear input
setChatMessage('')
// Ensure input stays focused
if (inputRef.current) {
inputRef.current.focus()
}
// Execute the workflow to generate a response
await handleRunWorkflow({ input: sentMessage })
// Ensure input stays focused even after response
if (inputRef.current) {
inputRef.current.focus()
}
}
// Handle key press
@@ -156,7 +166,7 @@ export function ChatModal({ open, onOpenChange, chatMessage, setChatMessage }: C
`}</style>
{/* Header with title and close button */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center justify-between px-4 py-3">
<h2 className="text-lg font-medium">Chat</h2>
<Button
variant="ghost"
@@ -217,19 +227,15 @@ export function ChatModal({ open, onOpenChange, chatMessage, setChatMessage }: C
onKeyDown={handleKeyPress}
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"
disabled={!activeWorkflowId || isExecuting}
disabled={!activeWorkflowId}
/>
<Button
onClick={handleSendMessage}
size="icon"
disabled={!chatMessage.trim() || !activeWorkflowId || isExecuting}
className="absolute right-3 top-1/2 -translate-y-1/2 h-10 w-10 p-0 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 rounded-xl"
className="absolute right-3 top-1/2 -translate-y-1/2 h-10 w-10 p-0 rounded-xl bg-black dark:bg-primary text-white hover:bg-gray-800 dark:hover:bg-primary/80"
>
{isExecuting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
<ArrowUp className="h-4 w-4 dark:text-black" />
</Button>
</div>

View File

@@ -0,0 +1,282 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getBlock } from '@/blocks'
interface OutputSelectProps {
workflowId: string | null
selectedOutput: string | null
onOutputSelect: (outputId: string) => void
disabled?: boolean
placeholder?: string
}
export function OutputSelect({
workflowId,
selectedOutput,
onOutputSelect,
disabled = false,
placeholder = 'Select output source',
}: OutputSelectProps) {
const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const blocks = useWorkflowStore((state) => state.blocks)
// Get workflow outputs for the dropdown
const workflowOutputs = useMemo(() => {
const outputs: {
id: string
label: string
blockId: string
blockName: string
blockType: string
path: string
}[] = []
if (!workflowId) return outputs
// Process blocks to extract outputs
Object.values(blocks).forEach((block) => {
// Skip starter/start blocks
if (block.type === 'starter') return
const blockName = block.name.replace(/\s+/g, '').toLowerCase()
// Add response outputs
if (block.outputs && typeof block.outputs === 'object') {
const addOutput = (path: string, outputObj: any, prefix = '') => {
const fullPath = prefix ? `${prefix}.${path}` : path
if (typeof outputObj === 'object' && outputObj !== null) {
// For objects, recursively add each property
Object.entries(outputObj).forEach(([key, value]) => {
addOutput(key, value, fullPath)
})
} else {
// Add leaf node as output option
outputs.push({
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name,
blockType: block.type,
path: fullPath,
})
}
}
// Start with the response object
if (block.outputs.response) {
addOutput('response', block.outputs.response)
}
}
})
return outputs
}, [blocks, workflowId])
// Get selected output display name
const selectedOutputDisplayName = useMemo(() => {
if (!selectedOutput) return placeholder
const output = workflowOutputs.find((o) => o.id === selectedOutput)
return output
? `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}`
: placeholder
}, [selectedOutput, workflowOutputs, placeholder])
// Get selected output block info
const selectedOutputInfo = useMemo(() => {
if (!selectedOutput) return null
const output = workflowOutputs.find((o) => o.id === selectedOutput)
if (!output) return null
return {
blockName: output.blockName,
blockId: output.blockId,
blockType: output.blockType,
path: output.path,
}
}, [selectedOutput, workflowOutputs])
// Group output options by block
const groupedOutputs = useMemo(() => {
const groups: Record<string, typeof workflowOutputs> = {}
const blockDistances: Record<string, number> = {}
const edges = useWorkflowStore.getState().edges
// Find the starter block
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
const starterBlockId = starterBlock?.id
// Calculate distances from starter block if it exists
if (starterBlockId) {
// Build an adjacency list for faster traversal
const adjList: Record<string, string[]> = {}
for (const edge of edges) {
if (!adjList[edge.source]) {
adjList[edge.source] = []
}
adjList[edge.source].push(edge.target)
}
// BFS to find distances from starter block
const visited = new Set<string>()
const queue: [string, number][] = [[starterBlockId, 0]] // [nodeId, distance]
while (queue.length > 0) {
const [currentNodeId, distance] = queue.shift()!
if (visited.has(currentNodeId)) continue
visited.add(currentNodeId)
blockDistances[currentNodeId] = distance
// Get all outgoing edges from the adjacency list
const outgoingNodeIds = adjList[currentNodeId] || []
// Add all target nodes to the queue with incremented distance
for (const targetId of outgoingNodeIds) {
queue.push([targetId, distance + 1])
}
}
}
// Group by block name
workflowOutputs.forEach((output) => {
if (!groups[output.blockName]) {
groups[output.blockName] = []
}
groups[output.blockName].push(output)
})
// Convert to array of [blockName, outputs] for sorting
const groupsArray = Object.entries(groups).map(([blockName, outputs]) => {
// Find the blockId for this group (using the first output's blockId)
const blockId = outputs[0]?.blockId
// Get the distance for this block (or default to 0 if not found)
const distance = blockId ? blockDistances[blockId] || 0 : 0
return { blockName, outputs, distance }
})
// Sort by distance (descending - furthest first)
groupsArray.sort((a, b) => b.distance - a.distance)
// Convert back to record
return groupsArray.reduce(
(acc, { blockName, outputs }) => {
acc[blockName] = outputs
return acc
},
{} as Record<string, typeof workflowOutputs>
)
}, [workflowOutputs, blocks])
// Get block color for an output
const getOutputColor = (blockId: string, blockType: string) => {
// Try to get the block's color from its configuration
const blockConfig = getBlock(blockType)
return blockConfig?.bgColor || '#2F55FF' // Default blue if not found
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOutputDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
// Handle output selection
const handleOutputSelection = (value: string) => {
onOutputSelect(value)
setIsOutputDropdownOpen(false)
}
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOutputDropdownOpen(!isOutputDropdownOpen)}
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm rounded-md transition-colors ${
isOutputDropdownOpen
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
disabled={workflowOutputs.length === 0 || disabled}
>
{selectedOutputInfo ? (
<div className="flex items-center gap-2 w-[calc(100%-24px)] overflow-hidden">
<div
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
style={{
backgroundColor: getOutputColor(
selectedOutputInfo.blockId,
selectedOutputInfo.blockType
),
}}
>
<span className="w-3 h-3 text-white font-bold text-xs">
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate">{selectedOutputDisplayName}</span>
</div>
) : (
<span className="truncate w-[calc(100%-24px)]">{selectedOutputDisplayName}</span>
)}
<ChevronDown
className={`h-4 w-4 transition-transform ml-1 flex-shrink-0 ${
isOutputDropdownOpen ? 'rotate-180' : ''
}`}
/>
</button>
{isOutputDropdownOpen && workflowOutputs.length > 0 && (
<div className="absolute z-50 mt-1 pt-1 w-full bg-popover rounded-md border shadow-md overflow-hidden">
<div className="max-h-[240px] overflow-y-auto">
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground border-t first:border-t-0">
{blockName}
</div>
<div>
{outputs.map((output) => (
<button
type="button"
key={output.id}
onClick={() => handleOutputSelection(output.id)}
className={cn(
'flex items-center gap-2 text-sm text-left w-full px-3 py-1.5',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
selectedOutput === output.id && 'bg-accent text-accent-foreground'
)}
>
<div
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
style={{
backgroundColor: getOutputColor(output.blockId, output.blockType),
}}
>
<span className="w-3 h-3 text-white font-bold text-xs">
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className="truncate max-w-[calc(100%-28px)]">{output.path}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -97,6 +97,12 @@ export function useWorkflowExecution() {
}
const executionId = uuidv4()
// Determine if this is a chat execution
// Only true if the execution is initiated from the chat panel
// or through a chat-specific execution path
const isChatExecution = activeTab === 'chat' &&
(workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput)
try {
// Clear any existing state
@@ -154,6 +160,15 @@ export function useWorkflowExecution() {
// Execute workflow
const result = await newExecutor.execute(activeWorkflowId)
// Add metadata about source being chat if applicable
if (isChatExecution) {
// Use type assertion for adding custom metadata
(result as any).metadata = {
...(result.metadata || {}),
source: 'chat'
};
}
// If we're in debug mode, store the execution context for later steps
if (result.metadata?.isDebugSession && result.metadata.context) {
setDebugContext(result.metadata.context)

View File

@@ -1,13 +1,9 @@
import { Chat } from './components/chat/chat'
import { ErrorBoundary } from './components/error'
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
<>
{/* <Chat /> */}
<main className="bg-muted/40 overflow-hidden h-full">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</>
<main className="bg-muted/40 overflow-hidden h-full">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
)
}

View File

@@ -35,6 +35,8 @@ const getTriggerBadgeStyles = (trigger: string) => {
return 'bg-purple-100 dark:bg-purple-950/40 text-purple-700 dark:text-purple-400'
case 'schedule':
return 'bg-green-100 dark:bg-green-950/40 text-green-700 dark:text-green-400'
case 'chat':
return 'bg-amber-100 dark:bg-amber-950/40 text-amber-700 dark:text-amber-400'
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400'
}

View File

@@ -17,12 +17,13 @@ import EmailFooter from './footer'
interface OTPVerificationEmailProps {
otp: string
email?: string
type?: 'sign-in' | 'email-verification' | 'forget-password'
type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access'
chatTitle?: string
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
const getSubjectByType = (type: string) => {
const getSubjectByType = (type: string, chatTitle?: string) => {
switch (type) {
case 'sign-in':
return 'Sign in to Sim Studio'
@@ -30,6 +31,8 @@ const getSubjectByType = (type: string) => {
return 'Verify your email for Sim Studio'
case 'forget-password':
return 'Reset your Sim Studio password'
case 'chat-access':
return `Verification code for ${chatTitle || 'Chat'}`
default:
return 'Verification code for Sim Studio'
}
@@ -39,12 +42,27 @@ export const OTPVerificationEmail = ({
otp,
email = '',
type = 'email-verification',
chatTitle,
}: OTPVerificationEmailProps) => {
// Get a message based on the type
const getMessage = () => {
switch (type) {
case 'sign-in':
return 'Sign in to Sim Studio'
case 'forget-password':
return 'Reset your password for Sim Studio'
case 'chat-access':
return `Access ${chatTitle || 'the chat'}`
default:
return 'Welcome to Sim Studio'
}
}
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>{getSubjectByType(type)}</Preview>
<Preview>{getSubjectByType(type, chatTitle)}</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
@@ -68,14 +86,7 @@ export const OTPVerificationEmail = ({
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>
{type === 'sign-in'
? 'Sign in to'
: type === 'forget-password'
? 'Reset your password for'
: 'Welcome to'}{' '}
Sim Studio!
</Text>
<Text style={baseStyles.paragraph}>{getMessage()}</Text>
<Text style={baseStyles.paragraph}>Your verification code is:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>

View File

@@ -0,0 +1,73 @@
'use client'
import { useState } from 'react'
import { Button } from './button'
import { InputOTP, InputOTPGroup, InputOTPSlot } from './input-otp'
import { Loader2 } from 'lucide-react'
interface OTPInputFormProps {
onSubmit: (otp: string) => void
isLoading?: boolean
error?: string | null
length?: number
}
export function OTPInputForm({
onSubmit,
isLoading = false,
error = null,
length = 6,
}: OTPInputFormProps) {
const [value, setValue] = useState('')
const handleComplete = (value: string) => {
setValue(value)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (value.length === length && !isLoading) {
onSubmit(value)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex justify-center">
<InputOTP
maxLength={length}
value={value}
onChange={setValue}
onComplete={handleComplete}
disabled={isLoading}
pattern="[0-9]*"
inputMode="numeric"
containerClassName="gap-2"
>
<InputOTPGroup>
{Array.from({ length }).map((_, i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-12" />
))}
</InputOTPGroup>
</InputOTP>
</div>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={value.length !== length || isLoading}
>
{isLoading ? (
<div className="flex items-center justify-center">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</div>
) : (
'Verify'
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -1 +0,0 @@
ALTER TABLE "workflow_schedule" ADD COLUMN "timezone" text DEFAULT 'UTC' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -63,13 +63,13 @@ export async function persistLog(log: LogEntry) {
* @param workflowId - The ID of the workflow
* @param executionId - The ID of the execution
* @param result - The execution result
* @param triggerType - The type of trigger (api, webhook, schedule, manual)
* @param triggerType - The type of trigger (api, webhook, schedule, manual, chat)
*/
export async function persistExecutionLogs(
workflowId: string,
executionId: string,
result: ExecutorResult,
triggerType: 'api' | 'webhook' | 'schedule' | 'manual'
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
) {
try {
// Get the workflow record to get the userId
@@ -601,13 +601,13 @@ export async function persistExecutionLogs(
* @param workflowId - The ID of the workflow
* @param executionId - The ID of the execution
* @param error - The error that occurred
* @param triggerType - The type of trigger (api, webhook, schedule, manual)
* @param triggerType - The type of trigger (api, webhook, schedule, manual, chat)
*/
export async function persistExecutionError(
workflowId: string,
executionId: string,
error: Error,
triggerType: 'api' | 'webhook' | 'schedule' | 'manual'
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
) {
try {
const errorPrefix = getTriggerErrorPrefix(triggerType)
@@ -630,7 +630,7 @@ export async function persistExecutionError(
}
// Helper functions for trigger-specific messages
function getTriggerSuccessMessage(triggerType: 'api' | 'webhook' | 'schedule' | 'manual'): string {
function getTriggerSuccessMessage(triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'): string {
switch (triggerType) {
case 'api':
return 'API workflow executed successfully'
@@ -640,12 +640,14 @@ function getTriggerSuccessMessage(triggerType: 'api' | 'webhook' | 'schedule' |
return 'Scheduled workflow executed successfully'
case 'manual':
return 'Manual workflow executed successfully'
case 'chat':
return 'Chat workflow executed successfully'
default:
return 'Workflow executed successfully'
}
}
function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule' | 'manual'): string {
function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'): string {
switch (triggerType) {
case 'api':
return 'API workflow'
@@ -655,6 +657,8 @@ function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule' | 'ma
return 'Scheduled workflow'
case 'manual':
return 'Manual workflow'
case 'chat':
return 'Chat workflow'
default:
return 'Workflow'
}

View File

@@ -15,6 +15,7 @@ const SUSPICIOUS_UA_PATTERNS = [
/^\(\)\s*{/, // Command execution attempt
/\b(sqlmap|nikto|gobuster|dirb|nmap)\b/i // Known scanning tools
]
const BASE_DOMAIN = isDevelopment ? 'localhost:3000' : 'simstudio.ai'
export async function middleware(request: NextRequest) {
// Check for active session
@@ -24,13 +25,34 @@ export async function middleware(request: NextRequest) {
// Check if user has previously logged in by checking localStorage value in cookies
const hasPreviouslyLoggedIn = request.cookies.get('has_logged_in_before')?.value === 'true'
const url = request.nextUrl
const hostname = request.headers.get('host') || ''
// Extract subdomain
const isCustomDomain = hostname !== BASE_DOMAIN &&
!hostname.startsWith('www.') &&
hostname.includes(isDevelopment ? 'localhost' : 'simstudio.ai')
const subdomain = isCustomDomain ? hostname.split('.')[0] : null
// Handle chat subdomains
if (subdomain && isCustomDomain) {
// Special case for API requests from the subdomain
if (url.pathname.startsWith('/api/chat/')) {
// Already an API request, let it go through
return NextResponse.next()
}
// Rewrite to the chat page but preserve the URL in browser
return NextResponse.rewrite(new URL(`/chat/${subdomain}${url.pathname}`, request.url))
}
// Check if the path is exactly /w
if (request.nextUrl.pathname === '/w') {
if (url.pathname === '/w') {
return NextResponse.redirect(new URL('/w/1', request.url))
}
// Handle protected routes that require authentication
if (request.nextUrl.pathname.startsWith('/w/') || request.nextUrl.pathname === '/w') {
if (url.pathname.startsWith('/w/') || url.pathname === '/w') {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
@@ -48,14 +70,14 @@ export async function middleware(request: NextRequest) {
}
// Handle waitlist protection for login and signup in production
if (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup') {
if (url.pathname === '/login' || url.pathname === '/signup') {
// If this is the login page and user has logged in before, allow access
if (hasPreviouslyLoggedIn && request.nextUrl.pathname === '/login') {
return NextResponse.next()
}
// Check for a waitlist token in the URL
const waitlistToken = request.nextUrl.searchParams.get('token')
const waitlistToken = url.searchParams.get('token')
// Validate the token if present
if (waitlistToken) {
@@ -73,19 +95,19 @@ export async function middleware(request: NextRequest) {
}
// Token is invalid, expired, or wrong type - redirect to home
if (request.nextUrl.pathname === '/signup') {
if (url.pathname === '/signup') {
return NextResponse.redirect(new URL('/', request.url))
}
} catch (error) {
logger.error('Token validation error:', error)
// In case of error, redirect signup attempts to home
if (request.nextUrl.pathname === '/signup') {
if (url.pathname === '/signup') {
return NextResponse.redirect(new URL('/', request.url))
}
}
} else {
// If no token for signup, redirect to home
if (request.nextUrl.pathname === '/signup') {
if (url.pathname === '/signup') {
return NextResponse.redirect(new URL('/', request.url))
}
}
@@ -134,6 +156,7 @@ export const config = {
'/w', // Match exactly /w
'/w/:path*', // Match protected routes
'/login',
'/signup'
'/signup',
'/((?!_next/static|_next/image|favicon.ico).*)'
],
}

559
sim/package-lock.json generated
View File

@@ -28,6 +28,7 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.3.3",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.2",
@@ -3957,6 +3958,311 @@
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.3.tgz",
"integrity": "sha512-647Bm/gC/XLM+B3MMkBlzjWRVkRoaB93QwOeD0iRfu029GtagWouaiql+oS1kw7//WuH9fjHUpIjOOnQFQplMw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.3",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-roving-focus": "1.1.7",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz",
"integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",
"integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.7.tgz",
"integrity": "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.0",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.7.tgz",
@@ -4306,6 +4612,21 @@
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
@@ -12817,125 +13138,6 @@
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-darwin-x64": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.2.tgz",
"integrity": "sha512-caR62jNDUCU+qobStO6YJ05p9E+LR0EoXh1EEmyU69cYydsAy7drMcOlUlRtQihM6K6QfvNwJuLhsHcCzNpqtA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.2.tgz",
"integrity": "sha512-fHHXBusURjBmN6VBUtu6/5s7cCeEkuGAb/ZZiGHBLVBXMBy4D5QpM8P33Or8JD1nlOjm/ZT9sEE5HouQ0F+hUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.2.tgz",
"integrity": "sha512-9CF1Pnivij7+M3G74lxr+e9h6o2YNIe7QtExWq1KUK4hsOLTBv6FJikEwCaC3NeYTflzrm69E5UfwEAbV2U9/g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.2.tgz",
"integrity": "sha512-tINV7WmcTUf4oM/eN3Yuu/f8jQ5C6AkueZPKeALs/qfdfX57eNv4Ij7rt0SA6iZ8+fMobVfcFVv664Op0caCCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.2.tgz",
"integrity": "sha512-jf2IseC4WRsGkzeUw/cK3wci9pxR53GlLAt30+y+B+2qAQxMw6WAC3QrANIKxkcoPU3JFh/10uFfmoMDF9JXKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.2.tgz",
"integrity": "sha512-wvg7MlfnaociP7k8lxLX4s2iBJm4BrNiNFhVUY+Yur5yhAJHfkS8qPPeDEUH8rQiY0PX3u/P7Q/wcg6Mv6GSAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.2.tgz",
"integrity": "sha512-D3cNA8NoT3aWISWmo7HF5Eyko/0OdOO+VagkoJuiTk7pyX3P/b+n8XA/MYvyR+xSVcbKn68B1rY9fgqjNISqzQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
@@ -13106,6 +13308,125 @@
}
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-darwin-x64": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.2.tgz",
"integrity": "sha512-caR62jNDUCU+qobStO6YJ05p9E+LR0EoXh1EEmyU69cYydsAy7drMcOlUlRtQihM6K6QfvNwJuLhsHcCzNpqtA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.2.tgz",
"integrity": "sha512-fHHXBusURjBmN6VBUtu6/5s7cCeEkuGAb/ZZiGHBLVBXMBy4D5QpM8P33Or8JD1nlOjm/ZT9sEE5HouQ0F+hUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.2.tgz",
"integrity": "sha512-9CF1Pnivij7+M3G74lxr+e9h6o2YNIe7QtExWq1KUK4hsOLTBv6FJikEwCaC3NeYTflzrm69E5UfwEAbV2U9/g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.2.tgz",
"integrity": "sha512-tINV7WmcTUf4oM/eN3Yuu/f8jQ5C6AkueZPKeALs/qfdfX57eNv4Ij7rt0SA6iZ8+fMobVfcFVv664Op0caCCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.2.tgz",
"integrity": "sha512-jf2IseC4WRsGkzeUw/cK3wci9pxR53GlLAt30+y+B+2qAQxMw6WAC3QrANIKxkcoPU3JFh/10uFfmoMDF9JXKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.2.tgz",
"integrity": "sha512-wvg7MlfnaociP7k8lxLX4s2iBJm4BrNiNFhVUY+Yur5yhAJHfkS8qPPeDEUH8rQiY0PX3u/P7Q/wcg6Mv6GSAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.2.tgz",
"integrity": "sha512-D3cNA8NoT3aWISWmo7HF5Eyko/0OdOO+VagkoJuiTk7pyX3P/b+n8XA/MYvyR+xSVcbKn68B1rY9fgqjNISqzQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/react-email/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",

View File

@@ -42,6 +42,7 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.3.3",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.2",

View File

@@ -3,12 +3,12 @@ import { devtools } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console-logger'
import { useEnvironmentStore } from '../settings/environment/store'
import { useWorkflowStore } from '../workflows/workflow/store'
import { ChatMessage, ChatStore } from './types'
import { CopilotMessage, CopilotStore } from './types'
import { calculateBlockPosition, getNextBlockNumber } from './utils'
const logger = createLogger('Chat Store')
const logger = createLogger('Copilot Store')
export const useChatStore = create<ChatStore>()(
export const useCopilotStore = create<CopilotStore>()(
devtools(
(set, get) => ({
messages: [],
@@ -29,7 +29,7 @@ export const useChatStore = create<ChatStore>()(
}
// User message
const newMessage: ChatMessage = {
const newMessage: CopilotMessage = {
id: crypto.randomUUID(),
role: 'user',
content: content.trim(),
@@ -53,7 +53,7 @@ export const useChatStore = create<ChatStore>()(
messages: [...state.messages, newMessage],
}))
const response = await fetch('/api/chat', {
const response = await fetch('/api/copilot', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -136,7 +136,7 @@ export const useChatStore = create<ChatStore>()(
}))
}
} catch (error) {
logger.error('Chat error:', { error })
logger.error('Copilot error:', { error })
set({
error: error instanceof Error ? error.message : 'Unknown error',
})
@@ -145,9 +145,9 @@ export const useChatStore = create<ChatStore>()(
}
},
clearChat: () => set({ messages: [], error: null }),
clearCopilot: () => set({ messages: [], error: null }),
setError: (error) => set({ error }),
}),
{ name: 'chat-store' }
{ name: 'copilot-store' }
)
)

View File

@@ -1,20 +1,20 @@
export interface ChatMessage {
export interface CopilotMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
}
export interface ChatState {
messages: ChatMessage[]
export interface CopilotState {
messages: CopilotMessage[]
isProcessing: boolean
error: string | null
}
export interface ChatActions {
export interface CopilotActions {
sendMessage: (content: string) => Promise<void>
clearChat: () => void
clearCopilot: () => void
setError: (error: string | null) => void
}
export type ChatStore = ChatState & ChatActions
export type CopilotStore = CopilotState & CopilotActions

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react'
import { createLogger } from '@/lib/logs/console-logger'
import { SubBlockType } from '@/blocks/types'
import { useChatStore } from './chat/store'
import { useCopilotStore } from './copilot/store'
import { useCustomToolsStore } from './custom-tools/store'
import { useExecutionStore } from './execution/store'
import { useNotificationStore } from './notifications/store'
@@ -251,7 +251,7 @@ export {
useEnvironmentStore,
useExecutionStore,
useConsoleStore,
useChatStore,
useCopilotStore,
useCustomToolsStore,
useVariablesStore,
}
@@ -276,7 +276,7 @@ export const resetAllStores = () => {
})
useExecutionStore.getState().reset()
useConsoleStore.setState({ entries: [], isOpen: false })
useChatStore.setState({ messages: [], isProcessing: false, error: null })
useCopilotStore.setState({ messages: [], isProcessing: false, error: null })
useCustomToolsStore.setState({ tools: {} })
useVariablesStore.getState().resetLoaded() // Reset variables store tracking
}
@@ -290,7 +290,7 @@ export const logAllStores = () => {
environment: useEnvironmentStore.getState(),
execution: useExecutionStore.getState(),
console: useConsoleStore.getState(),
chat: useChatStore.getState(),
copilot: useCopilotStore.getState(),
customTools: useCustomToolsStore.getState(),
subBlock: useSubBlockStore.getState(),
variables: useVariablesStore.getState(),