Skip to content

Commit 2d49892

Browse files
waleedlatif1waleed
authored andcommitted
feat(deployed-chat): added file upload to workflow execute API, added to deployed chat, updated chat panel (#1588)
* feat(deployed-chat): updated chat panel UI, deployed chat and API can now accept files * added nested tag dropdown for files * added duplicate file validation to chat panel * update docs & SDKs * fixed build * rm extraneous comments * ack PR comments, cut multiple DB roundtrips for permissions & api key checks in api/workflows * allow read-only users to access deployment info, but not take actions * add downloadable file to logs for files passed in via API * protect files/serve route that is only used client-side --------- Co-authored-by: waleed <waleed>
1 parent 8ce5a1b commit 2d49892

File tree

40 files changed

+2039
-655
lines changed

40 files changed

+2039
-655
lines changed

apps/docs/content/docs/en/sdks/typescript.mdx

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,14 +593,91 @@ async function executeClientSideWorkflow() {
593593
});
594594

595595
console.log('Workflow result:', result);
596-
596+
597597
// Update UI with result
598-
document.getElementById('result')!.textContent =
598+
document.getElementById('result')!.textContent =
599599
JSON.stringify(result.output, null, 2);
600600
} catch (error) {
601601
console.error('Error:', error);
602602
}
603603
}
604+
```
605+
606+
### File Upload
607+
608+
File objects are automatically detected and converted to base64 format. Include them in your input under the field name matching your workflow's API trigger input format.
609+
610+
The SDK converts File objects to this format:
611+
```typescript
612+
{
613+
type: 'file',
614+
data: 'data:mime/type;base64,base64data',
615+
name: 'filename',
616+
mime: 'mime/type'
617+
}
618+
```
619+
620+
Alternatively, you can manually provide files using the URL format:
621+
```typescript
622+
{
623+
type: 'url',
624+
data: 'https://example.com/file.pdf',
625+
name: 'file.pdf',
626+
mime: 'application/pdf'
627+
}
628+
```
629+
630+
<Tabs items={['Browser', 'Node.js']}>
631+
<Tab value="Browser">
632+
```typescript
633+
import { SimStudioClient } from 'simstudio-ts-sdk';
634+
635+
const client = new SimStudioClient({
636+
apiKey: process.env.NEXT_PUBLIC_SIM_API_KEY!
637+
});
638+
639+
// From file input
640+
async function handleFileUpload(event: Event) {
641+
const input = event.target as HTMLInputElement;
642+
const files = Array.from(input.files || []);
643+
644+
// Include files under the field name from your API trigger's input format
645+
const result = await client.executeWorkflow('workflow-id', {
646+
input: {
647+
documents: files, // Must match your workflow's "files" field name
648+
instructions: 'Analyze these documents'
649+
}
650+
});
651+
652+
console.log('Result:', result);
653+
}
654+
```
655+
</Tab>
656+
<Tab value="Node.js">
657+
```typescript
658+
import { SimStudioClient } from 'simstudio-ts-sdk';
659+
import fs from 'fs';
660+
661+
const client = new SimStudioClient({
662+
apiKey: process.env.SIM_API_KEY!
663+
});
664+
665+
// Read file and create File object
666+
const fileBuffer = fs.readFileSync('./document.pdf');
667+
const file = new File([fileBuffer], 'document.pdf', {
668+
type: 'application/pdf'
669+
});
670+
671+
// Include files under the field name from your API trigger's input format
672+
const result = await client.executeWorkflow('workflow-id', {
673+
input: {
674+
documents: [file], // Must match your workflow's "files" field name
675+
query: 'Summarize this document'
676+
}
677+
});
678+
```
679+
</Tab>
680+
</Tabs>
604681

605682
// Attach to button click
606683
document.getElementById('executeBtn')?.addEventListener('click', executeClientSideWorkflow);

apps/docs/content/docs/en/triggers/api.mdx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,17 @@ The API trigger exposes your workflow as a secure HTTP endpoint. Send JSON data
2222
/>
2323
</div>
2424

25-
Add an **Input Format** field for each parameter. Runtime output keys mirror the schema and are also available under `<api.input>`.
25+
Add an **Input Format** field for each parameter. Supported types:
2626

27-
Manual runs in the editor use the `value` column so you can test without sending a request. During execution the resolver populates both `<api.userId>` and `<api.input.userId>`.
27+
- **string** - Text values
28+
- **number** - Numeric values
29+
- **boolean** - True/false values
30+
- **json** - JSON objects
31+
- **files** - File uploads (access via `<api.fieldName[0].url>`, `<api.fieldName[0].name>`, etc.)
32+
33+
Runtime output keys mirror the schema and are available under `<api.input>`.
34+
35+
Manual runs in the editor use the `value` column so you can test without sending a request. During execution the resolver populates both `<api.fieldName>` and `<api.input.fieldName>`.
2836

2937
## Request Example
3038

@@ -123,6 +131,53 @@ data: {"blockId":"agent1-uuid","chunk":" complete"}
123131
| `<api.field>` | Field defined in the Input Format |
124132
| `<api.input>` | Entire structured request body |
125133

134+
### File Upload Format
135+
136+
The API accepts files in two formats:
137+
138+
**1. Base64-encoded files** (recommended for SDKs):
139+
```json
140+
{
141+
"documents": [{
142+
"type": "file",
143+
"data": "data:application/pdf;base64,JVBERi0xLjQK...",
144+
"name": "document.pdf",
145+
"mime": "application/pdf"
146+
}]
147+
}
148+
```
149+
- Maximum file size: 20MB per file
150+
- Files are uploaded to cloud storage and converted to UserFile objects with all properties
151+
152+
**2. Direct URL references**:
153+
```json
154+
{
155+
"documents": [{
156+
"type": "url",
157+
"data": "https://example.com/document.pdf",
158+
"name": "document.pdf",
159+
"mime": "application/pdf"
160+
}]
161+
}
162+
```
163+
- File is not uploaded, URL is passed through directly
164+
- Useful for referencing existing files
165+
166+
### File Properties
167+
168+
For files, access all properties:
169+
170+
| Property | Description | Type |
171+
|----------|-------------|------|
172+
| `<api.fieldName[0].url>` | Signed download URL | string |
173+
| `<api.fieldName[0].name>` | Original filename | string |
174+
| `<api.fieldName[0].size>` | File size in bytes | number |
175+
| `<api.fieldName[0].type>` | MIME type | string |
176+
| `<api.fieldName[0].uploadedAt>` | Upload timestamp (ISO 8601) | string |
177+
| `<api.fieldName[0].expiresAt>` | URL expiry timestamp (ISO 8601) | string |
178+
179+
For URL-referenced files, the same properties are available except `uploadedAt` and `expiresAt` since the file is not uploaded to our storage.
180+
126181
If no Input Format is defined, the executor exposes the raw JSON at `<api.input>` only.
127182

128183
<Callout type="warning">

apps/docs/content/docs/en/triggers/chat.mdx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,24 @@ The Chat trigger creates a conversational interface for your workflow. Deploy yo
2424

2525
The trigger writes three fields that downstream blocks can reference:
2626

27-
| Reference | Description |
28-
|-----------|-------------|
29-
| `<chat.input>` | Latest user message |
30-
| `<chat.conversationId>` | Conversation thread ID |
31-
| `<chat.files>` | Optional uploaded files |
32-
33-
Files include `name`, `mimeType`, and a signed download `url`.
27+
| Reference | Description | Type |
28+
|-----------|-------------|------|
29+
| `<chat.input>` | Latest user message | string |
30+
| `<chat.conversationId>` | Conversation thread ID | string |
31+
| `<chat.files>` | Optional uploaded files | files array |
32+
33+
### File Properties
34+
35+
Access individual file properties using array indexing:
36+
37+
| Property | Description | Type |
38+
|----------|-------------|------|
39+
| `<chat.files[0].url>` | Signed download URL | string |
40+
| `<chat.files[0].name>` | Original filename | string |
41+
| `<chat.files[0].size>` | File size in bytes | number |
42+
| `<chat.files[0].type>` | MIME type | string |
43+
| `<chat.files[0].uploadedAt>` | Upload timestamp (ISO 8601) | string |
44+
| `<chat.files[0].expiresAt>` | URL expiry timestamp (ISO 8601) | string |
3445

3546
## Usage Notes
3647

apps/sim/app/api/__test-utils__/utils.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,12 +1116,20 @@ export function createMockDatabase(options: MockDatabaseOptions = {}) {
11161116

11171117
const createUpdateChain = () => ({
11181118
set: vi.fn().mockImplementation(() => ({
1119-
where: vi.fn().mockImplementation(() => {
1120-
if (updateOptions.throwError) {
1121-
return Promise.reject(createDbError('update', updateOptions.errorMessage))
1122-
}
1123-
return Promise.resolve(updateOptions.results)
1124-
}),
1119+
where: vi.fn().mockImplementation(() => ({
1120+
returning: vi.fn().mockImplementation(() => {
1121+
if (updateOptions.throwError) {
1122+
return Promise.reject(createDbError('update', updateOptions.errorMessage))
1123+
}
1124+
return Promise.resolve(updateOptions.results)
1125+
}),
1126+
then: vi.fn().mockImplementation((resolve) => {
1127+
if (updateOptions.throwError) {
1128+
return Promise.reject(createDbError('update', updateOptions.errorMessage))
1129+
}
1130+
return Promise.resolve(updateOptions.results).then(resolve)
1131+
}),
1132+
})),
11251133
})),
11261134
})
11271135

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createLogger } from '@/lib/logs/console/logger'
66
import { generateRequestId } from '@/lib/utils'
77
import {
88
addCorsHeaders,
9+
processChatFiles,
910
setChatAuthCookie,
1011
validateAuthToken,
1112
validateChatAuth,
@@ -75,7 +76,7 @@ export async function POST(
7576
}
7677

7778
// Use the already parsed body
78-
const { input, password, email, conversationId } = parsedBody
79+
const { input, password, email, conversationId, files } = parsedBody
7980

8081
// If this is an authentication request (has password or email but no input),
8182
// set auth cookie and return success
@@ -88,8 +89,8 @@ export async function POST(
8889
return response
8990
}
9091

91-
// For chat messages, create regular response
92-
if (!input) {
92+
// For chat messages, create regular response (allow empty input if files are present)
93+
if (!input && (!files || files.length === 0)) {
9394
return addCorsHeaders(createErrorResponse('No input provided', 400), request)
9495
}
9596

@@ -108,7 +109,6 @@ export async function POST(
108109
}
109110

110111
try {
111-
// Transform outputConfigs to selectedOutputs format (blockId_attribute format)
112112
const selectedOutputs: string[] = []
113113
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
114114
for (const config of deployment.outputConfigs) {
@@ -123,11 +123,30 @@ export async function POST(
123123
const { SSE_HEADERS } = await import('@/lib/utils')
124124
const { createFilteredResult } = await import('@/app/api/workflows/[id]/execute/route')
125125

126+
const workflowInput: any = { input, conversationId }
127+
if (files && Array.isArray(files) && files.length > 0) {
128+
logger.debug(`[${requestId}] Processing ${files.length} attached files`)
129+
130+
const executionId = crypto.randomUUID()
131+
const executionContext = {
132+
workspaceId: deployment.userId,
133+
workflowId: deployment.workflowId,
134+
executionId,
135+
}
136+
137+
const uploadedFiles = await processChatFiles(files, executionContext, requestId)
138+
139+
if (uploadedFiles.length > 0) {
140+
workflowInput.files = uploadedFiles
141+
logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`)
142+
}
143+
}
144+
126145
const stream = await createStreamingResponse({
127146
requestId,
128147
workflow: { id: deployment.workflowId, userId: deployment.userId, isDeployed: true },
129-
input: { input, conversationId }, // Format for chat_trigger
130-
executingUserId: deployment.userId, // Use workflow owner's ID for chat deployments
148+
input: workflowInput,
149+
executingUserId: deployment.userId,
131150
streamConfig: {
132151
selectedOutputs,
133152
isSecureMode: true,

apps/sim/app/api/chat/utils.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { isDev } from '@/lib/environment'
66
import { createLogger } from '@/lib/logs/console/logger'
77
import { hasAdminPermission } from '@/lib/permissions/utils'
88
import { decryptSecret } from '@/lib/utils'
9+
import { uploadExecutionFile } from '@/lib/workflows/execution-file-storage'
10+
import type { UserFile } from '@/executor/types'
911

1012
const logger = createLogger('ChatAuthUtils')
1113

@@ -263,3 +265,61 @@ export async function validateChatAuth(
263265
// Unknown auth type
264266
return { authorized: false, error: 'Unsupported authentication type' }
265267
}
268+
269+
/**
270+
* Process and upload chat files to execution storage
271+
* Handles both base64 dataUrl format and direct URL pass-through
272+
*/
273+
export async function processChatFiles(
274+
files: Array<{ dataUrl?: string; url?: string; name: string; type: string }>,
275+
executionContext: { workspaceId: string; workflowId: string; executionId: string },
276+
requestId: string
277+
): Promise<UserFile[]> {
278+
const uploadedFiles: UserFile[] = []
279+
280+
for (const file of files) {
281+
try {
282+
if (file.dataUrl) {
283+
const dataUrlPrefix = 'data:'
284+
const base64Prefix = ';base64,'
285+
286+
if (!file.dataUrl.startsWith(dataUrlPrefix)) {
287+
logger.warn(`[${requestId}] Invalid dataUrl format for file: ${file.name}`)
288+
continue
289+
}
290+
291+
const base64Index = file.dataUrl.indexOf(base64Prefix)
292+
if (base64Index === -1) {
293+
logger.warn(
294+
`[${requestId}] Invalid dataUrl format (no base64 marker) for file: ${file.name}`
295+
)
296+
continue
297+
}
298+
299+
const mimeType = file.dataUrl.substring(dataUrlPrefix.length, base64Index)
300+
const base64Data = file.dataUrl.substring(base64Index + base64Prefix.length)
301+
const buffer = Buffer.from(base64Data, 'base64')
302+
303+
logger.debug(`[${requestId}] Uploading file to S3: ${file.name} (${buffer.length} bytes)`)
304+
305+
const userFile = await uploadExecutionFile(
306+
executionContext,
307+
buffer,
308+
file.name,
309+
mimeType || file.type
310+
)
311+
312+
uploadedFiles.push(userFile)
313+
logger.debug(`[${requestId}] Successfully uploaded ${file.name} with URL: ${userFile.url}`)
314+
} else if (file.url) {
315+
uploadedFiles.push(file as UserFile)
316+
logger.debug(`[${requestId}] Using existing URL for file: ${file.name}`)
317+
}
318+
} catch (error) {
319+
logger.error(`[${requestId}] Failed to process file ${file.name}:`, error)
320+
throw new Error(`Failed to upload file: ${file.name}`)
321+
}
322+
}
323+
324+
return uploadedFiles
325+
}

0 commit comments

Comments
 (0)