diff --git a/apps/sim/app/api/__test-utils__/utils.ts b/apps/sim/app/api/__test-utils__/utils.ts
index 8c7caa8c5d..55d2357991 100644
--- a/apps/sim/app/api/__test-utils__/utils.ts
+++ b/apps/sim/app/api/__test-utils__/utils.ts
@@ -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', () => ({
diff --git a/apps/sim/app/api/chat/edit/[id]/route.ts b/apps/sim/app/api/chat/edit/[id]/route.ts
index dcaaeee46e..08babbcd58 100644
--- a/apps/sim/app/api/chat/edit/[id]/route.ts
+++ b/apps/sim/app/api/chat/edit/[id]/route.ts
@@ -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(),
diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts
index cbbb937c6c..9ea5ca3c1e 100644
--- a/apps/sim/app/api/chat/route.ts
+++ b/apps/sim/app/api/chat/route.ts
@@ -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(),
diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts
index a96446b005..4702324d52 100644
--- a/apps/sim/app/api/files/presigned/route.test.ts
+++ b/apps/sim/app/api/files/presigned/route.test.ts
@@ -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', () => ({
diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts
index 6ca7879773..30c3321544 100644
--- a/apps/sim/app/api/files/presigned/route.ts
+++ b/apps/sim/app/api/files/presigned/route.ts
@@ -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,
diff --git a/apps/sim/app/chat/[subdomain]/chat-client.tsx b/apps/sim/app/chat/[subdomain]/chat-client.tsx
index 4eba1ab8fa..5646b9414d 100644
--- a/apps/sim/app/chat/[subdomain]/chat-client.tsx
+++ b/apps/sim/app/chat/[subdomain]/chat-client.tsx
@@ -30,6 +30,7 @@ interface ChatConfig {
customizations: {
primaryColor?: string
logoUrl?: string
+ imageUrl?: string
welcomeMessage?: string
headerText?: string
}
diff --git a/apps/sim/app/chat/[subdomain]/components/header/header.tsx b/apps/sim/app/chat/[subdomain]/components/header/header.tsx
index ce7590d2cf..be88caf077 100644
--- a/apps/sim/app/chat/[subdomain]/components/header/header.tsx
+++ b/apps/sim/app/chat/[subdomain]/components/header/header.tsx
@@ -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 (
-
-
- {chatConfig?.customizations?.logoUrl && (
+
+
+ {customImage ? (

+ ) : (
+ // Default Sim Studio logo when no custom image is provided
+
)}
-
+
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
@@ -39,8 +80,8 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
target='_blank'
rel='noopener noreferrer'
>
-
-
{starCount}
+
+
{starCount}