mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-14 17:37:55 -05:00
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:
319
sim/app/api/chat/[subdomain]/otp/route.ts
Normal file
319
sim/app/api/chat/[subdomain]/otp/route.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
221
sim/app/api/chat/[subdomain]/route.ts
Normal file
221
sim/app/api/chat/[subdomain]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
303
sim/app/api/chat/edit/[id]/route.ts
Normal file
303
sim/app/api/chat/edit/[id]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
54
sim/app/api/chat/subdomain-check/route.ts
Normal file
54
sim/app/api/chat/subdomain-check/route.ts
Normal 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
469
sim/app/api/chat/utils.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
216
sim/app/api/copilot/route.ts
Normal file
216
sim/app/api/copilot/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
49
sim/app/api/workflows/[id]/chat/status/route.ts
Normal file
49
sim/app/api/workflows/[id]/chat/status/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
693
sim/app/chat/[subdomain]/components/chat-client.tsx
Normal file
693
sim/app/chat/[subdomain]/components/chat-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
sim/app/chat/[subdomain]/page.tsx
Normal file
10
sim/app/chat/[subdomain]/page.tsx
Normal 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} />
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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't store the complete key. You won'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
73
sim/components/ui/input-otp-form.tsx
Normal file
73
sim/components/ui/input-otp-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
sim/components/ui/radio-group.tsx
Normal file
44
sim/components/ui/radio-group.tsx
Normal 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 }
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
559
sim/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user