Skip to content

Commit 2441d5a

Browse files
authored
feat(mothership): add files to mship block (#4584)
* Add files to mship block * Fixes * Fix * fix
1 parent 1ed3a4e commit 2441d5a

6 files changed

Lines changed: 189 additions & 3 deletions

File tree

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
5151
chatId,
5252
messageId: providedMessageId,
5353
requestId: providedRequestId,
54+
fileAttachments,
5455
workflowId,
5556
executionId,
5657
} = validation.data.body
@@ -89,6 +90,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
8990
messageId,
9091
isHosted: true,
9192
workspaceContext: workspaceContextWithMothershipTools,
93+
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
9294
...(integrationTools.length > 0 ? { integrationTools } : {}),
9395
...(mothershipToolRuntime.tools.length > 0
9496
? { mothershipTools: mothershipToolRuntime.tools }

apps/sim/blocks/blocks/mothership.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
4141
type: 'short-input',
4242
placeholder: 'e.g., user-123, session-abc, customer-456',
4343
},
44+
{
45+
id: 'attachmentFiles',
46+
title: 'Attachments',
47+
type: 'file-upload',
48+
canonicalParamId: 'files',
49+
placeholder: 'Upload files to attach',
50+
mode: 'basic',
51+
multiple: true,
52+
required: false,
53+
},
54+
{
55+
id: 'fileReferences',
56+
title: 'Attachments',
57+
type: 'short-input',
58+
canonicalParamId: 'files',
59+
placeholder: 'Reference files from previous blocks',
60+
mode: 'advanced',
61+
required: false,
62+
},
4463
],
4564
tools: {
4665
access: [],
@@ -54,6 +73,10 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
5473
type: 'string',
5574
description: 'Mothership chat ID to continue; generated when omitted',
5675
},
76+
files: {
77+
type: 'file',
78+
description: 'Files to send to Mothership as attachments',
79+
},
5780
},
5881
outputs: {
5982
content: { type: 'string', description: 'Generated response content' },

apps/sim/executor/handlers/mothership/mothership-handler.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ const {
1313
mockGenerateId,
1414
mockIsExecutionCancelled,
1515
mockIsRedisCancellationEnabled,
16+
mockReadUserFileContent,
1617
} = vi.hoisted(() => ({
1718
mockBuildAuthHeaders: vi.fn(),
1819
mockBuildAPIUrl: vi.fn(),
1920
mockExtractAPIErrorMessage: vi.fn(),
2021
mockGenerateId: vi.fn(),
2122
mockIsExecutionCancelled: vi.fn(),
2223
mockIsRedisCancellationEnabled: vi.fn(),
24+
mockReadUserFileContent: vi.fn(),
2325
}))
2426

2527
vi.mock('@/executor/utils/http', () => ({
@@ -37,6 +39,10 @@ vi.mock('@/lib/execution/cancellation', () => ({
3739
isRedisCancellationEnabled: mockIsRedisCancellationEnabled,
3840
}))
3941

42+
vi.mock('@/lib/execution/payloads/materialization.server', () => ({
43+
readUserFileContent: mockReadUserFileContent,
44+
}))
45+
4046
function createAbortError(): Error {
4147
const error = new Error('The operation was aborted')
4248
error.name = 'AbortError'
@@ -78,13 +84,14 @@ describe('MothershipBlockHandler', () => {
7884
mockIsExecutionCancelled.mockReset()
7985
mockIsRedisCancellationEnabled.mockReset()
8086
mockIsRedisCancellationEnabled.mockReturnValue(false)
87+
mockReadUserFileContent.mockReset()
8188

8289
block = {
8390
id: 'mothership-block-1',
8491
metadata: { id: BlockType.MOTHERSHIP, name: 'Mothership' },
8592
position: { x: 0, y: 0 },
8693
config: { tool: BlockType.MOTHERSHIP, params: {} },
87-
inputs: { prompt: 'string', conversationId: 'string' },
94+
inputs: { prompt: 'string', conversationId: 'string', files: 'file[]' },
8895
outputs: {},
8996
enabled: true,
9097
} as SerializedBlock
@@ -212,6 +219,80 @@ describe('MothershipBlockHandler', () => {
212219
expect(mockGenerateId).toHaveBeenCalledTimes(2)
213220
})
214221

222+
it('embeds attached files for the mothership execute request', async () => {
223+
const fileContent = Buffer.from('hello mothership', 'utf8').toString('base64')
224+
mockGenerateId.mockReturnValueOnce('chat-uuid')
225+
mockGenerateId.mockReturnValueOnce('message-uuid')
226+
mockGenerateId.mockReturnValueOnce('request-uuid')
227+
mockReadUserFileContent.mockResolvedValueOnce(fileContent)
228+
229+
fetchMock.mockResolvedValue(
230+
new Response(
231+
JSON.stringify({
232+
content: 'analyzed',
233+
model: 'mothership',
234+
conversationId: 'chat-uuid',
235+
tokens: {},
236+
toolCalls: [],
237+
}),
238+
{
239+
status: 200,
240+
headers: { 'Content-Type': 'application/json' },
241+
}
242+
)
243+
)
244+
245+
const result = await handler.execute(context, block, {
246+
prompt: 'Analyze this file',
247+
files: [
248+
{
249+
name: 'notes.txt',
250+
key: 'workspace/workspace-1/notes.txt',
251+
size: 16,
252+
type: 'text/plain',
253+
},
254+
],
255+
})
256+
257+
expect(result).toMatchObject({
258+
content: 'analyzed',
259+
model: 'mothership',
260+
conversationId: 'chat-uuid',
261+
})
262+
expect(mockReadUserFileContent).toHaveBeenCalledWith(
263+
expect.objectContaining({
264+
id: expect.stringMatching(/^file-/),
265+
key: 'workspace/workspace-1/notes.txt',
266+
name: 'notes.txt',
267+
url: '',
268+
size: 16,
269+
type: 'text/plain',
270+
}),
271+
expect.objectContaining({
272+
encoding: 'base64',
273+
userId: 'user-1',
274+
workspaceId: 'workspace-1',
275+
workflowId: 'workflow-1',
276+
executionId: 'execution-1',
277+
requestId: 'request-uuid',
278+
})
279+
)
280+
281+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
282+
const body = JSON.parse(String(options.body))
283+
expect(body.fileAttachments).toEqual([
284+
{
285+
type: 'document',
286+
source: {
287+
type: 'base64',
288+
media_type: 'text/plain',
289+
data: fileContent,
290+
},
291+
filename: 'notes.txt',
292+
},
293+
])
294+
})
295+
215296
it('propagates local aborts to the mothership request', async () => {
216297
const abortController = new AbortController()
217298
context.abortSignal = abortController.signal

apps/sim/executor/handlers/mothership/mothership-handler.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,69 @@ import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { generateId } from '@sim/utils/id'
44
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
5+
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
6+
import {
7+
createFileContentFromBase64,
8+
type MessageContent,
9+
processSingleFileToUserFile,
10+
type RawFileInput,
11+
} from '@/lib/uploads/utils/file-utils'
512
import type { BlockOutput } from '@/blocks/types'
13+
import { normalizeFileInput } from '@/blocks/utils'
614
import { BlockType } from '@/executor/constants'
715
import type { BlockHandler, ExecutionContext } from '@/executor/types'
816
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
917
import type { SerializedBlock } from '@/serializer/types'
1018

1119
const logger = createLogger('MothershipBlockHandler')
1220
const CANCELLATION_CHECK_INTERVAL_MS = 500
21+
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024
22+
23+
type MothershipFileAttachment = MessageContent & {
24+
filename?: string
25+
}
26+
27+
async function buildMothershipFileAttachments(
28+
filesInput: unknown,
29+
ctx: ExecutionContext,
30+
requestId: string
31+
): Promise<MothershipFileAttachment[] | undefined> {
32+
const files = normalizeFileInput(filesInput)
33+
if (!files || files.length === 0) {
34+
return undefined
35+
}
36+
37+
if (!ctx.userId) {
38+
throw new Error('Mothership file attachments require an authenticated user.')
39+
}
40+
41+
const attachments: MothershipFileAttachment[] = []
42+
for (const file of files) {
43+
const userFile = processSingleFileToUserFile(file as RawFileInput, requestId, logger)
44+
const base64 = await readUserFileContent(userFile, {
45+
encoding: 'base64',
46+
userId: ctx.userId,
47+
workspaceId: ctx.workspaceId,
48+
workflowId: ctx.workflowId,
49+
executionId: ctx.executionId,
50+
largeValueExecutionIds: ctx.largeValueExecutionIds,
51+
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
52+
requestId,
53+
logger,
54+
maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
55+
maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
56+
})
57+
58+
const content = createFileContentFromBase64(base64, userFile.type)
59+
if (!content) {
60+
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
61+
}
62+
63+
attachments.push({ ...content, filename: userFile.name })
64+
}
65+
66+
return attachments
67+
}
1368

1469
/**
1570
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
@@ -38,6 +93,7 @@ export class MothershipBlockHandler implements BlockHandler {
3893
const chatId = providedConversationId || generateId()
3994
const messageId = generateId()
4095
const requestId = generateId()
96+
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)
4197

4298
const url = buildAPIUrl('/api/mothership/execute')
4399
const headers = await buildAuthHeaders(ctx.userId)
@@ -49,6 +105,7 @@ export class MothershipBlockHandler implements BlockHandler {
49105
chatId,
50106
messageId,
51107
requestId,
108+
...(fileAttachments && { fileAttachments }),
52109
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
53110
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
54111
}
@@ -60,6 +117,7 @@ export class MothershipBlockHandler implements BlockHandler {
60117
workflowId: ctx.workflowId,
61118
executionId: ctx.executionId,
62119
chatId,
120+
fileAttachmentCount: fileAttachments?.length ?? 0,
63121
})
64122

65123
const abortController = new AbortController()

apps/sim/lib/api/contracts/mothership-tasks.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ const mothershipExecuteMessageSchema = z.object({
5353
content: z.string(),
5454
})
5555

56+
const mothershipExecuteFileAttachmentSchema = z
57+
.object({
58+
type: z.enum(['text', 'image', 'document', 'audio', 'video']),
59+
source: z
60+
.object({
61+
type: z.literal('base64'),
62+
media_type: z.string().min(1),
63+
data: z.string().min(1),
64+
})
65+
.optional(),
66+
filename: z.string().optional(),
67+
})
68+
.passthrough()
69+
5670
export const mothershipExecuteBodySchema = z.object({
5771
messages: z.array(mothershipExecuteMessageSchema).min(1, 'At least one message is required'),
5872
responseFormat: z.any().optional(),
@@ -61,6 +75,7 @@ export const mothershipExecuteBodySchema = z.object({
6175
chatId: z.string().optional(),
6276
messageId: z.string().optional(),
6377
requestId: z.string().optional(),
78+
fileAttachments: z.array(mothershipExecuteFileAttachmentSchema).optional(),
6479
workflowId: z.string().optional(),
6580
executionId: z.string().optional(),
6681
})

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ export function bufferToBase64(buffer: Buffer): string {
147147
* Create message content from file data
148148
*/
149149
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
150+
return createFileContentFromBase64(bufferToBase64(fileBuffer), mimeType)
151+
}
152+
153+
/**
154+
* Create message content from base64-encoded file data.
155+
*/
156+
export function createFileContentFromBase64(base64: string, mimeType: string): MessageContent | null {
150157
// SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP),
151158
// so send SVGs as an XML document instead
152159
if (mimeType.toLowerCase() === 'image/svg+xml') {
@@ -155,7 +162,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message
155162
source: {
156163
type: 'base64',
157164
media_type: 'text/xml',
158-
data: bufferToBase64(fileBuffer),
165+
data: base64,
159166
},
160167
}
161168
}
@@ -174,7 +181,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message
174181
source: {
175182
type: 'base64',
176183
media_type: mimeType,
177-
data: bufferToBase64(fileBuffer),
184+
data: base64,
178185
},
179186
}
180187
}

0 commit comments

Comments
 (0)