Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
downloadWorkspaceFile,
getWorkspaceFile,
getWorkspaceFileByName,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('FileManageAPI')

export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
}

const { searchParams } = new URL(request.url)
const userId = auth.userId || searchParams.get('userId')

if (!userId) {
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
}

let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 })
}

const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId')
if (!workspaceId) {
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
}

const operation = body.operation as string

try {
switch (operation) {
case 'write': {
const fileName = body.fileName as string | undefined
const fileId = body.fileId as string | undefined
const content = body.content as string | undefined
const contentType = body.contentType as string | undefined
const append = Boolean(body.append)

if (!content && content !== '') {
return NextResponse.json(
{ success: false, error: 'content is required for write operation' },
{ status: 400 }
)
}

if (fileName && !fileId) {
const existing = await getWorkspaceFileByName(workspaceId, fileName)

if (existing) {
let finalContent: string
if (append) {
const existingBuffer = await downloadWorkspaceFile(existing)
finalContent = existingBuffer.toString('utf-8') + content
} else {
finalContent = content ?? ''
}

const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)

logger.info('File overwritten by name', {
fileId: existing.id,
name: existing.name,
size: fileBuffer.length,
append,
})

return NextResponse.json({
success: true,
data: { id: existing.id, name: existing.name, size: fileBuffer.length },
})
}

const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
fileName,
mimeType
)

logger.info('File created', {
fileId: result.id,
name: fileName,
size: fileBuffer.length,
})

return NextResponse.json({
success: true,
data: { id: result.id, name: result.name, size: fileBuffer.length, url: result.url },
})
}

if (fileId) {
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json(
{ success: false, error: `File with ID "${fileId}" not found` },
{ status: 404 }
)
}

let finalContent: string
if (append) {
const existingBuffer = await downloadWorkspaceFile(fileRecord)
const existingContent = existingBuffer.toString('utf-8')
finalContent = existingContent + content
} else {
finalContent = content ?? ''
}

const fileBuffer = Buffer.from(finalContent, 'utf-8')
await updateWorkspaceFileContent(workspaceId, fileId, userId, fileBuffer)

logger.info('File written', {
fileId,
name: fileRecord.name,
size: fileBuffer.length,
append,
})

return NextResponse.json({
success: true,
data: { id: fileId, name: fileRecord.name, size: fileBuffer.length },
})
}

return NextResponse.json(
{
success: false,
error: 'Either fileName (to create) or fileId (to update) is required',
},
{ status: 400 }
)
}

default:
return NextResponse.json(
{ success: false, error: `Unknown operation: ${operation}. Supported: write` },
{ status: 400 }
)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('File operation failed', { operation, error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}
106 changes: 93 additions & 13 deletions apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,16 +250,26 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
export const FileV3Block: BlockConfig<FileParserV3Output> = {
type: 'file_v3',
name: 'File',
description: 'Read and parse multiple files',
description: 'Read and write workspace files',
longDescription:
'Upload files directly or import from external URLs to get UserFile objects for use in other blocks.',
'Read and parse files from uploads or URLs, or write workspace resource files. Writing by name creates the file if it does not exist, or overwrites it if it does. Use append mode to add content to existing files.',
docsLink: 'https://docs.sim.ai/tools/file',
category: 'tools',
integrationType: IntegrationType.FileStorage,
tags: ['document-processing'],
bgColor: '#40916C',
icon: DocumentIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown' as SubBlockType,
options: [
{ label: 'Read', id: 'file_parser_v3' },
{ label: 'Write', id: 'file_write' },
],
value: () => 'file_parser_v3',
},
{
id: 'file',
title: 'Files',
Expand All @@ -270,7 +280,8 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
multiple: true,
mode: 'basic',
maxSize: 100,
required: true,
required: { field: 'operation', value: 'file_parser_v3' },
condition: { field: 'operation', value: 'file_parser_v3' },
},
{
id: 'fileUrl',
Expand All @@ -279,22 +290,70 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
canonicalParamId: 'fileInput',
placeholder: 'https://example.com/document.pdf',
mode: 'advanced',
required: true,
required: { field: 'operation', value: 'file_parser_v3' },
condition: { field: 'operation', value: 'file_parser_v3' },
},
{
id: 'fileName',
title: 'File Name',
type: 'short-input' as SubBlockType,
placeholder: 'File name (e.g., data.csv) — overwrites if exists',
condition: { field: 'operation', value: 'file_write' },
},
{
id: 'fileId',
title: 'File ID',
type: 'short-input' as SubBlockType,
placeholder: 'Existing file ID to update',
condition: { field: 'operation', value: 'file_write' },
},
{
id: 'content',
title: 'Content',
type: 'long-input' as SubBlockType,
placeholder: 'File content to write...',
condition: { field: 'operation', value: 'file_write' },
required: { field: 'operation', value: 'file_write' },
},
{
id: 'append',
title: 'Append',
type: 'switch' as SubBlockType,
condition: { field: 'operation', value: 'file_write' },
},
{
id: 'contentType',
title: 'Content Type',
type: 'short-input' as SubBlockType,
placeholder: 'text/plain (auto-detected from extension)',
condition: { field: 'operation', value: 'file_write' },
mode: 'advanced',
},
],
tools: {
access: ['file_parser_v3'],
access: ['file_parser_v3', 'file_write'],
config: {
tool: () => 'file_parser_v3',
tool: (params) => params.operation || 'file_parser_v3',
params: (params) => {
// Use canonical 'fileInput' param directly
const operation = params.operation || 'file_parser_v3'

if (operation === 'file_write') {
return {
fileName: params.fileName,
fileId: params.fileId,
content: params.content,
contentType: params.contentType,
append: Boolean(params.append),
workspaceId: params._context?.workspaceId,
}
}

const fileInput = params.fileInput
if (!fileInput) {
logger.error('No file input provided')
throw new Error('File input is required')
}

// First, try to normalize as file objects (handles JSON strings from advanced mode)
const normalizedFiles = normalizeFileInput(fileInput)
if (normalizedFiles) {
const filePaths = resolveFilePathsFromInput(normalizedFiles)
Expand All @@ -309,7 +368,6 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
}
}

// If normalization fails, treat as direct URL string
if (typeof fileInput === 'string' && fileInput.trim()) {
return {
filePath: fileInput.trim(),
Expand All @@ -326,17 +384,39 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
},
},
inputs: {
fileInput: { type: 'json', description: 'File input (canonical param)' },
fileType: { type: 'string', description: 'File type' },
operation: { type: 'string', description: 'Operation to perform (read or write)' },
fileInput: { type: 'json', description: 'File input for read (canonical param)' },
fileType: { type: 'string', description: 'File type for read' },
fileName: { type: 'string', description: 'Name for a new file (write)' },
fileId: { type: 'string', description: 'ID of an existing file to update (write)' },
content: { type: 'string', description: 'File content to write' },
contentType: { type: 'string', description: 'MIME content type for write' },
append: { type: 'string', description: 'Whether to append content (write)' },
},
outputs: {
files: {
type: 'file[]',
description: 'Parsed files as UserFile objects',
description: 'Parsed files as UserFile objects (read)',
},
combinedContent: {
type: 'string',
description: 'All file contents merged into a single text string',
description: 'All file contents merged into a single text string (read)',
},
id: {
type: 'string',
description: 'File ID (write)',
},
name: {
type: 'string',
description: 'File name (write)',
},
size: {
type: 'number',
description: 'File size in bytes (write)',
},
url: {
type: 'string',
description: 'URL to access the file (write)',
},
},
}
18 changes: 2 additions & 16 deletions apps/sim/lib/copilot/tools/server/files/workspace-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,12 @@ import {
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'

const logger = createLogger('WorkspaceFileServerTool')

const PPTX_SOURCE_MIME = 'text/x-pptxgenjs'

const EXT_TO_MIME: Record<string, string> = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.json': 'application/json',
'.csv': 'text/csv',
}

function inferContentType(fileName: string, explicitType?: string): string {
if (explicitType) return explicitType
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
return EXT_TO_MIME[ext] || 'text/plain'
}

export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, WorkspaceFileResult> = {
name: 'workspace_file',
async execute(
Expand Down Expand Up @@ -63,8 +50,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac

const contentType = fileName.toLowerCase().endsWith('.pptx')
? PPTX_SOURCE_MIME
: inferContentType(fileName, explicitType)

: explicitType || getMimeTypeFromExtension(getFileExtension(fileName))
const fileBuffer = Buffer.from(content, 'utf-8')

const result = await uploadWorkspaceFile(
Expand Down
Loading