feat(deploy-chat): added a logo upload for the chat, incr font size

This commit is contained in:
waleedlatif
2025-07-31 23:52:07 -07:00
parent c2593900d4
commit bab4b9f041
15 changed files with 513 additions and 26 deletions

View File

@@ -784,6 +784,10 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
bucket: 'test-s3-kb-bucket',
region: 'us-east-1',
},
S3_CHAT_CONFIG: {
bucket: 'test-s3-chat-bucket',
region: 'us-east-1',
},
BLOB_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
@@ -794,6 +798,11 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
accountKey: 'testkey',
containerName: 'test-kb-container',
},
BLOB_CHAT_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
containerName: 'test-chat-container',
},
}))
vi.doMock('@aws-sdk/client-s3', () => ({
@@ -809,7 +818,7 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
}),
}))
} else if (provider === 'blob') {
const baseUrl = presignedUrl.replace('?sas-token-string', '')
const baseUrl = 'https://testaccount.blob.core.windows.net/test-container'
const mockBlockBlobClient = {
url: baseUrl,
}
@@ -841,6 +850,11 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
accountKey: 'testkey',
containerName: 'test-kb-container',
},
BLOB_CHAT_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
containerName: 'test-chat-container',
},
}))
vi.doMock('@azure/storage-blob', () => ({

View File

@@ -29,6 +29,7 @@ const chatUpdateSchema = z.object({
.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
})
.optional(),
authType: z.enum(['public', 'password', 'email']).optional(),

View File

@@ -27,6 +27,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(),

View File

@@ -204,6 +204,32 @@ describe('/api/files/presigned', () => {
expect(data.directUploadSupported).toBe(true)
})
it('should generate chat S3 presigned URL with chat prefix and direct path', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('@/app/api/files/presigned/route')
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'chat-logo.png',
contentType: 'image/png',
fileSize: 4096,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/)
expect(data.fileInfo.path).toMatch(/^https:\/\/.*\.s3\..*\.amazonaws\.com\/chat\//)
expect(data.directUploadSupported).toBe(true)
})
it('should generate Azure Blob presigned URL successfully', async () => {
setupFileApiMocks({
cloudEnabled: true,
@@ -225,7 +251,9 @@ describe('/api/files/presigned', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(data.presignedUrl).toContain('https://example.com/presigned-url')
expect(data.presignedUrl).toContain(
'https://testaccount.blob.core.windows.net/test-container'
)
expect(data.presignedUrl).toContain('sas-token-string')
expect(data.fileInfo).toMatchObject({
path: expect.stringContaining('/api/files/serve/blob/'),
@@ -243,6 +271,41 @@ describe('/api/files/presigned', () => {
})
})
it('should generate chat Azure Blob presigned URL with chat prefix and direct path', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 'blob',
})
const { POST } = await import('@/app/api/files/presigned/route')
const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', {
method: 'POST',
body: JSON.stringify({
fileName: 'chat-logo.png',
contentType: 'image/png',
fileSize: 4096,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^chat\/.*chat-logo\.png$/)
expect(data.fileInfo.path).toContain(
'https://testaccount.blob.core.windows.net/test-container'
)
expect(data.directUploadSupported).toBe(true)
expect(data.uploadHeaders).toMatchObject({
'x-ms-blob-type': 'BlockBlob',
'x-ms-blob-content-type': 'image/png',
'x-ms-meta-originalname': expect.any(String),
'x-ms-meta-uploadedat': '2024-01-01T00:00:00.000Z',
'x-ms-meta-purpose': 'chat',
})
})
it('should return error for unknown storage provider', async () => {
// For unknown provider, we'll need to mock manually since our helper doesn't support it
vi.doMock('@/lib/uploads', () => ({

View File

@@ -6,7 +6,14 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { getBlobServiceClient } from '@/lib/uploads/blob/blob-client'
import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-client'
import { BLOB_CONFIG, BLOB_KB_CONFIG, S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup'
import {
BLOB_CHAT_CONFIG,
BLOB_CONFIG,
BLOB_KB_CONFIG,
S3_CHAT_CONFIG,
S3_CONFIG,
S3_KB_CONFIG,
} from '@/lib/uploads/setup'
import { createErrorResponse, createOptionsResponse } from '@/app/api/files/utils'
const logger = createLogger('PresignedUploadAPI')
@@ -17,7 +24,7 @@ interface PresignedUrlRequest {
fileSize: number
}
type UploadType = 'general' | 'knowledge-base'
type UploadType = 'general' | 'knowledge-base' | 'chat'
class PresignedUrlError extends Error {
constructor(
@@ -72,7 +79,11 @@ export async function POST(request: NextRequest) {
const uploadTypeParam = request.nextUrl.searchParams.get('type')
const uploadType: UploadType =
uploadTypeParam === 'knowledge-base' ? 'knowledge-base' : 'general'
uploadTypeParam === 'knowledge-base'
? 'knowledge-base'
: uploadTypeParam === 'chat'
? 'chat'
: 'general'
if (!isUsingCloudStorage()) {
throw new StorageConfigError(
@@ -118,14 +129,19 @@ async function handleS3PresignedUrl(
uploadType: UploadType
) {
try {
const config = uploadType === 'knowledge-base' ? S3_KB_CONFIG : S3_CONFIG
const config =
uploadType === 'knowledge-base'
? S3_KB_CONFIG
: uploadType === 'chat'
? S3_CHAT_CONFIG
: S3_CONFIG
if (!config.bucket || !config.region) {
throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`)
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : ''
const prefix = uploadType === 'knowledge-base' ? 'kb/' : uploadType === 'chat' ? 'chat/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
@@ -137,6 +153,8 @@ async function handleS3PresignedUrl(
if (uploadType === 'knowledge-base') {
metadata.purpose = 'knowledge-base'
} else if (uploadType === 'chat') {
metadata.purpose = 'chat'
}
const command = new PutObjectCommand({
@@ -156,14 +174,22 @@ async function handleS3PresignedUrl(
)
}
const servePath = `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}`
// For chat images, use direct S3 URLs since they need to be permanently accessible
// For other files, use serve path for access control
const finalPath =
uploadType === 'chat'
? `https://${config.bucket}.s3.${config.region}.amazonaws.com/${uniqueKey}`
: `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated ${uploadType} S3 presigned URL for ${fileName} (${uniqueKey})`)
logger.info(`Presigned URL: ${presignedUrl}`)
logger.info(`Final path: ${finalPath}`)
return NextResponse.json({
presignedUrl,
uploadUrl: presignedUrl, // Make sure we're returning the uploadUrl field
fileInfo: {
path: servePath,
path: finalPath,
key: uniqueKey,
name: fileName,
size: fileSize,
@@ -187,7 +213,12 @@ async function handleBlobPresignedUrl(
uploadType: UploadType
) {
try {
const config = uploadType === 'knowledge-base' ? BLOB_KB_CONFIG : BLOB_CONFIG
const config =
uploadType === 'knowledge-base'
? BLOB_KB_CONFIG
: uploadType === 'chat'
? BLOB_CHAT_CONFIG
: BLOB_CONFIG
if (
!config.accountName ||
@@ -198,7 +229,7 @@ async function handleBlobPresignedUrl(
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : ''
const prefix = uploadType === 'knowledge-base' ? 'kb/' : uploadType === 'chat' ? 'chat/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
const blobServiceClient = getBlobServiceClient()
@@ -231,7 +262,12 @@ async function handleBlobPresignedUrl(
const presignedUrl = `${blockBlobClient.url}?${sasToken}`
const servePath = `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}`
// For chat images, use direct Blob URLs since they need to be permanently accessible
// For other files, use serve path for access control
const finalPath =
uploadType === 'chat'
? blockBlobClient.url
: `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated ${uploadType} Azure Blob presigned URL for ${fileName} (${uniqueKey})`)
@@ -244,12 +280,14 @@ async function handleBlobPresignedUrl(
if (uploadType === 'knowledge-base') {
uploadHeaders['x-ms-meta-purpose'] = 'knowledge-base'
} else if (uploadType === 'chat') {
uploadHeaders['x-ms-meta-purpose'] = 'chat'
}
return NextResponse.json({
presignedUrl,
fileInfo: {
path: servePath,
path: finalPath,
key: uniqueKey,
name: fileName,
size: fileSize,

View File

@@ -30,6 +30,7 @@ interface ChatConfig {
customizations: {
primaryColor?: string
logoUrl?: string
imageUrl?: string
welcomeMessage?: string
headerText?: string
}

View File

@@ -8,6 +8,7 @@ interface ChatHeaderProps {
customizations?: {
headerText?: string
logoUrl?: string
imageUrl?: string
primaryColor?: string
}
} | null
@@ -16,18 +17,58 @@ interface ChatHeaderProps {
export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
const primaryColor = chatConfig?.customizations?.primaryColor || '#701FFC'
const customImage = chatConfig?.customizations?.imageUrl || chatConfig?.customizations?.logoUrl
return (
<div className='flex items-center justify-between bg-background/95 px-5 py-3 pt-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6 md:pt-3'>
<div className='flex items-center gap-3'>
{chatConfig?.customizations?.logoUrl && (
<div className='flex items-center justify-between bg-background/95 px-6 py-4 pt-6 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-8 md:pt-4'>
<div className='flex items-center gap-4'>
{customImage ? (
<img
src={chatConfig.customizations.logoUrl}
src={customImage}
alt={`${chatConfig?.title || 'Chat'} logo`}
className='h-7 w-7 rounded-md object-contain'
className='h-12 w-12 rounded-md object-cover'
/>
) : (
// Default Sim Studio logo when no custom image is provided
<div
className='flex h-12 w-12 items-center justify-center rounded-md'
style={{ backgroundColor: primaryColor }}
>
<svg
width='20'
height='20'
viewBox='0 0 50 50'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z'
fill={primaryColor}
stroke='white'
strokeWidth='3.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z'
fill={primaryColor}
stroke='white'
strokeWidth='4'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398'
stroke='white'
strokeWidth='4'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle cx='25' cy='11' r='2' fill={primaryColor} />
</svg>
</div>
)}
<h2 className='font-medium text-base'>
<h2 className='font-medium text-lg'>
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
</h2>
</div>
@@ -39,8 +80,8 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
target='_blank'
rel='noopener noreferrer'
>
<GithubIcon className='h-[18px] w-[18px]' />
<span className='hidden font-medium text-xs sm:inline-block'>{starCount}</span>
<GithubIcon className='h-5 w-5' />
<span className='hidden font-medium text-sm sm:inline-block'>{starCount}</span>
</a>
<a
href='https://sim.ai'
@@ -49,7 +90,7 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
className='flex items-center rounded-md p-1 text-foreground/80 transition-colors duration-200 hover:text-foreground/100'
>
<div
className='flex h-6 w-6 items-center justify-center rounded-md'
className='flex h-7 w-7 items-center justify-center rounded-md'
style={{ backgroundColor: primaryColor }}
>
<svg

View File

@@ -56,7 +56,7 @@ export const ClientChatMessage = memo(
<div className='mx-auto max-w-3xl'>
<div className='flex justify-end'>
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 dark:bg-gray-600'>
<div className='whitespace-pre-wrap break-words text-base text-gray-800 leading-relaxed dark:text-gray-100'>
<div className='whitespace-pre-wrap break-words text-gray-800 text-lg leading-relaxed dark:text-gray-100'>
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
) : (
@@ -103,7 +103,7 @@ export const ClientChatMessage = memo(
if (item.type === 'text' && item.content.trim()) {
return (
<div key={`text-${index}`}>
<div className='break-words text-base'>
<div className='break-words text-lg'>
<EnhancedMarkdownRenderer content={item.content} />
</div>
</div>
@@ -115,7 +115,7 @@ export const ClientChatMessage = memo(
) : (
/* Fallback for empty content or no inline content */
<div>
<div className='break-words text-base'>
<div className='break-words text-lg'>
{isJsonObject ? (
<pre className='text-gray-800 dark:text-gray-100'>
{JSON.stringify(cleanTextContent, null, 2)}

View File

@@ -33,6 +33,7 @@ import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import { ImageSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/image-selector/image-selector'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components'
import type { OutputConfig } from '@/stores/panel/chat/types'
@@ -68,6 +69,7 @@ const chatSchema = z.object({
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email']),
password: z.string().optional(),
@@ -146,6 +148,9 @@ export function ChatDeploy({
// Welcome message state
const [welcomeMessage, setWelcomeMessage] = useState('Hi there! How can I help you today?')
// Image URL state
const [imageUrl, setImageUrl] = useState<string | null>(null)
// Expose a method to handle external submission requests
useEffect(() => {
// This will run when the component mounts
@@ -239,6 +244,11 @@ export function ChatDeploy({
if (chatDetail.customizations?.welcomeMessage) {
setWelcomeMessage(chatDetail.customizations.welcomeMessage)
}
// Set image URL if it exists
if (chatDetail.customizations?.imageUrl) {
setImageUrl(chatDetail.customizations.imageUrl)
}
} else {
logger.error('Failed to fetch chat details')
}
@@ -524,6 +534,7 @@ export function ChatDeploy({
customizations: {
primaryColor: '#802FFF',
welcomeMessage: welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: authType,
}
@@ -1189,7 +1200,7 @@ export function ChatDeploy({
</div>
</div>
{/* Welcome Message Section - Add this before the form closing div */}
{/* Welcome Message Section */}
<div className='space-y-2'>
<Label htmlFor='welcomeMessage' className='font-medium text-sm'>
Welcome Message
@@ -1206,6 +1217,15 @@ export function ChatDeploy({
This message will be displayed when users first open the chat
</p>
</div>
{/* Image Selector Section */}
<ImageSelector
value={imageUrl}
onChange={setImageUrl}
disabled={isDeploying}
label='Chat Logo'
placeholder='Upload a logo for your chat'
/>
</div>
</form>

View File

@@ -0,0 +1,278 @@
'use client'
import { useRef, useState } from 'react'
import { Image, Loader2, Upload, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
const logger = createLogger('ImageSelector')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp']
interface ImageSelectorProps {
value?: string | null
onChange: (imageUrl: string | null) => void
disabled?: boolean
label?: string
placeholder?: string
className?: string
}
export function ImageSelector({
value,
onChange,
disabled = false,
label = 'Logo Image',
placeholder = 'Upload an image for your chat logo',
className,
}: ImageSelectorProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const validateFile = (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, JPEG, GIF, or WebP.`
}
return null
}
const handleFileUpload = async (file: File) => {
const error = validateFile(file)
if (error) {
setUploadError(error)
return
}
setIsUploading(true)
setUploadError(null)
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
onChange(publicUrl)
logger.info(`Image uploaded successfully via direct upload: ${publicUrl}`)
} else {
// 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
onChange(publicUrl)
logger.info(`Image uploaded successfully via server upload: ${publicUrl}`)
}
} catch (error) {
logger.error('Error uploading image:', error)
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload image. Please try again.'
setUploadError(errorMessage)
} finally {
setIsUploading(false)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleFileUpload(file)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setIsDragging(true)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (disabled) return
const file = e.dataTransfer.files?.[0]
if (file) {
handleFileUpload(file)
}
}
const handleRemove = () => {
onChange(null)
setUploadError(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = () => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click()
}
}
return (
<div className={cn('space-y-2', className)}>
<Label className='font-medium text-sm'>{label}</Label>
{value ? (
// Show uploaded image
<div className='space-y-2'>
<div className='relative inline-block'>
<img
src={value}
alt='Uploaded logo'
className='h-20 w-20 rounded-lg border object-cover'
/>
<Button
type='button'
variant='destructive'
size='icon'
className='-right-2 -top-2 absolute h-6 w-6 rounded-full'
onClick={handleRemove}
disabled={disabled || isUploading}
>
<X className='h-3 w-3' />
</Button>
</div>
<Button
type='button'
variant='outline'
onClick={handleClick}
disabled={disabled || isUploading}
className='text-sm'
>
{isUploading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Uploading...
</>
) : (
<>
<Upload className='mr-2 h-4 w-4' />
Replace Image
</>
)}
</Button>
</div>
) : (
// Show upload area
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
className={cn(
'relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 text-center transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/40 hover:bg-muted/10',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<input
ref={fileInputRef}
type='file'
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileChange}
className='hidden'
disabled={disabled}
/>
{isUploading ? (
<div className='space-y-2'>
<Loader2 className='mx-auto h-8 w-8 animate-spin text-muted-foreground' />
<p className='text-muted-foreground text-sm'>Uploading image...</p>
</div>
) : (
<div className='space-y-2'>
<Image className='mx-auto h-8 w-8 text-muted-foreground' />
<div>
<p className='font-medium text-sm'>
{isDragging ? 'Drop image here!' : placeholder}
</p>
<p className='text-muted-foreground text-xs'>PNG, JPEG, GIF, or WebP (max 5MB)</p>
</div>
</div>
)}
</div>
)}
{uploadError && <p className='text-destructive text-sm'>{uploadError}</p>}
</div>
)
}

View File

@@ -1,3 +1,4 @@
export { ChatDeploy } from './chat-deploy/chat-deploy'
export { DeployForm } from './deploy-form/deploy-form'
export { DeploymentInfo } from './deployment-info/deployment-info'
export { ImageSelector } from './image-selector/image-selector'

View File

@@ -91,6 +91,7 @@ export const env = createEnv({
S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage
S3_LOGS_BUCKET_NAME: z.string().optional(), // S3 bucket for storing logs
S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
@@ -98,6 +99,7 @@ export const env = createEnv({
AZURE_CONNECTION_STRING: z.string().optional(), // Azure storage connection string
AZURE_STORAGE_CONTAINER_NAME: z.string().optional(), // Azure container for general files
AZURE_STORAGE_KB_CONTAINER_NAME: z.string().optional(), // Azure container for knowledge base files
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users

View File

@@ -51,6 +51,19 @@ export const cspDirectives: CSPDirectives = {
'https://cdn.discordapp.com',
'https://*.githubusercontent.com',
'https://*.public.blob.vercel-storage.com',
'https://*.s3.amazonaws.com',
'https://s3.amazonaws.com',
...(env.S3_BUCKET_NAME && env.AWS_REGION
? [`https://${env.S3_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com`]
: []),
...(env.S3_KB_BUCKET_NAME && env.AWS_REGION
? [`https://${env.S3_KB_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com`]
: []),
...(env.S3_CHAT_BUCKET_NAME && env.AWS_REGION
? [`https://${env.S3_CHAT_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com`]
: []),
'https://*.amazonaws.com',
'https://*.blob.core.windows.net',
],
'media-src': ["'self'", 'blob:'],

View File

@@ -1,9 +1,11 @@
export * as BlobClient from '@/lib/uploads/blob/blob-client'
export * as S3Client from '@/lib/uploads/s3/s3-client'
export {
BLOB_CHAT_CONFIG,
BLOB_CONFIG,
BLOB_KB_CONFIG,
ensureUploadsDirectory,
S3_CHAT_CONFIG,
S3_CONFIG,
S3_KB_CONFIG,
UPLOAD_DIR,

View File

@@ -48,6 +48,18 @@ export const BLOB_KB_CONFIG = {
containerName: env.AZURE_STORAGE_KB_CONTAINER_NAME || '',
}
export const S3_CHAT_CONFIG = {
bucket: env.S3_CHAT_BUCKET_NAME || '',
region: env.AWS_REGION || '',
}
export const BLOB_CHAT_CONFIG = {
accountName: env.AZURE_ACCOUNT_NAME || '',
accountKey: env.AZURE_ACCOUNT_KEY || '',
connectionString: env.AZURE_CONNECTION_STRING || '',
containerName: env.AZURE_STORAGE_CHAT_CONTAINER_NAME || '',
}
export async function ensureUploadsDirectory() {
if (USE_S3_STORAGE) {
logger.info('Using S3 storage, skipping local uploads directory creation')