diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 0cc64dfbfa8..a90370cba08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -26,15 +26,17 @@ const logger = createLogger('FileUpload') interface FileUploadProps { blockId: string subBlockId: string + value?: FileUploadValue | null + onValueChange?: (value: FileUploadValue | null) => void maxSize?: number // in MB acceptedTypes?: string // comma separated MIME types multiple?: boolean // whether to allow multiple file uploads isPreview?: boolean - previewValue?: any | null + previewValue?: FileUploadValue | null disabled?: boolean } -interface UploadedFile { +export interface UploadedFile { name: string path: string key?: string @@ -42,6 +44,8 @@ interface UploadedFile { type: string } +export type FileUploadValue = UploadedFile | UploadedFile[] + interface SingleFileSelectorProps { file: UploadedFile options: Array<{ label: string; value: string; disabled?: boolean }> @@ -143,6 +147,8 @@ interface UploadingFile { export function FileUpload({ blockId, subBlockId, + value: controlledValue, + onValueChange, maxSize = 10, // Default 10MB acceptedTypes = '*', multiple = false, // Default to single file for backward compatibility @@ -151,6 +157,7 @@ export function FileUpload({ disabled = false, }: FileUploadProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const isControlled = controlledValue !== undefined const [uploadingFiles, setUploadingFiles] = useState([]) const [uploadProgress, setUploadProgress] = useState(0) const [uploadError, setUploadError] = useState(null) @@ -173,7 +180,21 @@ export function FileUpload({ const uploadFileMutation = useUploadWorkspaceFile() const queryClient = useQueryClient() - const value = isPreview ? previewValue : storeValue + const value = + isPreview && previewValue !== undefined + ? previewValue + : isControlled + ? controlledValue + : storeValue + + const setValue = (newValue: FileUploadValue | null) => { + if (isControlled) { + onValueChange?.(newValue) + } else { + setStoreValue(newValue) + } + useWorkflowStore.getState().triggerUpdate() + } /** * Checks if a file's MIME type matches the accepted types @@ -390,11 +411,9 @@ export function FileUpload({ const newFiles = Array.from(uniqueFiles.values()) - setStoreValue(newFiles) - useWorkflowStore.getState().triggerUpdate() + setValue(newFiles) } else { - setStoreValue(uploadedFiles[0] || null) - useWorkflowStore.getState().triggerUpdate() + setValue(uploadedFiles[0] || null) } } catch (error) { logger.error( @@ -439,12 +458,11 @@ export function FileUpload({ uniqueFiles.set(uploadedFile.path, uploadedFile) const newFiles = Array.from(uniqueFiles.values()) - setStoreValue(newFiles) + setValue(newFiles) } else { - setStoreValue(uploadedFile) + setValue(uploadedFile) } - useWorkflowStore.getState().triggerUpdate() logger.info(`Selected workspace file: ${selectedFile.name}`, activeWorkflowId) } @@ -481,12 +499,10 @@ export function FileUpload({ if (multiple) { const filesArray = Array.isArray(value) ? value : value ? [value] : [] const updatedFiles = filesArray.filter((f) => f.path !== file.path) - setStoreValue(updatedFiles.length > 0 ? updatedFiles : null) + setValue(updatedFiles.length > 0 ? updatedFiles : null) } else { - setStoreValue(null) + setValue(null) } - - useWorkflowStore.getState().triggerUpdate() } catch (error) { logger.error( error instanceof Error ? error.message : 'Failed to remove file', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx index aaf3f529806..4fee7f6df5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx @@ -9,11 +9,26 @@ import { } from 'react' import { generateShortId } from '@sim/utils/id' import { isEqual } from 'es-toolkit' -import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' -import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' +import { AlertTriangle, ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' +import { + Button, + Popover, + PopoverContent, + PopoverItem, + PopoverTrigger, + Tooltip, +} from '@/components/emcn' import { Trash } from '@/components/emcn/icons/trash' import { cn } from '@/lib/core/utils/cn' +import { + CLAUDE_SUPPORTED_IMAGE_MIME_TYPES, + MIME_TYPE_MAPPING, +} from '@/lib/uploads/utils/file-utils' import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + FileUpload, + type FileUploadValue, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' @@ -22,10 +37,50 @@ import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workf import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' import type { SubBlockConfig } from '@/blocks/types' +import { getProviderFromModel, supportsFileAttachments } from '@/providers/models' const MIN_TEXTAREA_HEIGHT_PX = 80 const MAX_TEXTAREA_HEIGHT_PX = 320 +const ANTHROPIC_SUPPORTED_DOCUMENT_TYPES = new Set( + Object.entries(MIME_TYPE_MAPPING) + .filter(([, contentType]) => contentType === 'document') + .map(([mimeType]) => mimeType) +) + +const OPENAI_SUPPORTED_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', +]) + +const OPENAI_SUPPORTED_FILE_TYPES = new Set([ + 'text/x-c', + 'text/x-c++', + 'text/x-csharp', + 'text/css', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/x-golang', + 'text/html', + 'text/x-java', + 'text/javascript', + 'application/json', + 'text/markdown', + 'application/pdf', + 'text/x-php', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/x-python', + 'text/x-script.python', + 'text/x-ruby', + 'application/x-sh', + 'text/x-tex', + 'application/typescript', + 'text/plain', +]) + /** Pattern to match complete message objects in JSON */ const COMPLETE_MESSAGE_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g @@ -42,14 +97,53 @@ const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]* const unescapeContent = (str: string): string => str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\') +function isTextMessage(message: Message | undefined): message is TextMessage { + return ( + !!message && + (message.role === 'system' || message.role === 'user' || message.role === 'assistant') + ) +} + +function hasFilesValue(value: FileUploadValue | null | undefined): boolean { + return Array.isArray(value) ? value.length > 0 : Boolean(value) +} + +function hasAnthropicUnsupportedFiles(value: FileUploadValue | null | undefined): boolean { + const files = Array.isArray(value) ? value : value ? [value] : [] + return files.some((file) => { + const type = file.type.toLowerCase() + if (type === 'image/svg+xml') return false + return ( + !CLAUDE_SUPPORTED_IMAGE_MIME_TYPES.has(type) && !ANTHROPIC_SUPPORTED_DOCUMENT_TYPES.has(type) + ) + }) +} + +function hasOpenAIUnsupportedFiles(value: FileUploadValue | null | undefined): boolean { + const files = Array.isArray(value) ? value : value ? [value] : [] + return files.some((file) => { + const type = file.type.toLowerCase() + return !OPENAI_SUPPORTED_IMAGE_TYPES.has(type) && !OPENAI_SUPPORTED_FILE_TYPES.has(type) + }) +} + /** * Interface for individual message in the messages array */ -interface Message { - role: 'system' | 'user' | 'assistant' +type TextMessageRole = 'system' | 'user' | 'assistant' + +interface TextMessage { + role: TextMessageRole content: string } +interface FilesMessage { + role: 'files' + files?: FileUploadValue | null +} + +type Message = TextMessage | FilesMessage + /** * Props for the MessagesInput component */ @@ -88,10 +182,13 @@ export function MessagesInput({ wandControlRef, }: MessagesInputProps) { const [messages, setMessages] = useSubBlockValue(blockId, subBlockId, false) + const [selectedModel] = useSubBlockValue(blockId, 'model', false) const [localMessages, setLocalMessages] = useState([{ role: 'user', content: '' }]) const messageIdsRef = useRef([generateShortId()]) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const [openPopoverIndex, setOpenPopoverIndex] = useState(null) + const fileAttachmentsSupported = selectedModel ? supportsFileAttachments(selectedModel) : true + const selectedProvider = selectedModel ? getProviderFromModel(selectedModel) : null const subBlockInput = useSubBlockInput({ blockId, subBlockId, @@ -106,7 +203,9 @@ export function MessagesInput({ const getMessagesJson = useCallback((): string => { if (localMessages.length === 0) return '' // Filter out empty messages for cleaner context - const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '') + const nonEmptyMessages = localMessages.filter( + (m): m is TextMessage => isTextMessage(m) && m.content.trim() !== '' + ) if (nonEmptyMessages.length === 0) return '' return JSON.stringify(nonEmptyMessages, null, 2) }, [localMessages]) @@ -119,11 +218,11 @@ export function MessagesInput({ /** * Parses and validates messages from JSON content */ - const parseMessages = useCallback((content: string): Message[] | null => { + const parseMessages = useCallback((content: string): TextMessage[] | null => { try { const parsed = JSON.parse(content) if (Array.isArray(parsed)) { - const validMessages: Message[] = parsed + const validMessages: TextMessage[] = parsed .filter( (m): m is { role: string; content: string } => typeof m === 'object' && @@ -134,7 +233,7 @@ export function MessagesInput({ .map((m) => ({ role: (['system', 'user', 'assistant'].includes(m.role) ? m.role - : 'user') as Message['role'], + : 'user') as TextMessageRole, content: m.content, })) return validMessages.length > 0 ? validMessages : null @@ -150,18 +249,18 @@ export function MessagesInput({ * Uses simple pattern matching for efficiency */ const extractStreamingMessages = useCallback( - (buffer: string): Message[] => { + (buffer: string): TextMessage[] => { // Try complete JSON parse first const complete = parseMessages(buffer) if (complete) return complete - const result: Message[] = [] + const result: TextMessage[] = [] // Reset regex lastIndex for global pattern COMPLETE_MESSAGE_PATTERN.lastIndex = 0 let match while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) { - result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) }) + result.push({ role: match[1] as TextMessageRole, content: unescapeContent(match[2]) }) } // Check for incomplete message at end (content still streaming) @@ -176,7 +275,7 @@ export function MessagesInput({ const content = unescapeContent(incomplete[1]) // Only add if not duplicate of last complete message if (result.length === 0 || result[result.length - 1].content !== content) { - result.push({ role: roleMatch[1] as Message['role'], content }) + result.push({ role: roleMatch[1] as TextMessageRole, content }) } } } @@ -288,6 +387,7 @@ export function MessagesInput({ if (isPreview || disabled) return const updatedMessages = [...localMessages] + if (!isTextMessage(updatedMessages[index])) return updatedMessages[index] = { ...updatedMessages[index], content, @@ -302,14 +402,27 @@ export function MessagesInput({ * Updates a specific message's role */ const updateMessageRole = useCallback( - (index: number, role: 'system' | 'user' | 'assistant') => { + (index: number, role: TextMessageRole | 'files') => { if (isPreview || disabled) return const updatedMessages = [...localMessages] - updatedMessages[index] = { - ...updatedMessages[index], - role, - } + const currentMessage = updatedMessages[index] + updatedMessages[index] = + role === 'files' + ? { role: 'files', files: isTextMessage(currentMessage) ? null : currentMessage.files } + : { role, content: isTextMessage(currentMessage) ? currentMessage.content : '' } + setLocalMessages(updatedMessages) + setMessages(updatedMessages) + }, + [localMessages, setMessages, isPreview, disabled] + ) + + const updateMessageFiles = useCallback( + (index: number, files: FileUploadValue | null) => { + if (isPreview || disabled) return + + const updatedMessages = [...localMessages] + updatedMessages[index] = { role: 'files', files } setLocalMessages(updatedMessages) setMessages(updatedMessages) }, @@ -553,10 +666,21 @@ export function MessagesInput({ > {(() => { const fieldId = `message-${index}` + const textContent = isTextMessage(message) ? message.content : '' + const isFilesMessage = message.role === 'files' + const showFilesWarning = + isFilesMessage && hasFilesValue(message.files) && !fileAttachmentsSupported + const showUnsupportedFilesWarning = + isFilesMessage && + fileAttachmentsSupported && + (((selectedProvider === 'anthropic' || selectedProvider === 'azure-anthropic') && + hasAnthropicUnsupportedFiles(message.files)) || + ((selectedProvider === 'openai' || selectedProvider === 'azure-openai') && + hasOpenAIUnsupportedFiles(message.files))) const fieldState = subBlockInput.fieldHelpers.getFieldState(fieldId) const fieldHandlers = subBlockInput.fieldHelpers.createFieldHandlers( fieldId, - message.content, + textContent, (newValue: string) => { updateMessageContent(index, newValue) } @@ -564,7 +688,7 @@ export function MessagesInput({ const handleEnvSelect = subBlockInput.fieldHelpers.createEnvVarSelectHandler( fieldId, - message.content, + textContent, (newValue: string) => { updateMessageContent(index, newValue) } @@ -572,7 +696,7 @@ export function MessagesInput({ const handleTagSelect = subBlockInput.fieldHelpers.createTagSelectHandler( fieldId, - message.content, + textContent, (newValue: string) => { updateMessageContent(index, newValue) } @@ -598,51 +722,80 @@ export function MessagesInput({ } }} > - setOpenPopoverIndex(open ? index : null)} - colorScheme='inverted' - > - - - - -
- {(['system', 'user', 'assistant'] as const).map((role) => ( - { - updateMessageRole(index, role) - setOpenPopoverIndex(null) - }} - > - {formatRole(role)} - - ))} -
-
-
+
+ setOpenPopoverIndex(open ? index : null)} + colorScheme='inverted' + > + + + + +
+ {(['system', 'user', 'assistant', 'files'] as const).map((role) => ( + { + updateMessageRole(index, role) + setOpenPopoverIndex(null) + }} + > + {formatRole(role)} + + ))} +
+
+
+ + {showFilesWarning && ( + + + + + + + + The selected model does not accept files. These files will be ignored. + + + )} + + {showUnsupportedFilesWarning && ( + + + + + + + + The selected model does not accept one or more file types. Unsupported + files will be ignored. + + + )} +
{!isPreview && !disabled && (
@@ -703,100 +856,118 @@ export function MessagesInput({
{/* Content Input with overlay for variable highlighting */} -
-