Skip to content

Commit 0c44332

Browse files
committed
File uploads to mothership
1 parent 7c0cd36 commit 0c44332

File tree

10 files changed

+187
-42
lines changed

10 files changed

+187
-42
lines changed

apps/sim/app/api/files/upload/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,18 @@ export async function POST(request: NextRequest) {
251251
}
252252
}
253253

254-
// Handle image-only contexts (copilot, chat, profile-pictures)
254+
// Handle copilot, chat, profile-pictures contexts
255255
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
256-
if (!isImageFileType(file.type)) {
256+
if (context === 'copilot') {
257+
const { isSupportedFileType: isCopilotSupported } = await import(
258+
'@/lib/uploads/contexts/copilot/copilot-file-manager'
259+
)
260+
if (!isImageFileType(file.type) && !isCopilotSupported(file.type)) {
261+
throw new InvalidRequestError(
262+
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
263+
)
264+
}
265+
} else if (!isImageFileType(file.type)) {
257266
throw new InvalidRequestError(
258267
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
259268
)

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client'
22

33
import { useCallback, useEffect, useRef, useState } from 'react'
4-
import { ArrowUp, Mic, Paperclip } from 'lucide-react'
4+
import { ArrowUp, FileText, Loader2, Mic, Paperclip, X } from 'lucide-react'
55
import { Button } from '@/components/emcn'
66
import { cn } from '@/lib/core/utils/cn'
7+
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
78
import { useAnimatedPlaceholder } from '../../hooks'
89

910
const TEXTAREA_CLASSES = cn(
@@ -27,13 +28,25 @@ function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
2728
target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px`
2829
}
2930

31+
export interface FileAttachmentForApi {
32+
id: string
33+
key: string
34+
filename: string
35+
media_type: string
36+
size: number
37+
}
38+
39+
const ACCEPTED_FILE_TYPES =
40+
'image/*,.pdf,.txt,.csv,.md,.html,.json,.xml,text/plain,text/csv,text/markdown,text/html,application/json,application/xml,application/pdf'
41+
3042
interface UserInputProps {
3143
value: string
3244
onChange: (value: string) => void
33-
onSubmit: () => void
45+
onSubmit: (fileAttachments?: FileAttachmentForApi[]) => void
3446
isSending: boolean
3547
onStopGeneration: () => void
3648
isInitialView?: boolean
49+
userId?: string
3750
}
3851

3952
export function UserInput({
@@ -43,10 +56,14 @@ export function UserInput({
4356
isSending,
4457
onStopGeneration,
4558
isInitialView = true,
59+
userId,
4660
}: UserInputProps) {
4761
const animatedPlaceholder = useAnimatedPlaceholder()
4862
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
49-
const canSubmit = value.trim().length > 0 && !isSending
63+
64+
const files = useFileAttachments({ userId, disabled: false, isLoading: isSending })
65+
const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key)
66+
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending
5067

5168
const [isListening, setIsListening] = useState(false)
5269
const recognitionRef = useRef<SpeechRecognition | null>(null)
@@ -65,14 +82,29 @@ export function UserInput({
6582
textareaRef.current?.focus()
6683
}, [])
6784

85+
const handleSubmit = useCallback(() => {
86+
const fileAttachmentsForApi = files.attachedFiles
87+
.filter((f) => !f.uploading && f.key)
88+
.map((f) => ({
89+
id: f.id,
90+
key: f.key!,
91+
filename: f.name,
92+
media_type: f.type,
93+
size: f.size,
94+
}))
95+
96+
onSubmit(fileAttachmentsForApi.length > 0 ? fileAttachmentsForApi : undefined)
97+
files.clearAttachedFiles()
98+
}, [onSubmit, files])
99+
68100
const handleKeyDown = useCallback(
69101
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
70102
if (e.key === 'Enter' && !e.shiftKey) {
71103
e.preventDefault()
72-
onSubmit()
104+
handleSubmit()
73105
}
74106
},
75-
[onSubmit]
107+
[handleSubmit]
76108
)
77109

78110
const toggleListening = useCallback(() => {
@@ -133,26 +165,89 @@ export function UserInput({
133165
onClick={handleContainerClick}
134166
className={cn(
135167
'mx-auto w-full max-w-[640px] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-[10px] py-[8px] dark:bg-[var(--surface-4)]',
136-
isInitialView && 'shadow-sm'
168+
isInitialView && 'shadow-sm',
169+
files.isDragging && 'ring-[1.75px] ring-[var(--brand-secondary)]'
137170
)}
171+
onDragEnter={files.handleDragEnter}
172+
onDragLeave={files.handleDragLeave}
173+
onDragOver={files.handleDragOver}
174+
onDrop={files.handleDrop}
138175
>
176+
{/* Attached files */}
177+
{files.attachedFiles.length > 0 && (
178+
<div className='mb-[6px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
179+
{files.attachedFiles.map((file) => {
180+
const isImage = file.type.startsWith('image/')
181+
return (
182+
<div
183+
key={file.id}
184+
className='group relative h-[56px] w-[56px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] transition-all hover:bg-[var(--surface-4)]'
185+
title={`${file.name} (${files.formatFileSize(file.size)})`}
186+
onClick={() => files.handleFileClick(file)}
187+
>
188+
{isImage && file.previewUrl ? (
189+
<img
190+
src={file.previewUrl}
191+
alt={file.name}
192+
className='h-full w-full object-cover'
193+
/>
194+
) : (
195+
<div className='flex h-full w-full flex-col items-center justify-center gap-[2px]'>
196+
{file.type.includes('pdf') ? (
197+
<FileText className='h-[18px] w-[18px] text-red-500' />
198+
) : (
199+
<FileText className='h-[18px] w-[18px] text-blue-500' />
200+
)}
201+
<span className='max-w-[48px] truncate px-[2px] text-[9px] text-[var(--text-muted)]'>
202+
{file.name.split('.').pop()}
203+
</span>
204+
</div>
205+
)}
206+
{file.uploading && (
207+
<div className='absolute inset-0 flex items-center justify-center bg-black/50'>
208+
<Loader2 className='h-[14px] w-[14px] animate-spin text-white' />
209+
</div>
210+
)}
211+
{!file.uploading && (
212+
<button
213+
type='button'
214+
onClick={(e) => {
215+
e.stopPropagation()
216+
files.removeFile(file.id)
217+
}}
218+
className='absolute top-[2px] right-[2px] flex h-[16px] w-[16px] items-center justify-center rounded-full bg-black/60 opacity-0 transition-opacity group-hover:opacity-100'
219+
>
220+
<X className='h-[10px] w-[10px] text-white' />
221+
</button>
222+
)}
223+
</div>
224+
)
225+
})}
226+
</div>
227+
)}
228+
139229
<textarea
140230
ref={textareaRef}
141231
value={value}
142232
onChange={(e) => onChange(e.target.value)}
143233
onKeyDown={handleKeyDown}
144234
onInput={autoResizeTextarea}
145-
placeholder={placeholder}
235+
placeholder={files.isDragging ? 'Drop files here...' : placeholder}
146236
rows={1}
147237
className={TEXTAREA_CLASSES}
148238
/>
149239
<div className='flex items-center justify-between'>
150-
<div className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'>
240+
<button
241+
type='button'
242+
onClick={files.handleFileSelect}
243+
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
244+
title='Attach file'
245+
>
151246
<Paperclip
152247
className='h-[14px] w-[14px] text-[var(--text-muted)] dark:text-[var(--text-secondary)]'
153248
strokeWidth={2}
154249
/>
155-
</div>
250+
</button>
156251
<div className='flex items-center gap-[6px]'>
157252
<button
158253
type='button'
@@ -183,7 +278,7 @@ export function UserInput({
183278
</Button>
184279
) : (
185280
<Button
186-
onClick={onSubmit}
281+
onClick={handleSubmit}
187282
disabled={!canSubmit}
188283
className={cn(
189284
SEND_BUTTON_BASE,
@@ -198,6 +293,16 @@ export function UserInput({
198293
)}
199294
</div>
200295
</div>
296+
297+
{/* Hidden file input */}
298+
<input
299+
ref={files.fileInputRef}
300+
type='file'
301+
onChange={files.handleFileChange}
302+
className='hidden'
303+
accept={ACCEPTED_FILE_TYPES}
304+
multiple
305+
/>
201306
</div>
202307
)
203308
}

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import { useCallback, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { useParams } from 'next/navigation'
6+
import { useSession } from '@/lib/auth/auth-client'
67
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
78
import { MessageContent, MothershipView, UserInput } from './components'
9+
import type { FileAttachmentForApi } from './components/user-input/user-input'
810
import { useChat } from './hooks'
911

1012
const logger = createLogger('Home')
@@ -15,6 +17,7 @@ interface HomeProps {
1517

1618
export function Home({ chatId }: HomeProps = {}) {
1719
const { workspaceId } = useParams<{ workspaceId: string }>()
20+
const { data: session } = useSession()
1821
const [inputValue, setInputValue] = useState('')
1922
const hasCheckedLandingPromptRef = useRef(false)
2023

@@ -40,12 +43,15 @@ export function Home({ chatId }: HomeProps = {}) {
4043
setActiveResourceId,
4144
} = useChat(workspaceId, chatId)
4245

43-
const handleSubmit = useCallback(() => {
44-
const trimmed = inputValue.trim()
45-
if (!trimmed) return
46-
setInputValue('')
47-
sendMessage(trimmed)
48-
}, [inputValue, sendMessage])
46+
const handleSubmit = useCallback(
47+
(fileAttachments?: FileAttachmentForApi[]) => {
48+
const trimmed = inputValue.trim()
49+
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
50+
setInputValue('')
51+
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments)
52+
},
53+
[inputValue, sendMessage]
54+
)
4955

5056
const hasMessages = messages.length > 0
5157

@@ -61,6 +67,7 @@ export function Home({ chatId }: HomeProps = {}) {
6167
onSubmit={handleSubmit}
6268
isSending={isSending}
6369
onStopGeneration={stopGeneration}
70+
userId={session?.user?.id}
6471
/>
6572
</div>
6673
)
@@ -122,6 +129,7 @@ export function Home({ chatId }: HomeProps = {}) {
122129
isSending={isSending}
123130
onStopGeneration={stopGeneration}
124131
isInitialView={false}
132+
userId={session?.user?.id}
125133
/>
126134
</div>
127135
</div>

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,16 @@ export interface UseChatReturn {
3535
messages: ChatMessage[]
3636
isSending: boolean
3737
error: string | null
38-
sendMessage: (message: string) => Promise<void>
38+
sendMessage: (
39+
message: string,
40+
fileAttachments?: Array<{
41+
id: string
42+
key: string
43+
filename: string
44+
media_type: string
45+
size: number
46+
}>
47+
) => Promise<void>
3948
stopGeneration: () => void
4049
chatBottomRef: React.RefObject<HTMLDivElement | null>
4150
resources: MothershipResource[]
@@ -445,7 +454,16 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
445454
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
446455

447456
const sendMessage = useCallback(
448-
async (message: string) => {
457+
async (
458+
message: string,
459+
fileAttachments?: Array<{
460+
id: string
461+
key: string
462+
filename: string
463+
media_type: string
464+
size: number
465+
}>
466+
) => {
449467
if (!message.trim() || !workspaceId) return
450468

451469
abortControllerRef.current?.abort()
@@ -494,6 +512,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
494512
userMessageId,
495513
createNewChat: !chatIdRef.current,
496514
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
515+
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
497516
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
498517
}),
499518
signal: abortController.signal,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
112112
}
113113

114114
for (const file of Array.from(fileList)) {
115-
if (!file.type.startsWith('image/')) {
116-
logger.warn(`File ${file.name} is not an image. Only image files are allowed.`)
117-
continue
118-
}
119-
120115
let previewUrl: string | undefined
121116
if (file.type.startsWith('image/')) {
122117
previewUrl = URL.createObjectURL(file)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
883883
type='file'
884884
onChange={fileAttachments.handleFileChange}
885885
className='hidden'
886-
accept='image/*'
886+
accept='image/*,.pdf,.txt,.csv,.md,.html,.json,.xml,application/pdf,text/plain,text/csv,text/markdown'
887887
multiple
888888
disabled={disabled}
889889
/>

apps/sim/lib/copilot/chat-context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export async function processFileAttachments(
3838
for (const { buffer, attachment } of processedAttachments) {
3939
const fileContent = createFileContent(buffer, attachment.media_type)
4040
if (fileContent) {
41-
processedFileContents.push(fileContent as FileContent)
41+
const enriched: FileContent = { ...fileContent, filename: attachment.filename }
42+
processedFileContents.push(enriched)
4243
}
4344
}
4445

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,6 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
650650
| {
651651
name: string
652652
type: string
653-
required?: boolean
654653
unique?: boolean
655654
position?: number
656655
}
@@ -720,12 +719,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
720719
return { success: false, message: 'columnName is required' }
721720
}
722721
const newType = (args as Record<string, unknown>).newType as string | undefined
723-
const reqFlag = (args as Record<string, unknown>).required as boolean | undefined
724722
const uniqFlag = (args as Record<string, unknown>).unique as boolean | undefined
725-
if (newType === undefined && reqFlag === undefined && uniqFlag === undefined) {
723+
if (newType === undefined && uniqFlag === undefined) {
726724
return {
727725
success: false,
728-
message: 'At least one of newType, required, or unique must be provided',
726+
message: 'At least one of newType or unique must be provided',
729727
}
730728
}
731729
const requestId = crypto.randomUUID().slice(0, 8)
@@ -736,9 +734,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
736734
requestId
737735
)
738736
}
739-
if (reqFlag !== undefined || uniqFlag !== undefined) {
737+
if (uniqFlag !== undefined) {
740738
result = await updateColumnConstraints(
741-
{ tableId: args.tableId, columnName: colName, required: reqFlag, unique: uniqFlag },
739+
{ tableId: args.tableId, columnName: colName, unique: uniqFlag },
742740
requestId
743741
)
744742
}

0 commit comments

Comments
 (0)