mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(deploy-chat): added a logo upload for the chat, incr font size
This commit is contained in:
@@ -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', () => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ interface ChatConfig {
|
||||
customizations: {
|
||||
primaryColor?: string
|
||||
logoUrl?: string
|
||||
imageUrl?: string
|
||||
welcomeMessage?: string
|
||||
headerText?: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user