fix(chat-deploy): added new image upload component, fixed some state issues with success view (#842)

* fix(chat-deploy): added new image upload component, fixed some state issues with success view

* cleanup

---------

Co-authored-by: waleedlatif <waleedlatif@waleedlatifs-MacBook-Pro.local>
This commit is contained in:
Waleed Latif
2025-08-01 12:43:40 -07:00
committed by GitHub
parent f327d0479a
commit 9a565f48b1
13 changed files with 830 additions and 130 deletions

View File

@@ -1,18 +1,28 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Card,
CardContent,
ImageUpload,
Input,
Label,
Skeleton,
Textarea,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
import { SubdomainInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/subdomain-input'
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
@@ -32,6 +42,9 @@ interface ChatDeployProps {
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
onPreDeployWorkflow?: () => Promise<void>
showDeleteConfirmation?: boolean
setShowDeleteConfirmation?: (show: boolean) => void
onDeploymentComplete?: () => void
}
interface ExistingChat {
@@ -56,9 +69,27 @@ export function ChatDeploy({
setChatSubmitting,
onValidationChange,
onPreDeployWorkflow,
showDeleteConfirmation: externalShowDeleteConfirmation,
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
onDeploymentComplete,
}: ChatDeployProps) {
const [isLoading, setIsLoading] = useState(false)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [imageUploadError, setImageUploadError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isImageUploading, setIsImageUploading] = useState(false)
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
const [showSuccessView, setShowSuccessView] = useState(false)
// Use external state for delete confirmation if provided
const showDeleteConfirmation =
externalShowDeleteConfirmation !== undefined
? externalShowDeleteConfirmation
: internalShowDeleteConfirmation
const setShowDeleteConfirmation =
externalSetShowDeleteConfirmation || setInternalShowDeleteConfirmation
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
const { deployedUrl, deployChat } = useChatDeployment()
@@ -115,10 +146,18 @@ export function ChatDeploy({
: [],
})
// Set image URL if it exists
if (chatDetail.customizations?.imageUrl) {
setImageUrl(chatDetail.customizations.imageUrl)
}
setImageUploadError(null)
onChatExistsChange?.(true)
}
} else {
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
onChatExistsChange?.(false)
}
}
@@ -150,9 +189,14 @@ export function ChatDeploy({
return
}
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id)
await deployChat(workflowId, formData, deploymentInfo, existingChat?.id, imageUrl)
onChatExistsChange?.(true)
setShowSuccessView(true)
// Fetch the updated chat data immediately after deployment
// This ensures existingChat is available when switching back to edit mode
await fetchExistingChat()
} catch (error: any) {
if (error.message?.includes('subdomain')) {
setError('subdomain', error.message)
@@ -164,111 +208,263 @@ export function ChatDeploy({
}
}
const handleDelete = async () => {
if (!existingChat || !existingChat.id) return
try {
setIsDeleting(true)
const response = await fetch(`/api/chat/edit/${existingChat.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
// Update state
setExistingChat(null)
setImageUrl(null)
setImageUploadError(null)
onChatExistsChange?.(false)
// Notify parent of successful deletion
onDeploymentComplete?.()
} catch (error: any) {
logger.error('Failed to delete chat:', error)
setError('general', error.message || 'An unexpected error occurred while deleting')
} finally {
setIsDeleting(false)
setShowDeleteConfirmation(false)
}
}
if (isLoading) {
return <LoadingSkeleton />
}
if (deployedUrl) {
return <SuccessView deployedUrl={deployedUrl} existingChat={existingChat} />
if (deployedUrl && showSuccessView) {
return (
<>
<div id='chat-deploy-form'>
<SuccessView
deployedUrl={deployedUrl}
existingChat={existingChat}
onDelete={() => setShowDeleteConfirmation(true)}
onUpdate={() => setShowSuccessView(false)}
/>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{existingChat?.subdomain}.{getEmailDomain()}
</span>
.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className='bg-destructive hover:bg-destructive/90'
>
{isDeleting ? (
<span className='flex items-center'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Deleting...
</span>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
return (
<form
id='chat-deploy-form'
ref={formRef}
onSubmit={handleSubmit}
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
{errors.general && (
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>{errors.general}</AlertDescription>
</Alert>
)}
<>
<form
id='chat-deploy-form'
ref={formRef}
onSubmit={handleSubmit}
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
{errors.general && (
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>{errors.general}</AlertDescription>
</Alert>
)}
<div className='space-y-4'>
<SubdomainInput
value={formData.subdomain}
onChange={(value) => updateField('subdomain', value)}
originalSubdomain={existingChat?.subdomain}
disabled={chatSubmitting}
onValidationChange={setIsSubdomainValid}
/>
<div className='space-y-2'>
<Label htmlFor='title' className='font-medium text-sm'>
Chat Title
</Label>
<Input
id='title'
placeholder='Customer Support Assistant'
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
<div className='space-y-4'>
<SubdomainInput
value={formData.subdomain}
onChange={(value) => updateField('subdomain', value)}
originalSubdomain={existingChat?.subdomain || undefined}
disabled={chatSubmitting}
onValidationChange={setIsSubdomainValid}
isEditingExisting={!!existingChat}
/>
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
</div>
<div className='space-y-2'>
<Label htmlFor='description' className='font-medium text-sm'>
Description (Optional)
</Label>
<Textarea
id='description'
placeholder='A brief description of what this chat does'
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
rows={3}
disabled={chatSubmitting}
/>
</div>
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Output</Label>
<Card className='rounded-md border-input shadow-none'>
<CardContent className='p-1'>
<OutputSelect
workflowId={workflowId}
selectedOutputs={formData.selectedOutputBlocks}
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
placeholder='Select which block outputs to use'
disabled={chatSubmitting}
/>
</CardContent>
</Card>
{errors.outputBlocks && <p className='text-destructive text-sm'>{errors.outputBlocks}</p>}
<p className='mt-2 text-muted-foreground text-xs'>
Select which block's output to return to the user in the chat interface
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='title' className='font-medium text-sm'>
Chat Title
</Label>
<Input
id='title'
placeholder='Customer Support Assistant'
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
disabled={chatSubmitting}
/>
{errors.title && <p className='text-destructive text-sm'>{errors.title}</p>}
</div>
<div className='space-y-2'>
<Label htmlFor='description' className='font-medium text-sm'>
Description (Optional)
</Label>
<Textarea
id='description'
placeholder='A brief description of what this chat does'
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
rows={3}
disabled={chatSubmitting}
/>
</div>
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Output</Label>
<Card className='rounded-md border-input shadow-none'>
<CardContent className='p-1'>
<OutputSelect
workflowId={workflowId}
selectedOutputs={formData.selectedOutputBlocks}
onOutputSelect={(values) => updateField('selectedOutputBlocks', values)}
placeholder='Select which block outputs to use'
disabled={chatSubmitting}
/>
</CardContent>
</Card>
{errors.outputBlocks && (
<p className='text-destructive text-sm'>{errors.outputBlocks}</p>
)}
<p className='mt-2 text-muted-foreground text-xs'>
Select which block's output to return to the user in the chat interface
</p>
</div>
<AuthSelector
authType={formData.authType}
password={formData.password}
emails={formData.emails}
onAuthTypeChange={(type) => updateField('authType', type)}
onPasswordChange={(password) => updateField('password', password)}
onEmailsChange={(emails) => updateField('emails', emails)}
disabled={chatSubmitting}
isExistingChat={!!existingChat}
error={errors.password || errors.emails}
/>
<div className='space-y-2'>
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
Welcome Message
</Label>
<Textarea
id='welcomeMessage'
placeholder='Enter a welcome message for your chat'
value={formData.welcomeMessage}
onChange={(e) => updateField('welcomeMessage', e.target.value)}
rows={3}
<AuthSelector
authType={formData.authType}
password={formData.password}
emails={formData.emails}
onAuthTypeChange={(type) => updateField('authType', type)}
onPasswordChange={(password) => updateField('password', password)}
onEmailsChange={(emails) => updateField('emails', emails)}
disabled={chatSubmitting}
isExistingChat={!!existingChat}
error={errors.password || errors.emails}
/>
<div className='space-y-2'>
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
Welcome Message
</Label>
<Textarea
id='welcomeMessage'
placeholder='Enter a welcome message for your chat'
value={formData.welcomeMessage}
onChange={(e) => updateField('welcomeMessage', e.target.value)}
rows={3}
disabled={chatSubmitting}
/>
<p className='text-muted-foreground text-xs'>
This message will be displayed when users first open the chat
</p>
</div>
{/* Image Upload Section */}
<div className='space-y-2'>
<Label className='font-medium text-sm'>Chat Logo</Label>
<ImageUpload
value={imageUrl}
onUpload={(url) => {
setImageUrl(url)
setImageUploadError(null) // Clear error on successful upload
}}
onError={setImageUploadError}
onUploadStart={setIsImageUploading}
disabled={chatSubmitting}
uploadToServer={true}
height='h-32'
hideHeader={true}
/>
{imageUploadError && <p className='text-destructive text-sm'>{imageUploadError}</p>}
{!imageUrl && !isImageUploading && (
<p className='text-muted-foreground text-xs'>
Upload a logo for your chat (PNG, JPEG - max 5MB)
</p>
)}
</div>
{/* Hidden delete trigger button for modal footer */}
<button
type='button'
data-delete-trigger
onClick={() => setShowDeleteConfirmation(true)}
style={{ display: 'none' }}
/>
<p className='text-muted-foreground text-xs'>
This message will be displayed when users first open the chat
</p>
</div>
</div>
</form>
</form>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirmation} onOpenChange={setShowDeleteConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{existingChat?.subdomain}.{getEmailDomain()}
</span>
.
<span className='mt-2 block'>
All users will lose access immediately, and this action cannot be undone.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className='bg-destructive hover:bg-destructive/90'
>
{isDeleting ? (
<span className='flex items-center'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Deleting...
</span>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -10,6 +10,7 @@ interface SubdomainInputProps {
originalSubdomain?: string
disabled?: boolean
onValidationChange?: (isValid: boolean) => void
isEditingExisting?: boolean
}
const getDomainSuffix = (() => {
@@ -23,8 +24,13 @@ export function SubdomainInput({
originalSubdomain,
disabled = false,
onValidationChange,
isEditingExisting = false,
}: SubdomainInputProps) {
const { isChecking, error, isValid } = useSubdomainValidation(value, originalSubdomain)
const { isChecking, error, isValid } = useSubdomainValidation(
value,
originalSubdomain,
isEditingExisting
)
// Notify parent of validation changes
useEffect(() => {

View File

@@ -18,9 +18,11 @@ interface ExistingChat {
interface SuccessViewProps {
deployedUrl: string
existingChat: ExistingChat | null
onDelete?: () => void
onUpdate?: () => void
}
export function SuccessView({ deployedUrl, existingChat }: SuccessViewProps) {
export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: SuccessViewProps) {
const url = new URL(deployedUrl)
const hostname = url.hostname
const isDevelopmentUrl = hostname.includes('localhost')
@@ -71,6 +73,10 @@ export function SuccessView({ deployedUrl, existingChat }: SuccessViewProps) {
</a>
</p>
</div>
{/* Hidden triggers for modal footer buttons */}
<button type='button' data-delete-trigger onClick={onDelete} style={{ display: 'none' }} />
<button type='button' data-update-trigger onClick={onUpdate} style={{ display: 'none' }} />
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useChatDeployment } from './use-chat-deployment'
import { useChatForm } from './use-chat-form'
import { useImageUpload } from './use-image-upload'
export { useChatDeployment, useChatForm }
export { useChatDeployment, useChatForm, useImageUpload }
export type { ChatFormData, ChatFormErrors } from './use-chat-form'

View File

@@ -23,6 +23,7 @@ const chatSchema = z.object({
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email']).default('public'),
password: z.string().optional(),
@@ -50,7 +51,8 @@ export function useChatDeployment() {
workflowId: string,
formData: ChatFormData,
deploymentInfo: { apiKey: string } | null,
existingChatId?: string
existingChatId?: string,
imageUrl?: string | null
) => {
setState({ isLoading: true, error: null, deployedUrl: null })
@@ -79,6 +81,7 @@ export function useChatDeployment() {
customizations: {
primaryColor: '#802FFF',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,

View File

@@ -0,0 +1,184 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ImageUpload')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg']
interface UseImageUploadProps {
onUpload?: (url: string | null) => void
onError?: (error: string) => void
uploadToServer?: boolean
}
export function useImageUpload({
onUpload,
onError,
uploadToServer = false,
}: UseImageUploadProps = {}) {
const previewRef = useRef<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File "${file.name}" is too large. Maximum size is 5MB.`
}
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
return `File "${file.name}" is not a supported image format. Please use PNG or JPEG.`
}
return null
}, [])
const handleThumbnailClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const uploadFileToServer = useCallback(async (file: File): Promise<string> => {
try {
// First, try to get a pre-signed URL for direct upload with chat type
const presignedResponse = await fetch('/api/files/presigned?type=chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
if (presignedResponse.ok) {
// Use direct upload with presigned URL
const presignedData = await presignedResponse.json()
// Log the presigned URL response for debugging
logger.info('Presigned URL response:', presignedData)
// Upload directly to storage provider
const uploadHeaders: Record<string, string> = {
'Content-Type': file.type,
}
// Add any additional headers from the presigned response (for Azure Blob)
if (presignedData.uploadHeaders) {
Object.assign(uploadHeaders, presignedData.uploadHeaders)
}
const uploadResponse = await fetch(presignedData.uploadUrl, {
method: 'PUT',
body: file,
headers: uploadHeaders,
})
logger.info(`Upload response status: ${uploadResponse.status}`)
logger.info(
'Upload response headers:',
Object.fromEntries(uploadResponse.headers.entries())
)
if (!uploadResponse.ok) {
const responseText = await uploadResponse.text()
logger.error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
throw new Error(`Direct upload failed: ${uploadResponse.status} - ${responseText}`)
}
// Use the file info returned from the presigned URL endpoint
const publicUrl = presignedData.fileInfo.path
logger.info(`Image uploaded successfully via direct upload: ${publicUrl}`)
return publicUrl
}
// Fallback to traditional upload through API route
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(errorData.error || `Failed to upload file: ${response.status}`)
}
const data = await response.json()
const publicUrl = data.path
logger.info(`Image uploaded successfully via server upload: ${publicUrl}`)
return publicUrl
} catch (error) {
throw new Error(error instanceof Error ? error.message : 'Failed to upload image')
}
}, [])
const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
// Validate file first
const validationError = validateFile(file)
if (validationError) {
onError?.(validationError)
return
}
setFileName(file.name)
// Always create preview URL
const previewUrl = URL.createObjectURL(file)
setPreviewUrl(previewUrl)
previewRef.current = previewUrl
if (uploadToServer) {
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
onUpload?.(serverUrl)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to upload image'
onError?.(errorMessage)
} finally {
setIsUploading(false)
}
} else {
onUpload?.(previewUrl)
}
}
},
[onUpload, onError, uploadToServer, uploadFileToServer, validateFile]
)
const handleRemove = useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl)
}
setPreviewUrl(null)
setFileName(null)
previewRef.current = null
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
onUpload?.(null) // Notify parent that image was removed
}, [previewUrl, onUpload])
useEffect(() => {
return () => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
}
}, [])
return {
previewUrl,
fileName,
fileInputRef,
handleThumbnailClick,
handleFileChange,
handleRemove,
isUploading,
}
}

View File

@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from 'react'
export function useSubdomainValidation(subdomain: string, originalSubdomain?: string) {
export function useSubdomainValidation(
subdomain: string,
originalSubdomain?: string,
isEditingExisting?: boolean
) {
const [isChecking, setIsChecking] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isValid, setIsValid] = useState(false)
@@ -18,12 +22,20 @@ export function useSubdomainValidation(subdomain: string, originalSubdomain?: st
setIsValid(false)
setIsChecking(false)
// Skip validation if empty or same as original
// Skip validation if empty
if (!subdomain.trim()) {
return
}
if (subdomain === originalSubdomain) {
// Skip validation if same as original (existing deployment)
if (originalSubdomain && subdomain === originalSubdomain) {
setIsValid(true)
return
}
// If we're editing an existing deployment but originalSubdomain isn't available yet,
// assume it's valid and wait for the data to load
if (isEditingExisting && !originalSubdomain) {
setIsValid(true)
return
}
@@ -64,7 +76,7 @@ export function useSubdomainValidation(subdomain: string, originalSubdomain?: st
clearTimeout(timeoutRef.current)
}
}
}, [subdomain, originalSubdomain])
}, [subdomain, originalSubdomain, isEditingExisting])
return { isChecking, error, isValid }
}

View File

@@ -378,7 +378,13 @@ export function DeployModal({
const handleChatFormSubmit = () => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
form.requestSubmit()
// Check if we're in success view and need to trigger update
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
if (updateTrigger) {
updateTrigger.click()
} else {
form.requestSubmit()
}
}
}
@@ -474,6 +480,7 @@ export function DeployModal({
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
onPreDeployWorkflow={handleWorkflowPreDeploy}
onDeploymentComplete={handleCloseModal}
/>
)}
</div>
@@ -516,29 +523,57 @@ export function DeployModal({
Cancel
</Button>
<Button
type='button'
onClick={handleChatFormSubmit}
disabled={chatSubmitting || !isChatFormValid}
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'
<div className='flex gap-2'>
{chatExists && (
<Button
type='button'
onClick={() => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const deleteButton = form.querySelector(
'[data-delete-trigger]'
) as HTMLButtonElement
if (deleteButton) {
deleteButton.click()
}
}
}}
disabled={chatSubmitting}
className={cn(
'gap-2 font-medium',
'bg-red-500 hover:bg-red-600',
'shadow-[0_0_0_0_rgb(239,68,68)] hover:shadow-[0_0_0_4px_rgba(239,68,68,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-red-500 disabled:hover:shadow-none'
)}
>
Delete
</Button>
)}
>
{chatSubmitting ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Deploying...
</>
) : chatExists ? (
'Update'
) : (
'Deploy Chat'
)}
</Button>
<Button
type='button'
onClick={handleChatFormSubmit}
disabled={chatSubmitting || !isChatFormValid}
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='mr-1.5 h-3.5 w-3.5 animate-spin' />
Deploying...
</>
) : chatExists ? (
'Update'
) : (
'Deploy Chat'
)}
</Button>
</div>
</div>
)}
</DialogContent>

View File

@@ -0,0 +1,230 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { ImagePlus, Loader2, Trash2, Upload, X } from 'lucide-react'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useImageUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-image-upload'
interface ImageUploadProps {
onUpload?: (url: string | null) => void
onError?: (error: string) => void
onUploadStart?: (isUploading: boolean) => void
title?: string
description?: string
height?: string
className?: string
disabled?: boolean
acceptedFormats?: string[]
uploadToServer?: boolean
value?: string | null
hideHeader?: boolean
}
export function ImageUpload({
onUpload,
onError,
onUploadStart,
title = 'Logo Image',
description = 'PNG or JPEG (max 5MB)',
height = 'h-64',
className,
disabled = false,
acceptedFormats = ['image/png', 'image/jpeg', 'image/jpg'],
uploadToServer = false,
value,
hideHeader = false,
}: ImageUploadProps) {
const {
previewUrl,
fileName,
fileInputRef,
handleThumbnailClick,
handleFileChange,
handleRemove,
isUploading,
} = useImageUpload({
onUpload,
onError,
uploadToServer,
})
const [isDragging, setIsDragging] = useState(false)
// Use value prop if provided, otherwise use internal previewUrl
const displayUrl = value || previewUrl
const isDisabled = disabled || isUploading
// Notify parent when upload status changes
useEffect(() => {
onUploadStart?.(isUploading)
}, [isUploading, onUploadStart])
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (isDisabled) return
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const fakeEvent = {
target: {
files: [file],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
handleFileChange(fakeEvent)
}
},
[isDisabled, handleFileChange]
)
return (
<div
className={cn(
hideHeader
? 'w-full space-y-4'
: 'w-full max-w-md space-y-6 rounded-xl border border-border bg-card p-6 shadow-sm',
className
)}
>
{!hideHeader && (
<div className='space-y-2'>
<h3 className='font-medium text-lg'>{title}</h3>
<p className='text-muted-foreground text-sm'>{description}</p>
</div>
)}
<Input
type='file'
accept={acceptedFormats.join(',')}
className='hidden'
ref={fileInputRef}
onChange={handleFileChange}
disabled={isDisabled}
/>
{!displayUrl ? (
<div
onClick={
isDisabled
? undefined
: (e) => {
e.preventDefault()
e.stopPropagation()
handleThumbnailClick()
}
}
onDragOver={isDisabled ? undefined : handleDragOver}
onDragEnter={isDisabled ? undefined : handleDragEnter}
onDragLeave={isDisabled ? undefined : handleDragLeave}
onDrop={isDisabled ? undefined : handleDrop}
className={cn(
'flex cursor-pointer flex-col items-center justify-center gap-4 rounded-lg border-2 border-muted-foreground/25 border-dashed bg-muted/50 transition-colors',
height,
!isDisabled && 'hover:bg-muted',
isDragging && !isDisabled && 'border-primary/50 bg-primary/5',
isDisabled && 'cursor-not-allowed opacity-50'
)}
>
<div className='rounded-full bg-background p-3 shadow-sm'>
{isUploading ? (
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
) : (
<ImagePlus className='h-6 w-6 text-muted-foreground' />
)}
</div>
<div className='text-center'>
<p className='font-medium text-sm'>
{isUploading ? 'Uploading...' : 'Click to select'}
</p>
<p className='text-muted-foreground text-xs'>
{isUploading ? 'Please wait' : 'or drag and drop file here'}
</p>
</div>
</div>
) : displayUrl ? (
<div className='relative'>
<div className={cn('group relative overflow-hidden rounded-lg border', height)}>
<Image
src={displayUrl}
alt='Preview'
fill
className='object-cover transition-transform duration-300 group-hover:scale-105'
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
<div className='absolute inset-0 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100' />
<div className='absolute inset-0 flex items-center justify-center gap-2 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
type='button'
size='sm'
variant='secondary'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleThumbnailClick()
}}
className='h-9 w-9 p-0'
disabled={isDisabled}
>
<Upload className='h-4 w-4' />
</Button>
<Button
type='button'
size='sm'
variant='destructive'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleRemove()
}}
className='h-9 w-9 p-0'
disabled={isDisabled}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
{fileName && (
<div className='mt-2 flex items-center gap-2 text-muted-foreground text-sm'>
<span className='truncate'>{fileName}</span>
<button
type='button'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleRemove()
}}
className='ml-auto rounded-full p-1 hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
disabled={isDisabled}
>
<X className='h-4 w-4' />
</button>
</div>
)}
</div>
) : null}
</div>
)
}

View File

@@ -81,6 +81,7 @@ export {
useFormField,
} from './form'
export { formatDisplayText } from './formatted-text'
export { ImageUpload } from './image-upload'
export { Input } from './input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp'
export { OTPInputForm } from './input-otp-form'

View File

@@ -8,10 +8,38 @@ import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/
const nextConfig: NextConfig = {
devIndicators: false,
images: {
domains: [
'avatars.githubusercontent.com',
'oaidalleapiprodscus.blob.core.windows.net',
'api.stability.ai',
remotePatterns: [
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
{
protocol: 'https',
hostname: 'api.stability.ai',
},
// Azure Blob Storage
{
protocol: 'https',
hostname: '*.blob.core.windows.net',
},
// AWS S3 - various regions and bucket configurations
{
protocol: 'https',
hostname: '*.s3.amazonaws.com',
},
{
protocol: 'https',
hostname: '*.s3.*.amazonaws.com',
},
// Custom domain for file storage if configured
...(env.NEXT_PUBLIC_BLOB_BASE_URL
? [
{
protocol: 'https' as const,
hostname: new URL(env.NEXT_PUBLIC_BLOB_BASE_URL).hostname,
},
]
: []),
],
},
typescript: {

View File

@@ -107,7 +107,6 @@ Object.entries(providers).forEach(([id, provider]) => {
export function updateOllamaProviderModels(models: string[]): void {
updateOllamaModelsInDefinitions(models)
providers.ollama.models = getProviderModelsFromDefinitions('ollama')
logger.info('Updated Ollama provider models', { models })
}
export function getBaseModelProviders(): Record<string, ProviderId> {

View File

@@ -8,7 +8,6 @@ const logger = createLogger('OllamaStore')
export const useOllamaStore = create<OllamaStore>((set) => ({
models: [],
setModels: (models) => {
logger.info('Updating Ollama models', { models })
set({ models })
// Update the providers when models change
updateOllamaProviderModels(models)