mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(mothership): add files to mship block (#4584)
* Add files to mship block * Fixes * Fix * fix
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -41,6 +41,25 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
|
||||
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<MothershipResponse> = {
|
||||
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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<MothershipFileAttachment[] | undefined> {
|
||||
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()
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user