diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index d8a5e73f3d7..1e1c0ee0bc9 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -51,6 +51,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { chatId, messageId: providedMessageId, requestId: providedRequestId, + fileAttachments, workflowId, executionId, } = validation.data.body @@ -89,6 +90,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { messageId, isHosted: true, workspaceContext: workspaceContextWithMothershipTools, + ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipToolRuntime.tools.length > 0 ? { mothershipTools: mothershipToolRuntime.tools } diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index a524eb1674f..79389bc1db9 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -41,6 +41,25 @@ export const MothershipBlock: BlockConfig = { type: 'short-input', placeholder: 'e.g., user-123, session-abc, customer-456', }, + { + id: 'attachmentFiles', + title: 'Attachments', + type: 'file-upload', + canonicalParamId: 'files', + placeholder: 'Upload files to attach', + mode: 'basic', + multiple: true, + required: false, + }, + { + id: 'fileReferences', + title: 'Attachments', + type: 'short-input', + canonicalParamId: 'files', + placeholder: 'Reference files from previous blocks', + mode: 'advanced', + required: false, + }, ], tools: { access: [], @@ -54,6 +73,10 @@ export const MothershipBlock: BlockConfig = { type: 'string', description: 'Mothership chat ID to continue; generated when omitted', }, + files: { + type: 'file', + description: 'Files to send to Mothership as attachments', + }, }, outputs: { content: { type: 'string', description: 'Generated response content' }, diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 1dde2fcebc0..5aaeab83dfd 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -13,6 +13,7 @@ const { mockGenerateId, mockIsExecutionCancelled, mockIsRedisCancellationEnabled, + mockReadUserFileContent, } = vi.hoisted(() => ({ mockBuildAuthHeaders: vi.fn(), mockBuildAPIUrl: vi.fn(), @@ -20,6 +21,7 @@ const { mockGenerateId: vi.fn(), mockIsExecutionCancelled: vi.fn(), mockIsRedisCancellationEnabled: vi.fn(), + mockReadUserFileContent: vi.fn(), })) vi.mock('@/executor/utils/http', () => ({ @@ -37,6 +39,10 @@ vi.mock('@/lib/execution/cancellation', () => ({ isRedisCancellationEnabled: mockIsRedisCancellationEnabled, })) +vi.mock('@/lib/execution/payloads/materialization.server', () => ({ + readUserFileContent: mockReadUserFileContent, +})) + function createAbortError(): Error { const error = new Error('The operation was aborted') error.name = 'AbortError' @@ -78,13 +84,14 @@ describe('MothershipBlockHandler', () => { mockIsExecutionCancelled.mockReset() mockIsRedisCancellationEnabled.mockReset() mockIsRedisCancellationEnabled.mockReturnValue(false) + mockReadUserFileContent.mockReset() block = { id: 'mothership-block-1', metadata: { id: BlockType.MOTHERSHIP, name: 'Mothership' }, position: { x: 0, y: 0 }, config: { tool: BlockType.MOTHERSHIP, params: {} }, - inputs: { prompt: 'string', conversationId: 'string' }, + inputs: { prompt: 'string', conversationId: 'string', files: 'file[]' }, outputs: {}, enabled: true, } as SerializedBlock @@ -212,6 +219,80 @@ describe('MothershipBlockHandler', () => { expect(mockGenerateId).toHaveBeenCalledTimes(2) }) + it('embeds attached files for the mothership execute request', async () => { + const fileContent = Buffer.from('hello mothership', 'utf8').toString('base64') + mockGenerateId.mockReturnValueOnce('chat-uuid') + mockGenerateId.mockReturnValueOnce('message-uuid') + mockGenerateId.mockReturnValueOnce('request-uuid') + mockReadUserFileContent.mockResolvedValueOnce(fileContent) + + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + content: 'analyzed', + model: 'mothership', + conversationId: 'chat-uuid', + tokens: {}, + toolCalls: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + + const result = await handler.execute(context, block, { + prompt: 'Analyze this file', + files: [ + { + name: 'notes.txt', + key: 'workspace/workspace-1/notes.txt', + size: 16, + type: 'text/plain', + }, + ], + }) + + expect(result).toMatchObject({ + content: 'analyzed', + model: 'mothership', + conversationId: 'chat-uuid', + }) + expect(mockReadUserFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^file-/), + key: 'workspace/workspace-1/notes.txt', + name: 'notes.txt', + url: '', + size: 16, + type: 'text/plain', + }), + expect.objectContaining({ + encoding: 'base64', + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + requestId: 'request-uuid', + }) + ) + + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(String(options.body)) + expect(body.fileAttachments).toEqual([ + { + type: 'document', + source: { + type: 'base64', + media_type: 'text/plain', + data: fileContent, + }, + filename: 'notes.txt', + }, + ]) + }) + it('propagates local aborts to the mothership request', async () => { const abortController = new AbortController() context.abortSignal = abortController.signal diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 3c48d746909..e677680ecd7 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -2,7 +2,15 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' +import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' +import { + createFileContentFromBase64, + type MessageContent, + processSingleFileToUserFile, + type RawFileInput, +} from '@/lib/uploads/utils/file-utils' import type { BlockOutput } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' @@ -10,6 +18,53 @@ import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('MothershipBlockHandler') const CANCELLATION_CHECK_INTERVAL_MS = 500 +const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024 + +type MothershipFileAttachment = MessageContent & { + filename?: string +} + +async function buildMothershipFileAttachments( + filesInput: unknown, + ctx: ExecutionContext, + requestId: string +): Promise { + const files = normalizeFileInput(filesInput) + if (!files || files.length === 0) { + return undefined + } + + if (!ctx.userId) { + throw new Error('Mothership file attachments require an authenticated user.') + } + + const attachments: MothershipFileAttachment[] = [] + for (const file of files) { + const userFile = processSingleFileToUserFile(file as RawFileInput, requestId, logger) + const base64 = await readUserFileContent(userFile, { + encoding: 'base64', + userId: ctx.userId, + workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, + executionId: ctx.executionId, + largeValueExecutionIds: ctx.largeValueExecutionIds, + allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope, + requestId, + logger, + maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES, + maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES, + }) + + const content = createFileContentFromBase64(base64, userFile.type) + if (!content) { + throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`) + } + + attachments.push({ ...content, filename: userFile.name }) + } + + return attachments +} /** * Handler for Mothership blocks that proxy requests to the Mothership AI agent. @@ -38,6 +93,7 @@ export class MothershipBlockHandler implements BlockHandler { const chatId = providedConversationId || generateId() const messageId = generateId() const requestId = generateId() + const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) const url = buildAPIUrl('/api/mothership/execute') const headers = await buildAuthHeaders(ctx.userId) @@ -49,6 +105,7 @@ export class MothershipBlockHandler implements BlockHandler { chatId, messageId, requestId, + ...(fileAttachments && { fileAttachments }), ...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}), ...(ctx.executionId ? { executionId: ctx.executionId } : {}), } @@ -60,6 +117,7 @@ export class MothershipBlockHandler implements BlockHandler { workflowId: ctx.workflowId, executionId: ctx.executionId, chatId, + fileAttachmentCount: fileAttachments?.length ?? 0, }) const abortController = new AbortController() diff --git a/apps/sim/lib/api/contracts/mothership-tasks.ts b/apps/sim/lib/api/contracts/mothership-tasks.ts index a8d75166353..6e29964cdbe 100644 --- a/apps/sim/lib/api/contracts/mothership-tasks.ts +++ b/apps/sim/lib/api/contracts/mothership-tasks.ts @@ -53,6 +53,20 @@ const mothershipExecuteMessageSchema = z.object({ content: z.string(), }) +const mothershipExecuteFileAttachmentSchema = z + .object({ + type: z.enum(['text', 'image', 'document', 'audio', 'video']), + source: z + .object({ + type: z.literal('base64'), + media_type: z.string().min(1), + data: z.string().min(1), + }) + .optional(), + filename: z.string().optional(), + }) + .passthrough() + export const mothershipExecuteBodySchema = z.object({ messages: z.array(mothershipExecuteMessageSchema).min(1, 'At least one message is required'), responseFormat: z.any().optional(), @@ -61,6 +75,7 @@ export const mothershipExecuteBodySchema = z.object({ chatId: z.string().optional(), messageId: z.string().optional(), requestId: z.string().optional(), + fileAttachments: z.array(mothershipExecuteFileAttachmentSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), }) diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index bf426a4a90e..cf49299f5d5 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -147,6 +147,13 @@ export function bufferToBase64(buffer: Buffer): string { * Create message content from file data */ export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null { + return createFileContentFromBase64(bufferToBase64(fileBuffer), mimeType) +} + +/** + * Create message content from base64-encoded file data. + */ +export function createFileContentFromBase64(base64: string, mimeType: string): MessageContent | null { // SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP), // so send SVGs as an XML document instead if (mimeType.toLowerCase() === 'image/svg+xml') { @@ -155,7 +162,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message source: { type: 'base64', media_type: 'text/xml', - data: bufferToBase64(fileBuffer), + data: base64, }, } } @@ -174,7 +181,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message source: { type: 'base64', media_type: mimeType, - data: bufferToBase64(fileBuffer), + data: base64, }, } }