Skip to content

Commit 50bab80

Browse files
committed
feat(input-format): upload files in file fields via the file uploader
The file field in the start-block input format only offered a raw JSON editor expecting hand-written base64 objects, which users routinely filled with junk (local paths, raw text, leftover placeholders) and which never actually fed a run. Reuse the existing FileUpload component for file-typed fields: - Add an opt-in controlled mode (value/onValueChange) to FileUpload so it can be embedded where the value lives outside a subblock; store-bound consumers (stt/vision/agent) are unchanged. - Render the uploader for file fields, with a toggle to fall back to the raw JSON editor for power users / legacy values. - Detect file fields by normalized type (file[]/files/file/image) so copilot/API-authored variants render correctly too. - Wire editor-attached files (already uploaded, run-ready) into manual runs via the executor's files channel; chat/API runs still override. Backwards compatible: the inputFormat array shape is unchanged (file values are stored as a JSON string of run-ready file objects); legacy free-form values open in JSON mode so nothing is lost.
1 parent 84c22f1 commit 50bab80

5 files changed

Lines changed: 318 additions & 35 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,17 @@ interface FileUploadProps {
3939
isPreview?: boolean
4040
previewValue?: any | null
4141
disabled?: boolean
42+
/**
43+
* Controlled value. When `onValueChange` is provided the component reads from
44+
* this prop and writes through `onValueChange` instead of the subblock store,
45+
* letting it be embedded where the value lives outside a subblock (e.g. a
46+
* single field inside the input-format editor).
47+
*/
48+
value?: UploadedFile | UploadedFile[] | null
49+
onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void
4250
}
4351

44-
interface UploadedFile {
52+
export interface UploadedFile {
4553
name: string
4654
path: string
4755
key?: string
@@ -165,9 +173,25 @@ export function FileUpload({
165173
isPreview = false,
166174
previewValue,
167175
disabled = false,
176+
value: controlledValue,
177+
onValueChange,
168178
}: FileUploadProps) {
169179
const activeSearchTarget = useActiveSearchTarget()
170180
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
181+
const isControlled = onValueChange !== undefined
182+
183+
/**
184+
* Persists a new value. In controlled mode the caller owns persistence; in
185+
* store mode we write through the subblock store and notify collaborators.
186+
*/
187+
const commitValue = (next: UploadedFile | UploadedFile[] | null) => {
188+
if (isControlled) {
189+
onValueChange(next)
190+
return
191+
}
192+
setStoreValue(next)
193+
useWorkflowStore.getState().triggerUpdate()
194+
}
171195
const [modelValue] = useSubBlockValue(blockId, 'model')
172196
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
173197
const [uploadProgress, setUploadProgress] = useState(0)
@@ -191,7 +215,7 @@ export function FileUpload({
191215
const uploadFileMutation = useUploadWorkspaceFile()
192216
const queryClient = useQueryClient()
193217

194-
const value = isPreview ? previewValue : storeValue
218+
const value = isControlled ? controlledValue : isPreview ? previewValue : storeValue
195219

196220
const maxSizeInBytes = useMemo(() => {
197221
const fallback = maxSize * 1024 * 1024
@@ -413,11 +437,9 @@ export function FileUpload({
413437

414438
const newFiles = Array.from(uniqueFiles.values())
415439

416-
setStoreValue(newFiles)
417-
useWorkflowStore.getState().triggerUpdate()
440+
commitValue(newFiles)
418441
} else {
419-
setStoreValue(uploadedFiles[0] || null)
420-
useWorkflowStore.getState().triggerUpdate()
442+
commitValue(uploadedFiles[0] || null)
421443
}
422444
} catch (error) {
423445
logger.error(getErrorMessage(error, 'Failed to upload file(s)'), activeWorkflowId)
@@ -459,12 +481,11 @@ export function FileUpload({
459481
uniqueFiles.set(uploadedFile.path, uploadedFile)
460482
const newFiles = Array.from(uniqueFiles.values())
461483

462-
setStoreValue(newFiles)
484+
commitValue(newFiles)
463485
} else {
464-
setStoreValue(uploadedFile)
486+
commitValue(uploadedFile)
465487
}
466488

467-
useWorkflowStore.getState().triggerUpdate()
468489
logger.info(`Selected workspace file: ${selectedFile.name}`, activeWorkflowId)
469490
}
470491

@@ -501,12 +522,10 @@ export function FileUpload({
501522
if (multiple) {
502523
const filesArray = Array.isArray(value) ? value : value ? [value] : []
503524
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
504-
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
525+
commitValue(updatedFiles.length > 0 ? updatedFiles : null)
505526
} else {
506-
setStoreValue(null)
527+
commitValue(null)
507528
}
508-
509-
useWorkflowStore.getState().triggerUpdate()
510529
} catch (error) {
511530
logger.error(getErrorMessage(error, 'Failed to remove file'), activeWorkflowId)
512531
} finally {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef } from 'react'
1+
import { useCallback, useRef, useState } from 'react'
22
import {
33
Badge,
44
Button,
@@ -17,9 +17,19 @@ import {
1717
languages,
1818
} from '@sim/emcn'
1919
import { Trash } from '@sim/emcn/icons'
20+
import { generateId } from '@sim/utils/id'
2021
import { Plus } from 'lucide-react'
2122
import Editor from 'react-simple-code-editor'
22-
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
23+
import {
24+
createDefaultInputFormatField,
25+
type InputFormatFile,
26+
isFileFieldType,
27+
parseInputFormatFiles,
28+
} from '@/lib/workflows/input-format'
29+
import {
30+
FileUpload,
31+
type UploadedFile,
32+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'
2333
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
2434
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
2535
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
@@ -80,6 +90,66 @@ const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\]
8090

8191
const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json')
8292

93+
/**
94+
* Maps stored run-ready file objects to the {@link FileUpload} value shape
95+
* (which keys off `path`).
96+
*/
97+
const filesToControlValue = (files: InputFormatFile[]): UploadedFile[] =>
98+
files.map((file) => ({
99+
name: file.name,
100+
path: file.url,
101+
key: file.key,
102+
size: file.size,
103+
type: file.type,
104+
}))
105+
106+
/**
107+
* Maps a {@link FileUpload} value back to stored run-ready file objects,
108+
* preserving the stable `id` of files that were already present.
109+
*/
110+
const controlValueToFiles = (
111+
value: UploadedFile | UploadedFile[] | null,
112+
previous: InputFormatFile[]
113+
): InputFormatFile[] => {
114+
const uploaded = Array.isArray(value) ? value : value ? [value] : []
115+
return uploaded.map((file) => {
116+
const existing = previous.find(
117+
(prev) => (file.key && prev.key === file.key) || prev.url === file.path
118+
)
119+
return {
120+
id: existing?.id ?? generateId(),
121+
name: file.name,
122+
url: file.path,
123+
key: file.key,
124+
size: file.size,
125+
type: file.type,
126+
}
127+
})
128+
}
129+
130+
/**
131+
* Serializes run-ready file objects into a field value string (empty when none).
132+
*/
133+
const serializeInputFormatFiles = (files: InputFormatFile[]): string =>
134+
files.length > 0 ? JSON.stringify(files, null, 2) : ''
135+
136+
/**
137+
* Default editor mode for a file field: the uploader, unless the stored value is
138+
* legacy free-form content (raw text or a non-file array) that only the JSON
139+
* editor can represent without data loss.
140+
*/
141+
const defaultFileFieldMode = (value: string | undefined): 'upload' | 'json' => {
142+
if (!value || !value.trim()) return 'upload'
143+
try {
144+
const parsed = JSON.parse(value)
145+
if (!Array.isArray(parsed)) return 'json'
146+
if (parsed.length === 0) return 'upload'
147+
return parseInputFormatFiles(value).length > 0 ? 'upload' : 'json'
148+
} catch {
149+
return 'json'
150+
}
151+
}
152+
83153
export function FieldFormat({
84154
blockId,
85155
subBlockId,
@@ -101,6 +171,7 @@ export function FieldFormat({
101171
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
102172
const nameOverlayRefs = useRef<Record<string, HTMLDivElement>>({})
103173
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
174+
const [fileFieldModes, setFileFieldModes] = useState<Record<string, 'upload' | 'json'>>({})
104175

105176
const inputController = useSubBlockInput({
106177
blockId,
@@ -480,12 +551,14 @@ export function FieldFormat({
480551
)
481552
}
482553

483-
if (field.type === 'file[]') {
554+
if (isFileFieldType(field.type)) {
555+
const mode = fileFieldModes[field.id] ?? defaultFileFieldMode(field.value)
556+
const currentFiles = parseInputFormatFiles(field.value)
484557
const lineCount = fieldValue.split('\n').length
485558
const gutterWidth = calculateGutterWidth(lineCount)
486559

487-
const renderLineNumbers = () => {
488-
return Array.from({ length: lineCount }, (_, i) => (
560+
const renderLineNumbers = () =>
561+
Array.from({ length: lineCount }, (_, i) => (
489562
<div
490563
key={i}
491564
className='font-medium font-mono text-[var(--text-muted)] text-xs'
@@ -494,26 +567,60 @@ export function FieldFormat({
494567
{i + 1}
495568
</div>
496569
))
497-
}
498570

499571
return (
500-
<Code.Container className='min-h-[120px]'>
501-
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
502-
<Code.Content paddingLeft={`${gutterWidth}px`}>
503-
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
504-
{
505-
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
572+
<div className='flex flex-col gap-1.5'>
573+
<div className='flex justify-end'>
574+
<Button
575+
type='button'
576+
variant='ghost'
577+
onClick={() =>
578+
setFileFieldModes((prev) => ({
579+
...prev,
580+
[field.id]: mode === 'upload' ? 'json' : 'upload',
581+
}))
506582
}
507-
</Code.Placeholder>
508-
<Editor
509-
value={fieldValue}
510-
onValueChange={getEditorValueChangeHandler(field.id)}
511-
highlight={jsonHighlight}
512583
disabled={isReadOnly}
513-
{...getCodeEditorProps({ disabled: isReadOnly })}
584+
className='h-auto p-0 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-body)]'
585+
>
586+
{mode === 'upload' ? 'Enter JSON manually' : 'Use file uploader'}
587+
</Button>
588+
</div>
589+
{mode === 'upload' ? (
590+
<FileUpload
591+
blockId={blockId}
592+
subBlockId={subBlockId}
593+
multiple
594+
disabled={isReadOnly}
595+
value={filesToControlValue(currentFiles)}
596+
onValueChange={(next) =>
597+
updateField(
598+
field.id,
599+
'value',
600+
serializeInputFormatFiles(controlValueToFiles(next, currentFiles))
601+
)
602+
}
514603
/>
515-
</Code.Content>
516-
</Code.Container>
604+
) : (
605+
<Code.Container className='min-h-[120px]'>
606+
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
607+
<Code.Content paddingLeft={`${gutterWidth}px`}>
608+
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
609+
{
610+
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
611+
}
612+
</Code.Placeholder>
613+
<Editor
614+
value={fieldValue}
615+
onValueChange={getEditorValueChangeHandler(field.id)}
616+
highlight={jsonHighlight}
617+
disabled={isReadOnly}
618+
{...getCodeEditorProps({ disabled: isReadOnly })}
619+
/>
620+
</Code.Content>
621+
</Code.Container>
622+
)}
623+
</div>
517624
)
518625
}
519626

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
1313
import { processStreamingBlockLogs } from '@/lib/tokenization'
1414
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
1515
import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events'
16+
import {
17+
type InputFormatFile,
18+
isFileFieldType,
19+
parseInputFormatFiles,
20+
} from '@/lib/workflows/input-format'
1621
import {
1722
extractTriggerMockPayload,
1823
selectBestTrigger,
@@ -948,13 +953,23 @@ export function useWorkflowExecution() {
948953
selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
949954
}
950955

951-
// Helper to extract test values from inputFormat subblock
956+
/**
957+
* Extracts test values from the inputFormat subblock. File fields are
958+
* excluded here — they flow through the dedicated `files` channel (see
959+
* extractFilesFromInputFormat) rather than as named structured inputs.
960+
*/
952961
const extractTestValuesFromInputFormat = (inputFormatValue: any): Record<string, any> => {
953962
const testInput: Record<string, any> = {}
954963

955964
if (Array.isArray(inputFormatValue)) {
956965
inputFormatValue.forEach((field: any) => {
957-
if (field && typeof field === 'object' && field.name && field.value !== undefined) {
966+
if (
967+
field &&
968+
typeof field === 'object' &&
969+
field.name &&
970+
field.value !== undefined &&
971+
!isFileFieldType(field.type)
972+
) {
958973
testInput[field.name] = coerceValue(field.type, field.value)
959974
}
960975
})
@@ -963,6 +978,20 @@ export function useWorkflowExecution() {
963978
return testInput
964979
}
965980

981+
/**
982+
* Collects editor-attached files from file-typed inputFormat fields. These
983+
* are already uploaded to workspace storage, so they pass straight to the
984+
* executor's file channel (normalizeStartFile) without a re-upload.
985+
*/
986+
const extractFilesFromInputFormat = (inputFormatValue: any): InputFormatFile[] => {
987+
if (!Array.isArray(inputFormatValue)) return []
988+
return inputFormatValue.flatMap((field: any) =>
989+
field && typeof field === 'object' && isFileFieldType(field.type)
990+
? parseInputFormatFiles(field.value)
991+
: []
992+
)
993+
}
994+
966995
// Determine start block and workflow input based on execution type
967996
let startBlockId: string | undefined
968997
let finalWorkflowInput = workflowInput
@@ -1054,6 +1083,10 @@ export function useWorkflowExecution() {
10541083
) {
10551084
const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value
10561085
const testInput = extractTestValuesFromInputFormat(inputFormatValue)
1086+
const inputFiles = extractFilesFromInputFormat(inputFormatValue)
1087+
if (inputFiles.length > 0) {
1088+
testInput.files = inputFiles
1089+
}
10571090
if (Object.keys(testInput).length > 0) {
10581091
finalWorkflowInput = testInput
10591092
}

0 commit comments

Comments
 (0)