Skip to content

Commit 48db52a

Browse files
committed
Add files to mship block
1 parent 1ed3a4e commit 48db52a

5 files changed

Lines changed: 206 additions & 2 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: 83 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,81 @@ 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+
path: '/api/files/serve/workspace/workspace-1/notes.txt',
251+
key: 'workspace/workspace-1/notes.txt',
252+
size: 16,
253+
type: 'text/plain',
254+
},
255+
],
256+
})
257+
258+
expect(result).toMatchObject({
259+
content: 'analyzed',
260+
model: 'mothership',
261+
conversationId: 'chat-uuid',
262+
})
263+
expect(mockReadUserFileContent).toHaveBeenCalledWith(
264+
{
265+
id: 'workspace/workspace-1/notes.txt',
266+
key: 'workspace/workspace-1/notes.txt',
267+
name: 'notes.txt',
268+
url: '/api/files/serve/workspace/workspace-1/notes.txt',
269+
size: 16,
270+
type: 'text/plain',
271+
},
272+
expect.objectContaining({
273+
encoding: 'base64',
274+
userId: 'user-1',
275+
workspaceId: 'workspace-1',
276+
workflowId: 'workflow-1',
277+
executionId: 'execution-1',
278+
requestId: 'request-uuid',
279+
})
280+
)
281+
282+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
283+
const body = JSON.parse(String(options.body))
284+
expect(body.fileAttachments).toEqual([
285+
{
286+
type: 'document',
287+
source: {
288+
type: 'base64',
289+
media_type: 'text/plain',
290+
data: fileContent,
291+
},
292+
filename: 'notes.txt',
293+
},
294+
])
295+
})
296+
215297
it('propagates local aborts to the mothership request', async () => {
216298
const abortController = new AbortController()
217299
context.abortSignal = abortController.signal

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

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,93 @@ 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 { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils'
57
import type { BlockOutput } from '@/blocks/types'
8+
import { normalizeFileInput } from '@/blocks/utils'
69
import { BlockType } from '@/executor/constants'
7-
import type { BlockHandler, ExecutionContext } from '@/executor/types'
10+
import type { BlockHandler, ExecutionContext, UserFile } from '@/executor/types'
811
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
912
import type { SerializedBlock } from '@/serializer/types'
1013

1114
const logger = createLogger('MothershipBlockHandler')
1215
const CANCELLATION_CHECK_INTERVAL_MS = 500
16+
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024
17+
18+
type MothershipFileAttachment = MessageContent & {
19+
filename?: string
20+
}
21+
22+
function toUserFile(file: object): UserFile {
23+
const candidate = file as Record<string, unknown>
24+
const key = typeof candidate.key === 'string' ? candidate.key : ''
25+
const name = typeof candidate.name === 'string' ? candidate.name : ''
26+
const url =
27+
typeof candidate.url === 'string'
28+
? candidate.url
29+
: typeof candidate.path === 'string'
30+
? candidate.path
31+
: key
32+
const size = typeof candidate.size === 'number' ? candidate.size : Number(candidate.size)
33+
const type = typeof candidate.type === 'string' ? candidate.type : ''
34+
const id = typeof candidate.id === 'string' ? candidate.id : key
35+
36+
if (!id || !key || !name || !url || !Number.isFinite(size) || !type) {
37+
throw new Error('Mothership attachment must include file name, key, url/path, size, and type.')
38+
}
39+
40+
return {
41+
id,
42+
key,
43+
name,
44+
url,
45+
size,
46+
type,
47+
...(typeof candidate.context === 'string' ? { context: candidate.context } : {}),
48+
}
49+
}
50+
51+
async function buildMothershipFileAttachments(
52+
filesInput: unknown,
53+
ctx: ExecutionContext,
54+
requestId: string
55+
): Promise<MothershipFileAttachment[] | undefined> {
56+
const files = normalizeFileInput(filesInput)
57+
if (!files || files.length === 0) {
58+
return undefined
59+
}
60+
61+
if (!ctx.userId) {
62+
throw new Error('Mothership file attachments require an authenticated user.')
63+
}
64+
65+
const attachments: MothershipFileAttachment[] = []
66+
for (const file of files) {
67+
const userFile = toUserFile(file)
68+
const base64 = await readUserFileContent(userFile, {
69+
encoding: 'base64',
70+
userId: ctx.userId,
71+
workspaceId: ctx.workspaceId,
72+
workflowId: ctx.workflowId,
73+
executionId: ctx.executionId,
74+
largeValueExecutionIds: ctx.largeValueExecutionIds,
75+
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
76+
requestId,
77+
logger,
78+
maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
79+
maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
80+
})
81+
82+
const content = createFileContent(Buffer.from(base64, 'base64'), userFile.type)
83+
if (!content) {
84+
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
85+
}
86+
87+
attachments.push({ ...content, filename: userFile.name })
88+
}
89+
90+
return attachments
91+
}
1392

1493
/**
1594
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
@@ -38,6 +117,7 @@ export class MothershipBlockHandler implements BlockHandler {
38117
const chatId = providedConversationId || generateId()
39118
const messageId = generateId()
40119
const requestId = generateId()
120+
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)
41121

42122
const url = buildAPIUrl('/api/mothership/execute')
43123
const headers = await buildAuthHeaders(ctx.userId)
@@ -49,6 +129,7 @@ export class MothershipBlockHandler implements BlockHandler {
49129
chatId,
50130
messageId,
51131
requestId,
132+
...(fileAttachments && { fileAttachments }),
52133
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
53134
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
54135
}
@@ -60,6 +141,7 @@ export class MothershipBlockHandler implements BlockHandler {
60141
workflowId: ctx.workflowId,
61142
executionId: ctx.executionId,
62143
chatId,
144+
fileAttachmentCount: fileAttachments?.length ?? 0,
63145
})
64146

65147
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
})

0 commit comments

Comments
 (0)