mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(input-format): upload files in file fields via the file uploader (#5297)
* 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. * refactor(input-format): extract file adapters, share run-input builder, harden types Follow-up cleanup from /simplify + /cleanup review: - Extract pure file adapters into input-format-files.ts (filesToControlValue, controlValueToFiles, serializeInputFormatFiles, defaultFileFieldMode) so they are unit-tested without a DOM; add tests. - Derive InputFormatFile from the canonical executor UserFile so the editor and runtime file shapes can't drift. - Consolidate the two manual run-input builders into one buildInputFormatInput helper so manual run and run-from-block handle files identically. - Narrow file-field detection to the canonical file[] (matches the field-type dropdown and the existing execution/webhook file paths) so no pre-existing non-file[] field changes behavior. - defaultFileFieldMode parses once; the file value is only parsed in upload mode. - Add lib/component unit tests (45 passing). * fix(input-format): require run-ready file shape, don't clobber named files input Addresses review (Greptile P1s): - parseInputFormatFiles now requires the full run-ready shape (id/name/url + finite size + type), so a partial file can't open in uploader mode or reach workflowInput.files only to be rejected by normalizeStartFile (which would silently drop every file). - buildInputFormatInput no longer overwrites a user field literally named 'files' with the upload channel; that reserved-name collision is left for the executor to surface. * fix(input-format): require file key, keep mixed/partial values in JSON mode Addresses review round 2 (Greptile P1 + Cursor): - parseInputFormatFiles now requires a non-empty key (the uploader always sets one); an external/signed URL with no recoverable key no longer opens uploader mode or reaches workflowInput.files only to be rejected. - defaultFileFieldMode uses the uploader only when EVERY entry is run-ready; a mixed array with any legacy/partial entry stays in JSON mode so the uploader can't drop the entries it can't represent on the next save. * fix(input-format): recover internal-url keys, gate uploader to lossless values Addresses review round 3 (Greptile P1 + Cursor): - parseInputFormatFiles accepts a key-less file when its url is an internal /api/files/serve/... url (the executor recovers the key from it, same as normalizeStartFile); only a missing key with a non-internal url is rejected. - The file field offers the uploader only when it can represent the stored value losslessly (empty or all run-ready); for mixed/legacy values it forces JSON mode and hides the toggle, so a sticky 'upload' preference can no longer drop entries the uploader cannot show on save. * fix(input-format): require non-empty id/name/url/type in parseInputFormatFiles Addresses review round 4 (Greptile): normalizeStartFile rejects falsy id/name/url/type and file normalization is all-or-nothing, so an empty-string field (e.g. hand-edited value with an empty id + internal url) would open in uploader mode and drop every file from the run. Require non-empty strings to match the executor. * fix(input-format): validate recovered internal-url keys via parseInternalFileUrl Addresses review round 5 (Greptile): isInternalFileUrl only checks the URL prefix, but the executor parses+decodes the key. A malformed internal URL (no extractable key) passed the prefix check, got collected into workflowInput.files, then normalizeStartFile rejected it and dropped the whole files array. Now mirror the executor exactly — attempt parseInternalFileUrl and require a real key. Tests use a realistic recoverable internal URL. * fix(input-format): uploaded files always own the files channel Addresses review round 6 (Greptile): files is NOT in the executor's reserved input-format names (EXECUTION_CONTROL_OUTPUT_FIELD_NAMES), so the previous guard silently dropped uploaded file[] values whenever a plain field named files coexisted. files is the start block's canonical file channel (the chat trigger names its own file field files), so uploaded files now always populate it and take precedence — dropping real attachments is the worse outcome, and making files a reserved-name error isn't viable since it's the legitimate file-field name. * chore(input-format): tighten parseInputFormatFiles comment
This commit is contained in:
+32
-13
@@ -39,9 +39,17 @@ interface FileUploadProps {
|
||||
isPreview?: boolean
|
||||
previewValue?: any | null
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Controlled value. When `onValueChange` is provided the component reads from
|
||||
* this prop and writes through `onValueChange` instead of the subblock store,
|
||||
* letting it be embedded where the value lives outside a subblock (e.g. a
|
||||
* single field inside the input-format editor).
|
||||
*/
|
||||
value?: UploadedFile | UploadedFile[] | null
|
||||
onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
export interface UploadedFile {
|
||||
name: string
|
||||
path: string
|
||||
key?: string
|
||||
@@ -165,9 +173,25 @@ export function FileUpload({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
value: controlledValue,
|
||||
onValueChange,
|
||||
}: FileUploadProps) {
|
||||
const activeSearchTarget = useActiveSearchTarget()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const isControlled = onValueChange !== undefined
|
||||
|
||||
/**
|
||||
* Persists a new value. In controlled mode the caller owns persistence; in
|
||||
* store mode we write through the subblock store and notify collaborators.
|
||||
*/
|
||||
const commitValue = (next: UploadedFile | UploadedFile[] | null) => {
|
||||
if (isControlled) {
|
||||
onValueChange(next)
|
||||
return
|
||||
}
|
||||
setStoreValue(next)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
}
|
||||
const [modelValue] = useSubBlockValue(blockId, 'model')
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
@@ -191,7 +215,7 @@ export function FileUpload({
|
||||
const uploadFileMutation = useUploadWorkspaceFile()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const value = isControlled ? controlledValue : isPreview ? previewValue : storeValue
|
||||
|
||||
const maxSizeInBytes = useMemo(() => {
|
||||
const fallback = maxSize * 1024 * 1024
|
||||
@@ -413,11 +437,9 @@ export function FileUpload({
|
||||
|
||||
const newFiles = Array.from(uniqueFiles.values())
|
||||
|
||||
setStoreValue(newFiles)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
commitValue(newFiles)
|
||||
} else {
|
||||
setStoreValue(uploadedFiles[0] || null)
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
commitValue(uploadedFiles[0] || null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(getErrorMessage(error, 'Failed to upload file(s)'), activeWorkflowId)
|
||||
@@ -459,12 +481,11 @@ export function FileUpload({
|
||||
uniqueFiles.set(uploadedFile.path, uploadedFile)
|
||||
const newFiles = Array.from(uniqueFiles.values())
|
||||
|
||||
setStoreValue(newFiles)
|
||||
commitValue(newFiles)
|
||||
} else {
|
||||
setStoreValue(uploadedFile)
|
||||
commitValue(uploadedFile)
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
logger.info(`Selected workspace file: ${selectedFile.name}`, activeWorkflowId)
|
||||
}
|
||||
|
||||
@@ -501,12 +522,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)
|
||||
commitValue(updatedFiles.length > 0 ? updatedFiles : null)
|
||||
} else {
|
||||
setStoreValue(null)
|
||||
commitValue(null)
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
} catch (error) {
|
||||
logger.error(getErrorMessage(error, 'Failed to remove file'), activeWorkflowId)
|
||||
} finally {
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { InputFormatFile } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
controlValueToFiles,
|
||||
defaultFileFieldMode,
|
||||
filesToControlValue,
|
||||
serializeInputFormatFiles,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files'
|
||||
|
||||
const file: InputFormatFile = {
|
||||
id: 'f1',
|
||||
name: 'doc.pdf',
|
||||
url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace',
|
||||
key: 'key',
|
||||
size: 10,
|
||||
type: 'application/pdf',
|
||||
}
|
||||
|
||||
describe('filesToControlValue', () => {
|
||||
it.concurrent('maps url -> path for the FileUpload value shape', () => {
|
||||
expect(filesToControlValue([file])).toEqual([
|
||||
{
|
||||
name: file.name,
|
||||
path: file.url,
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.concurrent('round-trips through controlValueToFiles without data loss', () => {
|
||||
expect(controlValueToFiles(filesToControlValue([file]), [file])).toEqual([file])
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlValueToFiles', () => {
|
||||
it.concurrent('preserves the stable id of an existing file (matched by key)', () => {
|
||||
const control = [
|
||||
{ name: 'doc.pdf', path: '/moved', key: 'key', size: 10, type: 'application/pdf' },
|
||||
]
|
||||
expect(controlValueToFiles(control, [file])[0].id).toBe('f1')
|
||||
})
|
||||
|
||||
it.concurrent('matches an existing file by url when key is absent', () => {
|
||||
const control = [{ name: 'doc.pdf', path: file.url, size: 10, type: 'application/pdf' }]
|
||||
expect(controlValueToFiles(control, [file])[0].id).toBe('f1')
|
||||
})
|
||||
|
||||
it.concurrent('generates an id for a newly added file', () => {
|
||||
const control = [
|
||||
{
|
||||
name: 'new.pdf',
|
||||
path: '/api/files/serve/new',
|
||||
key: 'new',
|
||||
size: 5,
|
||||
type: 'application/pdf',
|
||||
},
|
||||
]
|
||||
const result = controlValueToFiles(control, [file])
|
||||
expect(result[0].id).toEqual(expect.any(String))
|
||||
expect(result[0].id).not.toBe('f1')
|
||||
expect(result[0].url).toBe('/api/files/serve/new')
|
||||
})
|
||||
|
||||
it.concurrent('normalizes a single object or null to an array', () => {
|
||||
const single = {
|
||||
name: file.name,
|
||||
path: file.url,
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}
|
||||
expect(controlValueToFiles(single, [file])).toEqual([file])
|
||||
expect(controlValueToFiles(null, [file])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeInputFormatFiles', () => {
|
||||
it.concurrent('serializes to JSON that parses back to the same files', () => {
|
||||
expect(JSON.parse(serializeInputFormatFiles([file]))).toEqual([file])
|
||||
})
|
||||
|
||||
it.concurrent('returns an empty string for no files', () => {
|
||||
expect(serializeInputFormatFiles([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultFileFieldMode', () => {
|
||||
it.concurrent('defaults to upload for empty or whitespace values', () => {
|
||||
expect(defaultFileFieldMode(undefined)).toBe('upload')
|
||||
expect(defaultFileFieldMode('')).toBe('upload')
|
||||
expect(defaultFileFieldMode(' ')).toBe('upload')
|
||||
})
|
||||
|
||||
it.concurrent('uses upload for an empty array or run-ready files', () => {
|
||||
expect(defaultFileFieldMode('[]')).toBe('upload')
|
||||
expect(defaultFileFieldMode(JSON.stringify([file]))).toBe('upload')
|
||||
})
|
||||
|
||||
it.concurrent('falls back to json for legacy free-form values (no data loss)', () => {
|
||||
expect(defaultFileFieldMode('C:/Users/x/budget.xlsx')).toBe('json')
|
||||
expect(defaultFileFieldMode('[{"data":"<base64>","name":"x.pdf"}]')).toBe('json')
|
||||
expect(defaultFileFieldMode('{"csv":"a,b,c"}')).toBe('json')
|
||||
})
|
||||
|
||||
it.concurrent('uses json when only some entries are run-ready (no silent drop)', () => {
|
||||
expect(defaultFileFieldMode(JSON.stringify([file, { name: 'legacy-only' }]))).toBe('json')
|
||||
})
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type InputFormatFile, parseInputFormatFiles } from '@/lib/workflows/input-format'
|
||||
import type { UploadedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'
|
||||
|
||||
/**
|
||||
* Pure adapters bridging a file-typed input-format field's stored value (a JSON
|
||||
* string of run-ready {@link InputFormatFile} objects) and the {@link FileUpload}
|
||||
* component's value shape (which keys off `path`). Kept separate from the React
|
||||
* component so they can be unit-tested without a DOM.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps stored run-ready file objects to the {@link FileUpload} value shape.
|
||||
*/
|
||||
export function filesToControlValue(files: InputFormatFile[]): UploadedFile[] {
|
||||
return files.map((file) => ({
|
||||
name: file.name,
|
||||
path: file.url,
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a {@link FileUpload} value back to stored run-ready file objects,
|
||||
* preserving the stable `id` of files that were already present.
|
||||
*/
|
||||
export function controlValueToFiles(
|
||||
value: UploadedFile | UploadedFile[] | null,
|
||||
previous: InputFormatFile[]
|
||||
): InputFormatFile[] {
|
||||
const uploaded = Array.isArray(value) ? value : value ? [value] : []
|
||||
return uploaded.map((file) => {
|
||||
const existing = previous.find(
|
||||
(prev) => (file.key && prev.key === file.key) || prev.url === file.path
|
||||
)
|
||||
return {
|
||||
id: existing?.id ?? generateId(),
|
||||
name: file.name,
|
||||
url: file.path,
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes run-ready file objects into a field value string (empty when none).
|
||||
*/
|
||||
export function serializeInputFormatFiles(files: InputFormatFile[]): string {
|
||||
return files.length > 0 ? JSON.stringify(files, null, 2) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Default editor mode for a file field: the uploader, unless the stored value is
|
||||
* legacy free-form content (raw text or a non-file array) that only the JSON
|
||||
* editor can represent without data loss.
|
||||
*/
|
||||
export function defaultFileFieldMode(value: string | undefined): 'upload' | 'json' {
|
||||
if (!value || !value.trim()) return 'upload'
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
return 'json'
|
||||
}
|
||||
if (!Array.isArray(parsed)) return 'json'
|
||||
if (parsed.length === 0) return 'upload'
|
||||
// Only use the uploader when EVERY entry is run-ready; if any entry is legacy
|
||||
// or partial, stay in JSON mode so the uploader can't drop the entries it
|
||||
// cannot represent when the field is next saved.
|
||||
return parseInputFormatFiles(parsed).length === parsed.length ? 'upload' : 'json'
|
||||
}
|
||||
+86
-24
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -19,8 +19,19 @@ import {
|
||||
import { Trash } from '@sim/emcn/icons'
|
||||
import { Plus } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
createDefaultInputFormatField,
|
||||
isFileFieldType,
|
||||
parseInputFormatFiles,
|
||||
} from '@/lib/workflows/input-format'
|
||||
import { FileUpload } 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 {
|
||||
controlValueToFiles,
|
||||
defaultFileFieldMode,
|
||||
filesToControlValue,
|
||||
serializeInputFormatFiles,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files'
|
||||
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
|
||||
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
|
||||
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
|
||||
@@ -101,6 +112,7 @@ export function FieldFormat({
|
||||
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
|
||||
const nameOverlayRefs = useRef<Record<string, HTMLDivElement>>({})
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const [fileFieldModes, setFileFieldModes] = useState<Record<string, 'upload' | 'json'>>({})
|
||||
|
||||
const inputController = useSubBlockInput({
|
||||
blockId,
|
||||
@@ -480,12 +492,60 @@ export function FieldFormat({
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'file[]') {
|
||||
if (isFileFieldType(field.type)) {
|
||||
// The uploader is only offered when it can represent the stored value
|
||||
// losslessly (empty or all run-ready). For mixed/legacy values it would
|
||||
// drop the entries it can't show on save, so we force JSON mode and hide
|
||||
// the toggle until the value is cleared or made fully run-ready.
|
||||
const canUseUploader = defaultFileFieldMode(field.value) === 'upload'
|
||||
const mode = canUseUploader ? (fileFieldModes[field.id] ?? 'upload') : 'json'
|
||||
|
||||
const modeToggle = canUseUploader ? (
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() =>
|
||||
setFileFieldModes((prev) => ({
|
||||
...prev,
|
||||
[field.id]: mode === 'upload' ? 'json' : 'upload',
|
||||
}))
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
className='h-auto p-0 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-body)]'
|
||||
>
|
||||
{mode === 'upload' ? 'Enter JSON manually' : 'Use file uploader'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
if (mode === 'upload') {
|
||||
const currentFiles = parseInputFormatFiles(field.value)
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{modeToggle}
|
||||
<FileUpload
|
||||
blockId={blockId}
|
||||
subBlockId={subBlockId}
|
||||
multiple
|
||||
disabled={isReadOnly}
|
||||
value={filesToControlValue(currentFiles)}
|
||||
onValueChange={(next) =>
|
||||
updateField(
|
||||
field.id,
|
||||
'value',
|
||||
serializeInputFormatFiles(controlValueToFiles(next, currentFiles))
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const lineCount = fieldValue.split('\n').length
|
||||
const gutterWidth = calculateGutterWidth(lineCount)
|
||||
|
||||
const renderLineNumbers = () => {
|
||||
return Array.from({ length: lineCount }, (_, i) => (
|
||||
const renderLineNumbers = () =>
|
||||
Array.from({ length: lineCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='font-medium font-mono text-[var(--text-muted)] text-xs'
|
||||
@@ -494,26 +554,28 @@ export function FieldFormat({
|
||||
{i + 1}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Code.Container className='min-h-[120px]'>
|
||||
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
|
||||
<Code.Content paddingLeft={`${gutterWidth}px`}>
|
||||
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
|
||||
{
|
||||
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
|
||||
}
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
</Code.Content>
|
||||
</Code.Container>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{modeToggle}
|
||||
<Code.Container className='min-h-[120px]'>
|
||||
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
|
||||
<Code.Content paddingLeft={`${gutterWidth}px`}>
|
||||
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
|
||||
{
|
||||
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
|
||||
}
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={fieldValue}
|
||||
onValueChange={getEditorValueChangeHandler(field.id)}
|
||||
highlight={jsonHighlight}
|
||||
disabled={isReadOnly}
|
||||
{...getCodeEditorProps({ disabled: isReadOnly })}
|
||||
/>
|
||||
</Code.Content>
|
||||
</Code.Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+42
-30
@@ -13,6 +13,7 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
|
||||
import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events'
|
||||
import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
extractTriggerMockPayload,
|
||||
selectBestTrigger,
|
||||
@@ -138,6 +139,41 @@ function normalizeErrorMessage(error: unknown): string {
|
||||
return WORKFLOW_EXECUTION_FAILURE_MESSAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the manual-run workflow input from a trigger's inputFormat subblock.
|
||||
* Named fields are coerced by type; file-typed fields are excluded as named
|
||||
* inputs and routed to the dedicated `files` channel (already uploaded, so they
|
||||
* pass straight to the executor's normalizeStartFile). Returns undefined when
|
||||
* the format yields nothing. Shared by every manual entry path so they stay
|
||||
* consistent.
|
||||
*/
|
||||
function buildInputFormatInput(inputFormatValue: unknown): Record<string, any> | undefined {
|
||||
if (!Array.isArray(inputFormatValue)) return undefined
|
||||
|
||||
const testInput: Record<string, any> = {}
|
||||
for (const field of inputFormatValue) {
|
||||
if (
|
||||
field &&
|
||||
typeof field === 'object' &&
|
||||
field.name &&
|
||||
field.value !== undefined &&
|
||||
!isFileFieldType(field.type)
|
||||
) {
|
||||
testInput[field.name] = coerceValue(field.type, field.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Route file[] fields to the dedicated `files` channel. `files` is the start
|
||||
// block's canonical file channel (the chat trigger names its own file field
|
||||
// `files`), so uploaded files must own it and take precedence over a plain
|
||||
// field that happens to be named `files` — dropping real attachments would be
|
||||
// the worse outcome.
|
||||
const files = collectInputFormatFiles(inputFormatValue)
|
||||
if (files.length > 0) testInput.files = files
|
||||
|
||||
return Object.keys(testInput).length > 0 ? testInput : undefined
|
||||
}
|
||||
|
||||
export function useWorkflowExecution() {
|
||||
const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>()
|
||||
const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId)
|
||||
@@ -948,21 +984,6 @@ export function useWorkflowExecution() {
|
||||
selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
|
||||
}
|
||||
|
||||
// Helper to extract test values from inputFormat subblock
|
||||
const extractTestValuesFromInputFormat = (inputFormatValue: any): Record<string, any> => {
|
||||
const testInput: Record<string, any> = {}
|
||||
|
||||
if (Array.isArray(inputFormatValue)) {
|
||||
inputFormatValue.forEach((field: any) => {
|
||||
if (field && typeof field === 'object' && field.name && field.value !== undefined) {
|
||||
testInput[field.name] = coerceValue(field.type, field.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return testInput
|
||||
}
|
||||
|
||||
// Determine start block and workflow input based on execution type
|
||||
let startBlockId: string | undefined
|
||||
let finalWorkflowInput = workflowInput
|
||||
@@ -1052,10 +1073,9 @@ export function useWorkflowExecution() {
|
||||
selectedCandidate.path === StartBlockPath.SPLIT_INPUT ||
|
||||
selectedCandidate.path === StartBlockPath.UNIFIED
|
||||
) {
|
||||
const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value
|
||||
const testInput = extractTestValuesFromInputFormat(inputFormatValue)
|
||||
if (Object.keys(testInput).length > 0) {
|
||||
finalWorkflowInput = testInput
|
||||
const builtInput = buildInputFormatInput(selectedTrigger.subBlocks?.inputFormat?.value)
|
||||
if (builtInput) {
|
||||
finalWorkflowInput = builtInput
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1807,17 +1827,9 @@ export function useWorkflowExecution() {
|
||||
candidate.path === StartBlockPath.SPLIT_INPUT ||
|
||||
candidate.path === StartBlockPath.UNIFIED
|
||||
) {
|
||||
const inputFormatValue = candidate.block.subBlocks?.inputFormat?.value
|
||||
if (Array.isArray(inputFormatValue)) {
|
||||
const testInput: Record<string, any> = {}
|
||||
inputFormatValue.forEach((field: any) => {
|
||||
if (field && typeof field === 'object' && field.name && field.value !== undefined) {
|
||||
testInput[field.name] = coerceValue(field.type, field.value)
|
||||
}
|
||||
})
|
||||
if (Object.keys(testInput).length > 0) {
|
||||
workflowInput = testInput
|
||||
}
|
||||
const builtInput = buildInputFormatInput(candidate.block.subBlocks?.inputFormat?.value)
|
||||
if (builtInput) {
|
||||
workflowInput = builtInput
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectInputFormatFiles,
|
||||
createDefaultInputFormatField,
|
||||
extractInputFieldsFromBlocks,
|
||||
isFileFieldType,
|
||||
normalizeInputFormatValue,
|
||||
parseInputFormatFiles,
|
||||
} from '@/lib/workflows/input-format'
|
||||
|
||||
describe('extractInputFieldsFromBlocks', () => {
|
||||
@@ -229,6 +232,136 @@ describe('normalizeInputFormatValue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFileFieldType', () => {
|
||||
it.concurrent('matches the canonical file[] type', () => {
|
||||
expect(isFileFieldType('file[]')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('does not match legacy variants or other types (no behavior change)', () => {
|
||||
expect(isFileFieldType('files')).toBe(false)
|
||||
expect(isFileFieldType('file')).toBe(false)
|
||||
expect(isFileFieldType('image')).toBe(false)
|
||||
expect(isFileFieldType('string')).toBe(false)
|
||||
expect(isFileFieldType('array')).toBe(false)
|
||||
expect(isFileFieldType(undefined)).toBe(false)
|
||||
expect(isFileFieldType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInputFormatFiles', () => {
|
||||
const file = {
|
||||
id: 'f1',
|
||||
name: 'doc.pdf',
|
||||
url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace',
|
||||
key: 'key',
|
||||
size: 10,
|
||||
type: 'application/pdf',
|
||||
}
|
||||
|
||||
it.concurrent('parses a JSON string of run-ready files', () => {
|
||||
expect(parseInputFormatFiles(JSON.stringify([file]))).toEqual([file])
|
||||
})
|
||||
|
||||
it.concurrent('accepts an already-materialized array', () => {
|
||||
expect(parseInputFormatFiles([file])).toEqual([file])
|
||||
})
|
||||
|
||||
it.concurrent('returns empty for blank, invalid, or non-array values', () => {
|
||||
expect(parseInputFormatFiles('')).toEqual([])
|
||||
expect(parseInputFormatFiles(' ')).toEqual([])
|
||||
expect(parseInputFormatFiles(undefined)).toEqual([])
|
||||
expect(parseInputFormatFiles('not json')).toEqual([])
|
||||
expect(parseInputFormatFiles('{"name":"x"}')).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('drops legacy entries missing id/url (base64 placeholder, raw text)', () => {
|
||||
expect(
|
||||
parseInputFormatFiles(
|
||||
JSON.stringify([{ data: '<base64>', type: 'file', name: 'document.pdf', mime: 'x' }])
|
||||
)
|
||||
).toEqual([])
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ name: 'doc.pdf', path: '/legacy' }]))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('keeps only the valid files in a mixed array', () => {
|
||||
expect(parseInputFormatFiles(JSON.stringify([file, { name: 'bad' }]))).toEqual([file])
|
||||
})
|
||||
|
||||
it.concurrent('rejects partial files missing the run-ready size/type', () => {
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ id: 'x', name: 'a.pdf', url: '/u' }]))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(
|
||||
parseInputFormatFiles(
|
||||
JSON.stringify([{ id: 'x', name: 'a.pdf', url: '/u', size: Number.NaN, type: 'x' }])
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('rejects files with no key and a non-internal url (key unrecoverable)', () => {
|
||||
const { key, ...noKey } = file
|
||||
const external = { ...noKey, url: 'https://example.com/x.pdf' }
|
||||
expect(parseInputFormatFiles(JSON.stringify([external]))).toEqual([])
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ ...external, key: '' }]))).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('accepts a key-less file when the url is an internal serve url', () => {
|
||||
const { key, ...noKey } = file
|
||||
expect(parseInputFormatFiles(JSON.stringify([noKey]))).toEqual([noKey])
|
||||
})
|
||||
|
||||
it.concurrent('rejects a key-less file whose internal url yields no key', () => {
|
||||
const { key, ...noKey } = file
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ ...noKey, url: '/api/files/serve/' }]))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('rejects empty-string id/name/url/type (executor treats them as falsy)', () => {
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ ...file, id: '' }]))).toEqual([])
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ ...file, name: '' }]))).toEqual([])
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ ...file, url: '' }]))).toEqual([])
|
||||
expect(parseInputFormatFiles(JSON.stringify([{ ...file, type: '' }]))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectInputFormatFiles', () => {
|
||||
const file = {
|
||||
id: 'f1',
|
||||
name: 'doc.pdf',
|
||||
url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace',
|
||||
key: 'key',
|
||||
size: 10,
|
||||
type: 'application/pdf',
|
||||
}
|
||||
|
||||
it.concurrent('returns empty for non-array values', () => {
|
||||
expect(collectInputFormatFiles(null)).toEqual([])
|
||||
expect(collectInputFormatFiles('nope')).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('collects files only from file[] fields, ignoring other types', () => {
|
||||
const value = [
|
||||
{ name: 'query', type: 'string', value: 'hi' },
|
||||
{ name: 'a', type: 'file[]', value: JSON.stringify([file]) },
|
||||
{ name: 'b', type: 'file[]', value: JSON.stringify([{ ...file, id: 'f2' }]) },
|
||||
{ name: 'legacy', type: 'files', value: JSON.stringify([{ ...file, id: 'ignored' }]) },
|
||||
]
|
||||
expect(collectInputFormatFiles(value).map((f) => f.id)).toEqual(['f1', 'f2'])
|
||||
})
|
||||
|
||||
it.concurrent('ignores legacy/unparseable file values', () => {
|
||||
const value = [
|
||||
{ name: 'a', type: 'file[]', value: 'C:/Users/x/budget.xlsx' },
|
||||
{ name: 'b', type: 'file[]', value: '[{"data":"<base64>"}]' },
|
||||
{ name: 'c', type: 'file[]', value: '' },
|
||||
]
|
||||
expect(collectInputFormatFiles(value)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDefaultInputFormatField', () => {
|
||||
it.concurrent('creates an empty field with the canonical default shape', () => {
|
||||
const field = createDefaultInputFormatField()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file-utils'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
|
||||
/**
|
||||
* Simplified input field representation for workflow input mapping
|
||||
@@ -41,6 +43,100 @@ export function createDefaultInputFormatField(): InputFormatFieldState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an input-format field type denotes a file input. Matches the canonical
|
||||
* `file[]` written by the field-type dropdown — the same literal the execution
|
||||
* and webhook file paths already key off (`lib/execution/files.ts`,
|
||||
* `lib/webhooks/providers/generic.ts`) — so the editor and runtime agree and no
|
||||
* existing non-`file[]` field changes behavior.
|
||||
*/
|
||||
export function isFileFieldType(type: string | null | undefined): boolean {
|
||||
return type === 'file[]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-ready file object stored as a file field's value. Derived from the
|
||||
* executor's canonical {@link UserFile} (validated by `normalizeStartFile`) so
|
||||
* editor-attached files flow into a run unchanged and the shape can't drift.
|
||||
*/
|
||||
export type InputFormatFile = Pick<UserFile, 'id' | 'name' | 'url' | 'size' | 'type'> &
|
||||
Pick<Partial<UserFile>, 'key'>
|
||||
|
||||
/**
|
||||
* Whether a file's key is usable at run time: an explicit non-empty `key`, or an
|
||||
* internal `/api/files/serve/...` URL the key can actually be parsed from. This
|
||||
* mirrors `normalizeStartFile` exactly (including the parse, so a malformed
|
||||
* internal URL is rejected rather than accepted on the prefix alone).
|
||||
*/
|
||||
function hasRecoverableFileKey(file: InputFormatFile): boolean {
|
||||
if (typeof file.key === 'string' && file.key.length > 0) return true
|
||||
if (typeof file.url !== 'string' || !isInternalFileUrl(file.url)) return false
|
||||
try {
|
||||
return parseInternalFileUrl(file.url).key.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerantly parses a file field's stored value (a JSON string, or an already
|
||||
* materialized array) into run-ready file objects. Returns an empty array for
|
||||
* legacy free-form values (base64 placeholders, raw text) that don't describe
|
||||
* uploaded files, so callers degrade gracefully instead of throwing.
|
||||
*/
|
||||
export function parseInputFormatFiles(value: unknown): InputFormatFile[] {
|
||||
let raw: unknown = value
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return []
|
||||
try {
|
||||
raw = JSON.parse(trimmed)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
return raw.filter((file): file is InputFormatFile => {
|
||||
if (file === null || typeof file !== 'object') return false
|
||||
const f = file as InputFormatFile
|
||||
// Accept only the run-ready shape `normalizeStartFile` accepts (non-empty
|
||||
// id/name/url/type + finite size + recoverable key); file normalization is
|
||||
// all-or-nothing, so anything short of this falls back to the JSON editor
|
||||
// rather than silently dropping every file at run time.
|
||||
return (
|
||||
typeof f.id === 'string' &&
|
||||
f.id.length > 0 &&
|
||||
typeof f.name === 'string' &&
|
||||
f.name.length > 0 &&
|
||||
typeof f.url === 'string' &&
|
||||
f.url.length > 0 &&
|
||||
typeof f.size === 'number' &&
|
||||
Number.isFinite(f.size) &&
|
||||
typeof f.type === 'string' &&
|
||||
f.type.length > 0 &&
|
||||
hasRecoverableFileKey(f)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all editor-attached files from the file-typed fields of an
|
||||
* inputFormat value. Files are already uploaded (run-ready), so callers can pass
|
||||
* them straight to the executor's file channel without a re-upload.
|
||||
*/
|
||||
export function collectInputFormatFiles(inputFormatValue: unknown): InputFormatFile[] {
|
||||
if (!Array.isArray(inputFormatValue)) return []
|
||||
return inputFormatValue.flatMap((field) =>
|
||||
field &&
|
||||
typeof field === 'object' &&
|
||||
isFileFieldType((field as { type?: unknown }).type as string)
|
||||
? parseInputFormatFiles((field as { value?: unknown }).value)
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts input fields from workflow blocks.
|
||||
* Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat.
|
||||
|
||||
Reference in New Issue
Block a user