diff --git a/README.md b/README.md index e93ac2a4bb..3fa7b7d016 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- Ask DeepWiki Set Up with Cursor + Ask DeepWiki Set Up with Cursor

### Build Workflows with Ease diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 1c245ffafd..689dbb50a5 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps) { ) } +export function TextractIcon(props: SVGProps) { + return ( + + + + ) +} + export function McpIcon(props: SVGProps) { return ( = { calendly: CalendlyIcon, circleback: CirclebackIcon, clay: ClayIcon, - confluence: ConfluenceIcon, + confluence_v2: ConfluenceIcon, cursor_v2: CursorIcon, datadog: DatadogIcon, discord: DiscordIcon, @@ -153,7 +154,7 @@ export const blockTypeToIconMap: Record = { elasticsearch: ElasticsearchIcon, elevenlabs: ElevenLabsIcon, exa: ExaAIIcon, - file: DocumentIcon, + file_v2: DocumentIcon, firecrawl: FirecrawlIcon, fireflies: FirefliesIcon, github_v2: GithubIcon, @@ -195,7 +196,7 @@ export const blockTypeToIconMap: Record = { microsoft_excel_v2: MicrosoftExcelIcon, microsoft_planner: MicrosoftPlannerIcon, microsoft_teams: MicrosoftTeamsIcon, - mistral_parse: MistralIcon, + mistral_parse_v2: MistralIcon, mongodb: MongoDBIcon, mysql: MySQLIcon, neo4j: Neo4jIcon, @@ -237,6 +238,7 @@ export const blockTypeToIconMap: Record = { supabase: SupabaseIcon, tavily: TavilyIcon, telegram: TelegramIcon, + textract: TextractIcon, tinybird: TinybirdIcon, translate: TranslateIcon, trello: TrelloIcon, @@ -244,7 +246,7 @@ export const blockTypeToIconMap: Record = { twilio_sms: TwilioIcon, twilio_voice: TwilioIcon, typeform: TypeformIcon, - video_generator: VideoIcon, + video_generator_v2: VideoIcon, vision: EyeIcon, wealthbox: WealthboxIcon, webflow: WebflowIcon, diff --git a/apps/docs/content/docs/en/tools/confluence.mdx b/apps/docs/content/docs/en/tools/confluence.mdx index bb8453eceb..9de626872d 100644 --- a/apps/docs/content/docs/en/tools/confluence.mdx +++ b/apps/docs/content/docs/en/tools/confluence.mdx @@ -6,7 +6,7 @@ description: Interact with Confluence import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/en/tools/file.mdx b/apps/docs/content/docs/en/tools/file.mdx index b90c163bdb..2a0cc1b87c 100644 --- a/apps/docs/content/docs/en/tools/file.mdx +++ b/apps/docs/content/docs/en/tools/file.mdx @@ -6,7 +6,7 @@ description: Read and parse multiple files import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -48,7 +48,7 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc | Parameter | Type | Description | | --------- | ---- | ----------- | -| `files` | array | Array of parsed files | -| `combinedContent` | string | Combined content of all parsed files | +| `files` | array | Array of parsed files with content, metadata, and file properties | +| `combinedContent` | string | All file contents merged into a single text string | diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index ec3178013b..f7db7e1be6 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -106,6 +106,7 @@ "supabase", "tavily", "telegram", + "textract", "tinybird", "translate", "trello", diff --git a/apps/docs/content/docs/en/tools/mistral_parse.mdx b/apps/docs/content/docs/en/tools/mistral_parse.mdx index b919be56ad..c45023367f 100644 --- a/apps/docs/content/docs/en/tools/mistral_parse.mdx +++ b/apps/docs/content/docs/en/tools/mistral_parse.mdx @@ -6,7 +6,7 @@ description: Extract text from PDF documents import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -54,18 +54,37 @@ Parse PDF documents using Mistral OCR API | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Whether the PDF was parsed successfully | -| `content` | string | Extracted content in the requested format \(markdown, text, or JSON\) | -| `metadata` | object | Processing metadata including jobId, fileType, pageCount, and usage info | -| ↳ `jobId` | string | Unique job identifier | -| ↳ `fileType` | string | File type \(e.g., pdf\) | -| ↳ `fileName` | string | Original file name | -| ↳ `source` | string | Source type \(url\) | -| ↳ `pageCount` | number | Number of pages processed | -| ↳ `model` | string | Mistral model used | -| ↳ `resultType` | string | Output format \(markdown, text, json\) | -| ↳ `processedAt` | string | Processing timestamp | -| ↳ `sourceUrl` | string | Source URL if applicable | -| ↳ `usageInfo` | object | Usage statistics from OCR processing | +| `pages` | array | Array of page objects from Mistral OCR | +| ↳ `index` | number | Page index \(zero-based\) | +| ↳ `markdown` | string | Extracted markdown content | +| ↳ `images` | array | Images extracted from this page with bounding boxes | +| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) | +| ↳ `top_left_x` | number | Top-left X coordinate in pixels | +| ↳ `top_left_y` | number | Top-left Y coordinate in pixels | +| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels | +| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels | +| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) | +| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) | +| ↳ `top_left_x` | number | Top-left X coordinate in pixels | +| ↳ `top_left_y` | number | Top-left Y coordinate in pixels | +| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels | +| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels | +| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) | +| ↳ `dimensions` | object | Page dimensions | +| ↳ `dpi` | number | Dots per inch | +| ↳ `height` | number | Page height in pixels | +| ↳ `width` | number | Page width in pixels | +| ↳ `dpi` | number | Dots per inch | +| ↳ `height` | number | Page height in pixels | +| ↳ `width` | number | Page width in pixels | +| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] | +| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \[ | +| ↳ `header` | string | Page header content \(when extract_header=true\) | +| ↳ `footer` | string | Page footer content \(when extract_footer=true\) | +| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) | +| `usage_info` | object | Usage and processing statistics | +| ↳ `pages_processed` | number | Total number of pages processed | +| ↳ `doc_size_bytes` | number | Document file size in bytes | +| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) | diff --git a/apps/docs/content/docs/en/tools/s3.mdx b/apps/docs/content/docs/en/tools/s3.mdx index 615ba08c1b..55ca4be64a 100644 --- a/apps/docs/content/docs/en/tools/s3.mdx +++ b/apps/docs/content/docs/en/tools/s3.mdx @@ -58,6 +58,7 @@ Upload a file to an AWS S3 bucket | Parameter | Type | Description | | --------- | ---- | ----------- | | `url` | string | URL of the uploaded S3 object | +| `uri` | string | S3 URI of the uploaded object \(s3://bucket/key\) | | `metadata` | object | Upload metadata including ETag and location | ### `s3_get_object` @@ -149,6 +150,7 @@ Copy an object within or between AWS S3 buckets | Parameter | Type | Description | | --------- | ---- | ----------- | | `url` | string | URL of the copied S3 object | +| `uri` | string | S3 URI of the copied object \(s3://bucket/key\) | | `metadata` | object | Copy operation metadata | diff --git a/apps/docs/content/docs/en/tools/textract.mdx b/apps/docs/content/docs/en/tools/textract.mdx new file mode 100644 index 0000000000..a09c71cf0b --- /dev/null +++ b/apps/docs/content/docs/en/tools/textract.mdx @@ -0,0 +1,120 @@ +--- +title: AWS Textract +description: Extract text, tables, and forms from documents +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[AWS Textract](https://aws.amazon.com/textract/) is a powerful AI service from Amazon Web Services designed to automatically extract printed text, handwriting, tables, forms, key-value pairs, and other structured data from scanned documents and images. Textract leverages advanced optical character recognition (OCR) and document analysis to transform documents into actionable data, enabling automation, analytics, compliance, and more. + +With AWS Textract, you can: + +- **Extract text from images and documents**: Recognize printed text and handwriting in formats such as PDF, JPEG, PNG, or TIFF +- **Detect and extract tables**: Automatically find tables and output their structured content +- **Parse forms and key-value pairs**: Pull structured data from forms, including fields and their corresponding values +- **Identify signatures and layout features**: Detect signatures, geometric layout, and relationships between document elements +- **Customize extraction with queries**: Extract specific fields and answers using query-based extraction (e.g., "What is the invoice number?") + +In Sim, the AWS Textract integration empowers your agents to intelligently process documents as part of their workflows. This unlocks automation scenarios such as data entry from invoices, onboarding documents, contracts, receipts, and more. Your agents can extract relevant data, analyze structured forms, and generate summaries or reports directly from document uploads or URLs. By connecting Sim with AWS Textract, you can reduce manual effort, improve data accuracy, and streamline your business processes with robust document understanding. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF. + + + +## Tools + +### `textract_parser` + +Parse documents using AWS Textract OCR and document analysis + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKeyId` | string | Yes | AWS Access Key ID | +| `secretAccessKey` | string | Yes | AWS Secret Access Key | +| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) | +| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. | +| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). | +| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). | +| `fileUpload` | object | No | File upload data from file-upload component | +| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. | +| `items` | string | No | Feature type | +| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. | +| `items` | object | No | Query configuration | +| `properties` | string | No | The query text | +| `Text` | string | No | No description | +| `Alias` | string | No | No description | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements | +| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) | +| ↳ `Id` | string | Unique identifier for the block | +| ↳ `Text` | string | Query text | +| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) | +| ↳ `Confidence` | number | Confidence score \(0-100\) | +| ↳ `Page` | number | Page number | +| ↳ `Geometry` | object | Location and bounding box information | +| ↳ `BoundingBox` | object | Height as ratio of document height | +| ↳ `Height` | number | Height as ratio of document height | +| ↳ `Left` | number | Left position as ratio of document width | +| ↳ `Top` | number | Top position as ratio of document height | +| ↳ `Width` | number | Width as ratio of document width | +| ↳ `Height` | number | Height as ratio of document height | +| ↳ `Left` | number | Left position as ratio of document width | +| ↳ `Top` | number | Top position as ratio of document height | +| ↳ `Width` | number | Width as ratio of document width | +| ↳ `Polygon` | array | Polygon coordinates | +| ↳ `X` | number | X coordinate | +| ↳ `Y` | number | Y coordinate | +| ↳ `X` | number | X coordinate | +| ↳ `Y` | number | Y coordinate | +| ↳ `BoundingBox` | object | Height as ratio of document height | +| ↳ `Height` | number | Height as ratio of document height | +| ↳ `Left` | number | Left position as ratio of document width | +| ↳ `Top` | number | Top position as ratio of document height | +| ↳ `Width` | number | Width as ratio of document width | +| ↳ `Height` | number | Height as ratio of document height | +| ↳ `Left` | number | Left position as ratio of document width | +| ↳ `Top` | number | Top position as ratio of document height | +| ↳ `Width` | number | Width as ratio of document width | +| ↳ `Polygon` | array | Polygon coordinates | +| ↳ `X` | number | X coordinate | +| ↳ `Y` | number | Y coordinate | +| ↳ `X` | number | X coordinate | +| ↳ `Y` | number | Y coordinate | +| ↳ `Relationships` | array | Relationships to other blocks | +| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) | +| ↳ `Ids` | array | IDs of related blocks | +| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) | +| ↳ `Ids` | array | IDs of related blocks | +| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) | +| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED | +| ↳ `RowIndex` | number | Row index for table cells | +| ↳ `ColumnIndex` | number | Column index for table cells | +| ↳ `RowSpan` | number | Row span for merged cells | +| ↳ `ColumnSpan` | number | Column span for merged cells | +| ↳ `Query` | object | Query information for QUERY blocks | +| ↳ `Text` | string | Query text | +| ↳ `Alias` | string | Query alias | +| ↳ `Pages` | array | Pages to search | +| ↳ `Alias` | string | Query alias | +| ↳ `Pages` | array | Pages to search | +| `documentMetadata` | object | Metadata about the analyzed document | +| ↳ `pages` | number | Number of pages in the document | +| `modelVersion` | string | Version of the Textract model used for processing | + + diff --git a/apps/docs/content/docs/en/tools/video_generator.mdx b/apps/docs/content/docs/en/tools/video_generator.mdx index 7930ad7b2f..437bb2dd68 100644 --- a/apps/docs/content/docs/en/tools/video_generator.mdx +++ b/apps/docs/content/docs/en/tools/video_generator.mdx @@ -6,7 +6,7 @@ description: Generate videos from text using AI import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index b31029d1bc..5474855af1 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { StorageService } from '@/lib/uploads' -import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { + extractStorageKey, + inferContextFromKey, + isInternalFileUrl, +} from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -47,13 +51,13 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Mistral parse request`, { filePath: validatedData.filePath, - isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'), + isWorkspaceFile: isInternalFileUrl(validatedData.filePath), userId, }) let fileUrl = validatedData.filePath - if (validatedData.filePath?.includes('/api/files/serve/')) { + if (isInternalFileUrl(validatedData.filePath)) { try { const storageKey = extractStorageKey(validatedData.filePath) diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 7c2f340b1b..74ef2fe08b 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { StorageService } from '@/lib/uploads' -import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { + extractStorageKey, + inferContextFromKey, + isInternalFileUrl, +} from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -48,13 +52,13 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Pulse parse request`, { filePath: validatedData.filePath, - isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'), + isWorkspaceFile: isInternalFileUrl(validatedData.filePath), userId, }) let fileUrl = validatedData.filePath - if (validatedData.filePath?.includes('/api/files/serve/')) { + if (isInternalFileUrl(validatedData.filePath)) { try { const storageKey = extractStorageKey(validatedData.filePath) const context = inferContextFromKey(storageKey) diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index fa96ac46b0..2ce14e9d31 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -5,7 +5,11 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { StorageService } from '@/lib/uploads' -import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { + extractStorageKey, + inferContextFromKey, + isInternalFileUrl, +} from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -44,13 +48,13 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Reducto parse request`, { filePath: validatedData.filePath, - isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'), + isWorkspaceFile: isInternalFileUrl(validatedData.filePath), userId, }) let fileUrl = validatedData.filePath - if (validatedData.filePath?.includes('/api/files/serve/')) { + if (isInternalFileUrl(validatedData.filePath)) { try { const storageKey = extractStorageKey(validatedData.filePath) const context = inferContextFromKey(storageKey) diff --git a/apps/sim/app/api/tools/s3/copy-object/route.ts b/apps/sim/app/api/tools/s3/copy-object/route.ts index 888aaf6308..74b0d9ee54 100644 --- a/apps/sim/app/api/tools/s3/copy-object/route.ts +++ b/apps/sim/app/api/tools/s3/copy-object/route.ts @@ -79,11 +79,13 @@ export async function POST(request: NextRequest) { // Generate public URL for destination (properly encode the destination key) const encodedDestKey = validatedData.destinationKey.split('/').map(encodeURIComponent).join('/') const url = `https://${validatedData.destinationBucket}.s3.${validatedData.region}.amazonaws.com/${encodedDestKey}` + const uri = `s3://${validatedData.destinationBucket}/${validatedData.destinationKey}` return NextResponse.json({ success: true, output: { url, + uri, copySourceVersionId: result.CopySourceVersionId, versionId: result.VersionId, etag: result.CopyObjectResult?.ETag, diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index 2f7aced28b..bd2bab3a6b 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -117,11 +117,13 @@ export async function POST(request: NextRequest) { const encodedKey = validatedData.objectKey.split('/').map(encodeURIComponent).join('/') const url = `https://${validatedData.bucketName}.s3.${validatedData.region}.amazonaws.com/${encodedKey}` + const uri = `s3://${validatedData.bucketName}/${validatedData.objectKey}` return NextResponse.json({ success: true, output: { url, + uri, etag: result.ETag, location: url, key: validatedData.objectKey, diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts new file mode 100644 index 0000000000..3fb73976dd --- /dev/null +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -0,0 +1,637 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { + validateAwsRegion, + validateExternalUrl, + validateS3BucketName, +} from '@/lib/core/security/input-validation' +import { generateRequestId } from '@/lib/core/utils/request' +import { StorageService } from '@/lib/uploads' +import { + extractStorageKey, + inferContextFromKey, + isInternalFileUrl, +} from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 // 5 minutes for large multi-page PDF processing + +const logger = createLogger('TextractParseAPI') + +const QuerySchema = z.object({ + Text: z.string().min(1), + Alias: z.string().optional(), + Pages: z.array(z.string()).optional(), +}) + +const TextractParseSchema = z + .object({ + accessKeyId: z.string().min(1, 'AWS Access Key ID is required'), + secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'), + region: z.string().min(1, 'AWS region is required'), + processingMode: z.enum(['sync', 'async']).optional().default('sync'), + filePath: z.string().optional(), + s3Uri: z.string().optional(), + featureTypes: z + .array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT'])) + .optional(), + queries: z.array(QuerySchema).optional(), + }) + .superRefine((data, ctx) => { + const regionValidation = validateAwsRegion(data.region, 'AWS region') + if (!regionValidation.isValid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: regionValidation.error, + path: ['region'], + }) + } + }) + +function getSignatureKey( + key: string, + dateStamp: string, + regionName: string, + serviceName: string +): Buffer { + const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest() + const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest() + const kService = crypto.createHmac('sha256', kRegion).update(serviceName).digest() + const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest() + return kSigning +} + +function signAwsRequest( + method: string, + host: string, + uri: string, + body: string, + accessKeyId: string, + secretAccessKey: string, + region: string, + service: string, + amzTarget: string +): Record { + const date = new Date() + const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '') + const dateStamp = amzDate.slice(0, 8) + + const payloadHash = crypto.createHash('sha256').update(body).digest('hex') + + const canonicalHeaders = + `content-type:application/x-amz-json-1.1\n` + + `host:${host}\n` + + `x-amz-date:${amzDate}\n` + + `x-amz-target:${amzTarget}\n` + + const signedHeaders = 'content-type;host;x-amz-date;x-amz-target' + + const canonicalRequest = `${method}\n${uri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}` + + const algorithm = 'AWS4-HMAC-SHA256' + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request` + const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}` + + const signingKey = getSignatureKey(secretAccessKey, dateStamp, region, service) + const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex') + + const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` + + return { + 'Content-Type': 'application/x-amz-json-1.1', + Host: host, + 'X-Amz-Date': amzDate, + 'X-Amz-Target': amzTarget, + Authorization: authorizationHeader, + } +} + +async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch document: ${response.statusText}`) + } + + const arrayBuffer = await response.arrayBuffer() + const bytes = Buffer.from(arrayBuffer).toString('base64') + const contentType = response.headers.get('content-type') || 'application/octet-stream' + + return { bytes, contentType } +} + +function parseS3Uri(s3Uri: string): { bucket: string; key: string } { + const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/) + if (!match) { + throw new Error( + `Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object` + ) + } + + const bucket = match[1] + const key = match[2] + + const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name') + if (!bucketValidation.isValid) { + throw new Error(bucketValidation.error) + } + + if (key.includes('..') || key.startsWith('/')) { + throw new Error('S3 key contains invalid path traversal sequences') + } + + return { bucket, key } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function callTextractAsync( + host: string, + amzTarget: string, + body: Record, + accessKeyId: string, + secretAccessKey: string, + region: string +): Promise> { + const bodyString = JSON.stringify(body) + const headers = signAwsRequest( + 'POST', + host, + '/', + bodyString, + accessKeyId, + secretAccessKey, + region, + 'textract', + amzTarget + ) + + const response = await fetch(`https://${host}/`, { + method: 'POST', + headers, + body: bodyString, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `Textract API error: ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.Message) { + errorMessage = errorJson.Message + } else if (errorJson.__type) { + errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}` + } + } catch { + // Use default error message + } + throw new Error(errorMessage) + } + + return response.json() +} + +async function pollForJobCompletion( + host: string, + jobId: string, + accessKeyId: string, + secretAccessKey: string, + region: string, + useAnalyzeDocument: boolean, + requestId: string +): Promise> { + const pollIntervalMs = 5000 // 5 seconds between polls + const maxPollTimeMs = 180000 // 3 minutes maximum polling time + const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) + + const getTarget = useAnalyzeDocument + ? 'Textract.GetDocumentAnalysis' + : 'Textract.GetDocumentTextDetection' + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await callTextractAsync( + host, + getTarget, + { JobId: jobId }, + accessKeyId, + secretAccessKey, + region + ) + + const jobStatus = result.JobStatus as string + + if (jobStatus === 'SUCCEEDED') { + logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) + + let allBlocks = (result.Blocks as unknown[]) || [] + let nextToken = result.NextToken as string | undefined + + while (nextToken) { + const nextResult = await callTextractAsync( + host, + getTarget, + { JobId: jobId, NextToken: nextToken }, + accessKeyId, + secretAccessKey, + region + ) + allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || []) + nextToken = nextResult.NextToken as string | undefined + } + + return { + ...result, + Blocks: allBlocks, + } + } + + if (jobStatus === 'FAILED') { + throw new Error(`Textract job failed: ${result.StatusMessage || 'Unknown error'}`) + } + + if (jobStatus === 'PARTIAL_SUCCESS') { + logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) + + let allBlocks = (result.Blocks as unknown[]) || [] + let nextToken = result.NextToken as string | undefined + + while (nextToken) { + const nextResult = await callTextractAsync( + host, + getTarget, + { JobId: jobId, NextToken: nextToken }, + accessKeyId, + secretAccessKey, + region + ) + allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || []) + nextToken = nextResult.NextToken as string | undefined + } + + return { + ...result, + Blocks: allBlocks, + } + } + + logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) + await sleep(pollIntervalMs) + } + + throw new Error( + `Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)` + ) +} + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Unauthorized', + }, + { status: 401 } + ) + } + + const userId = authResult.userId + const body = await request.json() + const validatedData = TextractParseSchema.parse(body) + + const processingMode = validatedData.processingMode || 'sync' + const featureTypes = validatedData.featureTypes ?? [] + const useAnalyzeDocument = featureTypes.length > 0 + const host = `textract.${validatedData.region}.amazonaws.com` + + logger.info(`[${requestId}] Textract parse request`, { + processingMode, + filePath: validatedData.filePath?.substring(0, 50), + s3Uri: validatedData.s3Uri?.substring(0, 50), + featureTypes, + userId, + }) + + if (processingMode === 'async') { + if (!validatedData.s3Uri) { + return NextResponse.json( + { + success: false, + error: 'S3 URI is required for multi-page processing (s3://bucket/key)', + }, + { status: 400 } + ) + } + + const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri) + + logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key }) + + const startTarget = useAnalyzeDocument + ? 'Textract.StartDocumentAnalysis' + : 'Textract.StartDocumentTextDetection' + + const startBody: Record = { + DocumentLocation: { + S3Object: { + Bucket: s3Bucket, + Name: s3Key, + }, + }, + } + + if (useAnalyzeDocument) { + startBody.FeatureTypes = featureTypes + + if ( + validatedData.queries && + validatedData.queries.length > 0 && + featureTypes.includes('QUERIES') + ) { + startBody.QueriesConfig = { + Queries: validatedData.queries.map((q) => ({ + Text: q.Text, + Alias: q.Alias, + Pages: q.Pages, + })), + } + } + } + + const startResult = await callTextractAsync( + host, + startTarget, + startBody, + validatedData.accessKeyId, + validatedData.secretAccessKey, + validatedData.region + ) + + const jobId = startResult.JobId as string + if (!jobId) { + throw new Error('Failed to start Textract job: No JobId returned') + } + + logger.info(`[${requestId}] Async job started`, { jobId }) + + const textractData = await pollForJobCompletion( + host, + jobId, + validatedData.accessKeyId, + validatedData.secretAccessKey, + validatedData.region, + useAnalyzeDocument, + requestId + ) + + logger.info(`[${requestId}] Textract async parse successful`, { + pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0, + blockCount: (textractData.Blocks as unknown[])?.length ?? 0, + }) + + return NextResponse.json({ + success: true, + output: { + blocks: textractData.Blocks ?? [], + documentMetadata: { + pages: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0, + }, + modelVersion: (textractData.AnalyzeDocumentModelVersion ?? + textractData.DetectDocumentTextModelVersion) as string | undefined, + }, + }) + } + + if (!validatedData.filePath) { + return NextResponse.json( + { + success: false, + error: 'File path is required for single-page processing', + }, + { status: 400 } + ) + } + + let fileUrl = validatedData.filePath + + const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath) + + if (isInternalFilePath) { + try { + const storageKey = extractStorageKey(validatedData.filePath) + const context = inferContextFromKey(storageKey) + + const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: storageKey, + context, + }) + return NextResponse.json( + { + success: false, + error: 'File not found', + }, + { status: 404 } + ) + } + + fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + return NextResponse.json( + { + success: false, + error: 'Failed to generate file access URL', + }, + { status: 500 } + ) + } + } else if (validatedData.filePath?.startsWith('/')) { + // Reject arbitrary absolute paths that don't contain /api/files/serve/ + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: validatedData.filePath.substring(0, 50), + }) + return NextResponse.json( + { + success: false, + error: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + { status: 400 } + ) + } else { + const urlValidation = validateExternalUrl(fileUrl, 'Document URL') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] SSRF attempt blocked`, { + userId, + url: fileUrl.substring(0, 100), + error: urlValidation.error, + }) + return NextResponse.json( + { + success: false, + error: urlValidation.error, + }, + { status: 400 } + ) + } + } + + const { bytes, contentType } = await fetchDocumentBytes(fileUrl) + + // Track if this is a PDF for better error messaging + const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf') + + const uri = '/' + + let textractBody: Record + let amzTarget: string + + if (useAnalyzeDocument) { + amzTarget = 'Textract.AnalyzeDocument' + textractBody = { + Document: { + Bytes: bytes, + }, + FeatureTypes: featureTypes, + } + + if ( + validatedData.queries && + validatedData.queries.length > 0 && + featureTypes.includes('QUERIES') + ) { + textractBody.QueriesConfig = { + Queries: validatedData.queries.map((q) => ({ + Text: q.Text, + Alias: q.Alias, + Pages: q.Pages, + })), + } + } + } else { + amzTarget = 'Textract.DetectDocumentText' + textractBody = { + Document: { + Bytes: bytes, + }, + } + } + + const bodyString = JSON.stringify(textractBody) + + const headers = signAwsRequest( + 'POST', + host, + uri, + bodyString, + validatedData.accessKeyId, + validatedData.secretAccessKey, + validatedData.region, + 'textract', + amzTarget + ) + + const textractResponse = await fetch(`https://${host}${uri}`, { + method: 'POST', + headers, + body: bodyString, + }) + + if (!textractResponse.ok) { + const errorText = await textractResponse.text() + logger.error(`[${requestId}] Textract API error:`, errorText) + + let errorMessage = `Textract API error: ${textractResponse.statusText}` + let isUnsupportedFormat = false + try { + const errorJson = JSON.parse(errorText) + if (errorJson.Message) { + errorMessage = errorJson.Message + } else if (errorJson.__type) { + errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}` + } + // Check for unsupported document format error + isUnsupportedFormat = + errorJson.__type === 'UnsupportedDocumentException' || + errorJson.Message?.toLowerCase().includes('unsupported document') || + errorText.toLowerCase().includes('unsupported document') + } catch { + isUnsupportedFormat = errorText.toLowerCase().includes('unsupported document') + } + + // Provide helpful message for unsupported format (likely multi-page PDF) + if (isUnsupportedFormat && isPdf) { + errorMessage = + 'This document format is not supported in Single Page mode. If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.' + } + + return NextResponse.json( + { + success: false, + error: errorMessage, + }, + { status: textractResponse.status } + ) + } + + const textractData = await textractResponse.json() + + logger.info(`[${requestId}] Textract parse successful`, { + pageCount: textractData.DocumentMetadata?.Pages ?? 0, + blockCount: textractData.Blocks?.length ?? 0, + }) + + return NextResponse.json({ + success: true, + output: { + blocks: textractData.Blocks ?? [], + documentMetadata: { + pages: textractData.DocumentMetadata?.Pages ?? 0, + }, + modelVersion: + textractData.AnalyzeDocumentModelVersion ?? + textractData.DetectDocumentTextModelVersion ?? + undefined, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: error.errors, + }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error in Textract parse:`, error) + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 7911b4fab7..16b8e60d6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -129,10 +129,6 @@ export function OutputSelect({ ? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value : subBlockValues?.[block.id]?.responseFormat const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id) - const operationValue = - shouldUseBaseline && baselineWorkflow - ? baselineWorkflow.blocks?.[block.id]?.subBlocks?.operation?.value - : subBlockValues?.[block.id]?.operation let outputsToProcess: Record = {} @@ -146,10 +142,20 @@ export function OutputSelect({ outputsToProcess = blockConfig?.outputs || {} } } else { - const toolOutputs = - blockConfig && typeof operationValue === 'string' - ? getToolOutputs(blockConfig, operationValue) - : {} + // Build subBlocks object for tool selector + const rawSubBlockValues = + shouldUseBaseline && baselineWorkflow + ? baselineWorkflow.blocks?.[block.id]?.subBlocks + : subBlockValues?.[block.id] + const subBlocks: Record = {} + if (rawSubBlockValues && typeof rawSubBlockValues === 'object') { + for (const [key, val] of Object.entries(rawSubBlockValues)) { + // Handle both { value: ... } and raw value formats + subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val } + } + } + + const toolOutputs = blockConfig ? getToolOutputs(blockConfig, subBlocks) : {} outputsToProcess = Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {} } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index e776b34214..234498d01c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -8,9 +8,10 @@ import { Button, Combobox } from '@/components/emcn/components' import { Progress } from '@/components/ui/progress' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useSubBlockValue } from '../../hooks/use-sub-block-value' const logger = createLogger('FileUpload') @@ -85,14 +86,47 @@ export function FileUpload({ } } + /** + * Checks if a file's MIME type matches the accepted types + * Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types + */ + const isFileTypeAccepted = (fileType: string | undefined, accepted: string): boolean => { + if (accepted === '*') return true + if (!fileType) return false + + const acceptedList = accepted.split(',').map((t) => t.trim().toLowerCase()) + const normalizedFileType = fileType.toLowerCase() + + return acceptedList.some((acceptedType) => { + if (acceptedType === normalizedFileType) return true + + if (acceptedType.endsWith('/*')) { + const typePrefix = acceptedType.slice(0, -1) // 'image/' from 'image/*' + return normalizedFileType.startsWith(typePrefix) + } + + if (acceptedType.startsWith('.')) { + const extension = acceptedType.slice(1).toLowerCase() + const fileExtension = getExtensionFromMimeType(normalizedFileType) + if (fileExtension === extension) return true + return normalizedFileType.endsWith(`/${extension}`) + } + + return false + }) + } + const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => { const existingFiles = Array.isArray(value) ? value : value ? [value] : [] - return !existingFiles.some( + + const isAlreadySelected = existingFiles.some( (existing) => existing.name === workspaceFile.name || existing.path?.includes(workspaceFile.key) || existing.key === workspaceFile.key ) + + return !isAlreadySelected }) useEffect(() => { @@ -421,23 +455,23 @@ export function FileUpload({ return (
-
+
{truncateMiddle(file.name)} ({formatFileSize(file.size)})
@@ -468,19 +502,30 @@ export function FileUpload({ const comboboxOptions = useMemo( () => [ { label: 'Upload New File', value: '__upload_new__' }, - ...availableWorkspaceFiles.map((file) => ({ - label: file.name, - value: file.id, - })), + ...availableWorkspaceFiles.map((file) => { + const isAccepted = + !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) + return { + label: file.name, + value: file.id, + disabled: !isAccepted, + } + }), ], - [availableWorkspaceFiles] + [availableWorkspaceFiles, acceptedTypes] ) const handleComboboxChange = (value: string) => { setInputValue(value) - const isValidOption = - value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value) + const selectedFile = availableWorkspaceFiles.find((file) => file.id === value) + const isAcceptedType = + selectedFile && + (!acceptedTypes || + acceptedTypes === '*' || + isFileTypeAccepted(selectedFile.type, acceptedTypes)) + + const isValidOption = value === '__upload_new__' || isAcceptedType if (!isValidOption) { return diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 3799c9c5e5..7bafdc05df 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -28,6 +28,7 @@ interface Field { name: string type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files' value?: string + description?: string collapsed?: boolean } @@ -41,7 +42,9 @@ interface FieldFormatProps { placeholder?: string showType?: boolean showValue?: boolean + showDescription?: boolean valuePlaceholder?: string + descriptionPlaceholder?: string config?: any } @@ -73,6 +76,7 @@ const createDefaultField = (): Field => ({ name: '', type: 'string', value: '', + description: '', collapsed: false, }) @@ -93,7 +97,9 @@ export function FieldFormat({ placeholder = 'fieldName', showType = true, showValue = false, + showDescription = false, valuePlaceholder = 'Enter default value', + descriptionPlaceholder = 'Describe this field', }: FieldFormatProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const valueInputRefs = useRef>({}) @@ -554,6 +560,18 @@ export function FieldFormat({
)} + {showDescription && ( +
+ + updateField(field.id, 'description', e.target.value)} + placeholder={descriptionPlaceholder} + disabled={isReadOnly} + /> +
+ )} + {showValue && (
@@ -568,8 +586,10 @@ export function FieldFormat({ ) } -export function InputFormat(props: Omit) { - return +export function InputFormat( + props: Omit +) { + return } export function ResponseFormat( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 32491d54e6..ebe6cb653b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -214,17 +214,42 @@ const getOutputTypeForPath = ( outputPath: string, mergedSubBlocksOverride?: Record ): string => { - const subBlocks = - mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks - const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled - - if (blockConfig?.tools?.config?.tool) { - const operationValue = getSubBlockValue(blockId, 'operation') - if (operationValue) { - return getToolOutputType(blockConfig, operationValue, outputPath) + if (block?.triggerMode && blockConfig?.triggers?.enabled) { + return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true) + } + if (block?.type === 'starter') { + const startWorkflowValue = + mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow') + + if (startWorkflowValue === 'chat') { + const chatModeTypes: Record = { + input: 'string', + conversationId: 'string', + files: 'files', + } + return chatModeTypes[outputPath] || 'any' } + const inputFormatValue = + mergedSubBlocksOverride?.inputFormat?.value ?? getSubBlockValue(blockId, 'inputFormat') + if (inputFormatValue && Array.isArray(inputFormatValue)) { + const field = inputFormatValue.find( + (f: { name?: string; type?: string }) => f.name === outputPath + ) + if (field?.type) return field.type + } + } else if (blockConfig?.category === 'triggers') { + const blockState = useWorkflowStore.getState().blocks[blockId] + const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) + return getBlockOutputType(block.type, outputPath, subBlocks) + } else if (blockConfig?.tools?.config?.tool) { + const blockState = useWorkflowStore.getState().blocks[blockId] + const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) + return getToolOutputType(blockConfig, subBlocks, outputPath) } + const subBlocks = + mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks + const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode) } @@ -1189,11 +1214,7 @@ export const TagDropdown: React.FC = ({ : allTags } } else { - const operationValue = - mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation') - const toolOutputPaths = operationValue - ? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks) - : [] + const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks) if (toolOutputPaths.length > 0) { blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`) @@ -1513,7 +1534,6 @@ export const TagDropdown: React.FC = ({ if (dynamicOutputs.length > 0) { const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) - // For self-reference, only show url and resumeEndpoint (not response format fields) blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')) : allTags @@ -1521,11 +1541,7 @@ export const TagDropdown: React.FC = ({ blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`] } } else { - const operationValue = - mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation') - const toolOutputPaths = operationValue - ? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks) - : [] + const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks) if (toolOutputPaths.length > 0) { blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts index 321e507ec0..af5f675299 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts @@ -2,13 +2,15 @@ import { useMemo } from 'react' import { extractFieldsFromSchema } from '@/lib/core/utils/response-format' -import { getBlockOutputPaths, getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { + getBlockOutputPaths, + getBlockOutputs, + getToolOutputs, +} from '@/lib/workflows/blocks/block-outputs' import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' import type { SchemaField } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item' import { getBlock } from '@/blocks' -import type { BlockConfig } from '@/blocks/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { getTool } from '@/tools/utils' const RESERVED_KEYS = new Set(['type', 'description']) @@ -24,64 +26,6 @@ const getSubBlockValue = (blockId: string, property: string): any => { return useSubBlockStore.getState().getValue(blockId, property) } -/** - * Generates output paths for a tool-based block - */ -const generateToolOutputPaths = (blockConfig: BlockConfig, operation: string): string[] => { - if (!blockConfig?.tools?.config?.tool) return [] - - try { - const toolId = blockConfig.tools.config.tool({ operation }) - if (!toolId) return [] - - const toolConfig = getTool(toolId) - if (!toolConfig?.outputs) return [] - - return generateOutputPaths(toolConfig.outputs) - } catch { - return [] - } -} - -/** - * Recursively generates all output paths from an outputs schema - */ -const generateOutputPaths = (outputs: Record, prefix = ''): string[] => { - const paths: string[] = [] - - for (const [key, value] of Object.entries(outputs)) { - const currentPath = prefix ? `${prefix}.${key}` : key - - if (typeof value === 'string') { - paths.push(currentPath) - } else if (typeof value === 'object' && value !== null) { - if ('type' in value && typeof value.type === 'string') { - paths.push(currentPath) - // Handle nested objects and arrays - if (value.type === 'object' && value.properties) { - paths.push(...generateOutputPaths(value.properties, currentPath)) - } else if (value.type === 'array' && value.items?.properties) { - paths.push(...generateOutputPaths(value.items.properties, currentPath)) - } else if ( - value.type === 'array' && - value.items && - typeof value.items === 'object' && - !('type' in value.items) - ) { - paths.push(...generateOutputPaths(value.items, currentPath)) - } - } else { - const subPaths = generateOutputPaths(value, currentPath) - paths.push(...subPaths) - } - } else { - paths.push(currentPath) - } - } - - return paths -} - /** * Extracts nested fields from array or object properties */ @@ -155,26 +99,6 @@ const createFieldFromOutput = ( return field } -/** - * Gets tool outputs for a block's operation - */ -const getToolOutputs = ( - blockConfig: BlockConfig | null, - operation?: string -): Record => { - if (!blockConfig?.tools?.config?.tool || !operation) return {} - - try { - const toolId = blockConfig.tools.config.tool({ operation }) - if (!toolId) return {} - - const toolConfig = getTool(toolId) - return toolConfig?.outputs || {} - } catch { - return {} - } -} - interface UseBlockOutputFieldsParams { blockId: string blockType: string @@ -299,14 +223,11 @@ export function useBlockOutputFields({ baseOutputs = getBlockOutputs(blockType, mergedSubBlocks) } else { // For tool-based blocks, try to get tool outputs first - const operationValue = - operation ?? mergedSubBlocks?.operation?.value ?? getSubBlockValue(blockId, 'operation') - const toolOutputs = operationValue ? getToolOutputs(blockConfig, operationValue) : {} + const toolOutputs = blockConfig ? getToolOutputs(blockConfig, mergedSubBlocks) : {} if (Object.keys(toolOutputs).length > 0) { baseOutputs = toolOutputs } else { - // Use getBlockOutputs which handles inputFormat merging baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode) } } diff --git a/apps/sim/blocks/blocks/a2a.ts b/apps/sim/blocks/blocks/a2a.ts index 6996b685a4..86c98ac9b5 100644 --- a/apps/sim/blocks/blocks/a2a.ts +++ b/apps/sim/blocks/blocks/a2a.ts @@ -107,14 +107,26 @@ export const A2ABlock: BlockConfig = { condition: { field: 'operation', value: 'a2a_send_message' }, }, { - id: 'files', + id: 'fileUpload', title: 'Files', type: 'file-upload', + canonicalParamId: 'files', placeholder: 'Upload files to send', description: 'Files to include with the message (FilePart)', condition: { field: 'operation', value: 'a2a_send_message' }, + mode: 'basic', multiple: true, }, + { + id: 'fileReference', + title: 'Files', + type: 'short-input', + canonicalParamId: 'files', + placeholder: 'Reference files from previous blocks', + description: 'Files to include with the message (FilePart)', + condition: { field: 'operation', value: 'a2a_send_message' }, + mode: 'advanced', + }, { id: 'taskId', title: 'Task ID', @@ -233,6 +245,14 @@ export const A2ABlock: BlockConfig = { type: 'array', description: 'Files to include with the message', }, + fileUpload: { + type: 'array', + description: 'Uploaded files (basic mode)', + }, + fileReference: { + type: 'json', + description: 'File reference from previous blocks (advanced mode)', + }, historyLength: { type: 'number', description: 'Number of history messages to include', diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index 6823bb617a..5f9436f5ce 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -5,8 +5,9 @@ import type { ConfluenceResponse } from '@/tools/confluence/types' export const ConfluenceBlock: BlockConfig = { type: 'confluence', - name: 'Confluence', + name: 'Confluence (Legacy)', description: 'Interact with Confluence', + hideFromToolbar: true, authMode: AuthMode.OAuth, longDescription: 'Integrate Confluence into the workflow. Can read, create, update, delete pages, manage comments, attachments, labels, and search content.', @@ -357,3 +358,342 @@ export const ConfluenceBlock: BlockConfig = { status: { type: 'string', description: 'Space status' }, }, } + +export const ConfluenceV2Block: BlockConfig = { + ...ConfluenceBlock, + type: 'confluence_v2', + name: 'Confluence', + hideFromToolbar: false, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Read Page', id: 'read' }, + { label: 'Create Page', id: 'create' }, + { label: 'Update Page', id: 'update' }, + { label: 'Delete Page', id: 'delete' }, + { label: 'Search Content', id: 'search' }, + { label: 'Create Comment', id: 'create_comment' }, + { label: 'List Comments', id: 'list_comments' }, + { label: 'Update Comment', id: 'update_comment' }, + { label: 'Delete Comment', id: 'delete_comment' }, + { label: 'Upload Attachment', id: 'upload_attachment' }, + { label: 'List Attachments', id: 'list_attachments' }, + { label: 'Delete Attachment', id: 'delete_attachment' }, + { label: 'List Labels', id: 'list_labels' }, + { label: 'Get Space', id: 'get_space' }, + { label: 'List Spaces', id: 'list_spaces' }, + ], + value: () => 'read', + }, + { + id: 'domain', + title: 'Domain', + type: 'short-input', + placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)', + required: true, + }, + { + id: 'credential', + title: 'Confluence Account', + type: 'oauth-input', + serviceId: 'confluence', + requiredScopes: [ + 'read:confluence-content.all', + 'read:confluence-space.summary', + 'read:space:confluence', + 'read:space-details:confluence', + 'write:confluence-content', + 'write:confluence-space', + 'write:confluence-file', + 'read:content:confluence', + 'read:page:confluence', + 'write:page:confluence', + 'read:comment:confluence', + 'write:comment:confluence', + 'delete:comment:confluence', + 'read:attachment:confluence', + 'write:attachment:confluence', + 'delete:attachment:confluence', + 'delete:page:confluence', + 'read:label:confluence', + 'write:label:confluence', + 'search:confluence', + 'read:me', + 'offline_access', + ], + placeholder: 'Select Confluence account', + required: true, + }, + { + id: 'pageId', + title: 'Select Page', + type: 'file-selector', + canonicalParamId: 'pageId', + serviceId: 'confluence', + placeholder: 'Select Confluence page', + dependsOn: ['credential', 'domain'], + mode: 'basic', + }, + { + id: 'manualPageId', + title: 'Page ID', + type: 'short-input', + canonicalParamId: 'pageId', + placeholder: 'Enter Confluence page ID', + mode: 'advanced', + }, + { + id: 'spaceId', + title: 'Space ID', + type: 'short-input', + placeholder: 'Enter Confluence space ID', + required: true, + condition: { field: 'operation', value: ['create', 'get_space'] }, + }, + { + id: 'title', + title: 'Title', + type: 'short-input', + placeholder: 'Enter title for the page', + condition: { field: 'operation', value: ['create', 'update'] }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + placeholder: 'Enter content for the page', + condition: { field: 'operation', value: ['create', 'update'] }, + }, + { + id: 'parentId', + title: 'Parent Page ID', + type: 'short-input', + placeholder: 'Enter parent page ID (optional)', + condition: { field: 'operation', value: 'create' }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'Enter search query', + required: true, + condition: { field: 'operation', value: 'search' }, + }, + { + id: 'comment', + title: 'Comment Text', + type: 'long-input', + placeholder: 'Enter comment text', + required: true, + condition: { field: 'operation', value: ['create_comment', 'update_comment'] }, + }, + { + id: 'commentId', + title: 'Comment ID', + type: 'short-input', + placeholder: 'Enter comment ID', + required: true, + condition: { field: 'operation', value: ['update_comment', 'delete_comment'] }, + }, + { + id: 'attachmentId', + title: 'Attachment ID', + type: 'short-input', + placeholder: 'Enter attachment ID', + required: true, + condition: { field: 'operation', value: 'delete_attachment' }, + }, + { + id: 'attachmentFileUpload', + title: 'File', + type: 'file-upload', + canonicalParamId: 'attachmentFile', + placeholder: 'Select file to upload', + condition: { field: 'operation', value: 'upload_attachment' }, + mode: 'basic', + }, + { + id: 'attachmentFileReference', + title: 'File', + type: 'short-input', + canonicalParamId: 'attachmentFile', + placeholder: 'Reference file from previous blocks', + condition: { field: 'operation', value: 'upload_attachment' }, + mode: 'advanced', + }, + { + id: 'attachmentFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional custom file name', + condition: { field: 'operation', value: 'upload_attachment' }, + }, + { + id: 'attachmentComment', + title: 'Comment', + type: 'short-input', + placeholder: 'Optional comment for the attachment', + condition: { field: 'operation', value: 'upload_attachment' }, + }, + { + id: 'labelName', + title: 'Label Name', + type: 'short-input', + placeholder: 'Enter label name', + required: true, + condition: { field: 'operation', value: ['add_label', 'remove_label'] }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: 'Enter maximum number of results (default: 25)', + condition: { + field: 'operation', + value: ['search', 'list_comments', 'list_attachments', 'list_spaces'], + }, + }, + ], + tools: { + access: [ + 'confluence_retrieve', + 'confluence_update', + 'confluence_create_page', + 'confluence_delete_page', + 'confluence_search', + 'confluence_create_comment', + 'confluence_list_comments', + 'confluence_update_comment', + 'confluence_delete_comment', + 'confluence_upload_attachment', + 'confluence_list_attachments', + 'confluence_delete_attachment', + 'confluence_list_labels', + 'confluence_get_space', + 'confluence_list_spaces', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'read': + return 'confluence_retrieve' + case 'create': + return 'confluence_create_page' + case 'update': + return 'confluence_update' + case 'delete': + return 'confluence_delete_page' + case 'search': + return 'confluence_search' + case 'create_comment': + return 'confluence_create_comment' + case 'list_comments': + return 'confluence_list_comments' + case 'update_comment': + return 'confluence_update_comment' + case 'delete_comment': + return 'confluence_delete_comment' + case 'upload_attachment': + return 'confluence_upload_attachment' + case 'list_attachments': + return 'confluence_list_attachments' + case 'delete_attachment': + return 'confluence_delete_attachment' + case 'list_labels': + return 'confluence_list_labels' + case 'get_space': + return 'confluence_get_space' + case 'list_spaces': + return 'confluence_list_spaces' + default: + return 'confluence_retrieve' + } + }, + params: (params) => { + const { + credential, + pageId, + manualPageId, + operation, + attachmentFileUpload, + attachmentFileReference, + attachmentFile, + attachmentFileName, + attachmentComment, + ...rest + } = params + + const effectivePageId = (pageId || manualPageId || '').trim() + + const requiresPageId = [ + 'read', + 'update', + 'delete', + 'create_comment', + 'list_comments', + 'list_attachments', + 'list_labels', + 'upload_attachment', + ] + + const requiresSpaceId = ['create', 'get_space'] + + if (requiresPageId.includes(operation) && !effectivePageId) { + throw new Error('Page ID is required. Please select a page or enter a page ID manually.') + } + + if (requiresSpaceId.includes(operation) && !rest.spaceId) { + throw new Error('Space ID is required for this operation.') + } + + if (operation === 'upload_attachment') { + const fileInput = attachmentFileUpload || attachmentFileReference || attachmentFile + if (!fileInput) { + throw new Error('File is required for upload attachment operation.') + } + return { + credential, + pageId: effectivePageId, + operation, + file: fileInput, + fileName: attachmentFileName, + comment: attachmentComment, + ...rest, + } + } + + return { + credential, + pageId: effectivePageId || undefined, + operation, + ...rest, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + domain: { type: 'string', description: 'Confluence domain' }, + credential: { type: 'string', description: 'Confluence access token' }, + pageId: { type: 'string', description: 'Page identifier' }, + manualPageId: { type: 'string', description: 'Manual page identifier' }, + spaceId: { type: 'string', description: 'Space identifier' }, + title: { type: 'string', description: 'Page title' }, + content: { type: 'string', description: 'Page content' }, + parentId: { type: 'string', description: 'Parent page identifier' }, + query: { type: 'string', description: 'Search query' }, + comment: { type: 'string', description: 'Comment text' }, + commentId: { type: 'string', description: 'Comment identifier' }, + attachmentId: { type: 'string', description: 'Attachment identifier' }, + attachmentFile: { type: 'json', description: 'File to upload as attachment' }, + attachmentFileUpload: { type: 'json', description: 'Uploaded file (basic mode)' }, + attachmentFileReference: { type: 'json', description: 'File reference (advanced mode)' }, + attachmentFileName: { type: 'string', description: 'Custom file name for attachment' }, + attachmentComment: { type: 'string', description: 'Comment for the attachment' }, + labelName: { type: 'string', description: 'Label name' }, + limit: { type: 'number', description: 'Maximum number of results' }, + }, +} diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index eed7c3a256..7e478f42a2 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { DocumentIcon } from '@/components/icons' import type { BlockConfig, SubBlockType } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { FileParserOutput } from '@/tools/file/types' const logger = createLogger('FileBlock') export const FileBlock: BlockConfig = { type: 'file', - name: 'File', + name: 'File (Legacy)', description: 'Read and parse multiple files', longDescription: `Integrate File into the workflow. Can upload a file manually or insert a file url.`, bestPractices: ` @@ -17,6 +18,7 @@ export const FileBlock: BlockConfig = { category: 'tools', bgColor: '#40916C', icon: DocumentIcon, + hideFromToolbar: true, subBlocks: [ { id: 'inputMethod', @@ -127,3 +129,92 @@ export const FileBlock: BlockConfig = { }, }, } + +export const FileV2Block: BlockConfig = { + ...FileBlock, + type: 'file_v2', + name: 'File', + description: 'Read and parse multiple files', + hideFromToolbar: false, + subBlocks: [ + { + id: 'file', + title: 'Files', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'fileInput', + acceptedTypes: + '.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf', + placeholder: 'Upload files to process', + multiple: true, + mode: 'basic', + maxSize: 100, + }, + { + id: 'filePath', + title: 'Files', + type: 'short-input' as SubBlockType, + canonicalParamId: 'fileInput', + placeholder: 'File URL', + mode: 'advanced', + }, + ], + tools: { + access: ['file_parser_v2'], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: () => 'file_parser', + suffix: '_v2', + fallbackToolId: 'file_parser_v2', + }), + params: (params) => { + const fileInput = params.file || params.filePath || params.fileInput + if (!fileInput) { + logger.error('No file input provided') + throw new Error('File is required') + } + + if (typeof fileInput === 'string') { + return { + filePath: fileInput.trim(), + fileType: params.fileType || 'auto', + workspaceId: params._context?.workspaceId, + } + } + + if (Array.isArray(fileInput) && fileInput.length > 0) { + const filePaths = fileInput.map((file) => file.path) + return { + filePath: filePaths.length === 1 ? filePaths[0] : filePaths, + fileType: params.fileType || 'auto', + } + } + + if (fileInput?.path) { + return { + filePath: fileInput.path, + fileType: params.fileType || 'auto', + } + } + + logger.error('Invalid file input format') + throw new Error('Invalid file input') + }, + }, + }, + inputs: { + fileInput: { type: 'json', description: 'File input (upload or URL reference)' }, + filePath: { type: 'string', description: 'File URL (advanced mode)' }, + file: { type: 'json', description: 'Uploaded file data (basic mode)' }, + fileType: { type: 'string', description: 'File type' }, + }, + outputs: { + files: { + type: 'json', + description: 'Array of parsed file objects with content, metadata, and file properties', + }, + combinedContent: { + type: 'string', + description: 'All file contents merged into a single text string', + }, + }, +} diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index c551f00cef..62773a71cd 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -1,11 +1,13 @@ import { MistralIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import { createVersionedToolSelector } from '@/blocks/utils' import type { MistralParserOutput } from '@/tools/mistral/types' export const MistralParseBlock: BlockConfig = { type: 'mistral_parse', - name: 'Mistral Parser', + name: 'Mistral Parser (Legacy)', description: 'Extract text from PDF documents', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: `Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`, docsLink: 'https://docs.sim.ai/tools/mistral_parse', @@ -13,7 +15,6 @@ export const MistralParseBlock: BlockConfig = { bgColor: '#000000', icon: MistralIcon, subBlocks: [ - // Show input method selection { id: 'inputMethod', title: 'Select Input Method', @@ -23,8 +24,6 @@ export const MistralParseBlock: BlockConfig = { { id: 'upload', label: 'Upload PDF Document' }, ], }, - - // URL input - conditional on inputMethod { id: 'filePath', title: 'PDF Document URL', @@ -35,8 +34,6 @@ export const MistralParseBlock: BlockConfig = { value: 'url', }, }, - - // File upload option { id: 'fileUpload', title: 'Upload PDF', @@ -46,9 +43,8 @@ export const MistralParseBlock: BlockConfig = { field: 'inputMethod', value: 'upload', }, - maxSize: 50, // 50MB max via direct upload + maxSize: 50, }, - { id: 'resultType', title: 'Output Format', @@ -65,28 +61,6 @@ export const MistralParseBlock: BlockConfig = { type: 'short-input', placeholder: 'e.g. 0,1,2 (leave empty for all pages)', }, - /* - * Image-related parameters - temporarily disabled - * Uncomment if PDF image extraction is needed - * - { - id: 'includeImageBase64', - title: 'Include PDF Images', - type: 'switch', - }, - { - id: 'imageLimit', - title: 'Max Images', - type: 'short-input', - placeholder: 'Maximum number of images to extract', - }, - { - id: 'imageMinSize', - title: 'Min Image Size (px)', - type: 'short-input', - placeholder: 'Min width/height in pixels', - }, - */ { id: 'apiKey', title: 'API Key', @@ -101,18 +75,15 @@ export const MistralParseBlock: BlockConfig = { config: { tool: () => 'mistral_parser', params: (params) => { - // Basic validation if (!params || !params.apiKey || params.apiKey.trim() === '') { throw new Error('Mistral API key is required') } - // Build parameters object - file processing is now handled at the tool level - const parameters: any = { + const parameters: Record = { apiKey: params.apiKey.trim(), resultType: params.resultType || 'markdown', } - // Set filePath or fileUpload based on input method const inputMethod = params.inputMethod || 'url' if (inputMethod === 'url') { if (!params.filePath || params.filePath.trim() === '') { @@ -123,11 +94,9 @@ export const MistralParseBlock: BlockConfig = { if (!params.fileUpload) { throw new Error('Please upload a PDF document') } - // Pass the entire fileUpload object to the tool parameters.fileUpload = params.fileUpload } - // Convert pages input from string to array of numbers if provided let pagesArray: number[] | undefined if (params.pages && params.pages.trim() !== '') { try { @@ -146,12 +115,12 @@ export const MistralParseBlock: BlockConfig = { if (pagesArray && pagesArray.length === 0) { pagesArray = undefined } - } catch (error: any) { - throw new Error(`Page number format error: ${error.message}`) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Page number format error: ${errorMessage}`) } } - // Add optional parameters if (pagesArray && pagesArray.length > 0) { parameters.pages = pagesArray } @@ -173,3 +142,129 @@ export const MistralParseBlock: BlockConfig = { metadata: { type: 'json', description: 'Processing metadata' }, }, } + +export const MistralParseV2Block: BlockConfig = { + ...MistralParseBlock, + type: 'mistral_parse_v2', + name: 'Mistral Parser', + description: 'Extract text from PDF documents', + hideFromToolbar: false, + subBlocks: [ + { + id: 'fileUpload', + title: 'PDF Document', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'document', + acceptedTypes: 'application/pdf', + placeholder: 'Upload a PDF document', + mode: 'basic', + maxSize: 50, + }, + { + id: 'filePath', + title: 'PDF Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'Document URL', + mode: 'advanced', + }, + { + id: 'resultType', + title: 'Output Format', + type: 'dropdown', + options: [ + { id: 'markdown', label: 'Markdown' }, + { id: 'text', label: 'Plain Text' }, + { id: 'json', label: 'JSON' }, + ], + }, + { + id: 'pages', + title: 'Specific Pages', + type: 'short-input', + placeholder: 'e.g. 0,1,2 (leave empty for all pages)', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your Mistral API key', + password: true, + required: true, + }, + ], + tools: { + access: ['mistral_parser_v2'], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: () => 'mistral_parser', + suffix: '_v2', + fallbackToolId: 'mistral_parser_v2', + }), + params: (params) => { + if (!params || !params.apiKey || params.apiKey.trim() === '') { + throw new Error('Mistral API key is required') + } + + const parameters: Record = { + apiKey: params.apiKey.trim(), + resultType: params.resultType || 'markdown', + } + + const documentInput = params.fileUpload || params.filePath || params.document + if (!documentInput) { + throw new Error('PDF document is required') + } + if (typeof documentInput === 'object') { + parameters.fileUpload = documentInput + } else if (typeof documentInput === 'string') { + parameters.filePath = documentInput.trim() + } + + let pagesArray: number[] | undefined + if (params.pages && params.pages.trim() !== '') { + try { + pagesArray = params.pages + .split(',') + .map((p: string) => p.trim()) + .filter((p: string) => p.length > 0) + .map((p: string) => { + const num = Number.parseInt(p, 10) + if (Number.isNaN(num) || num < 0) { + throw new Error(`Invalid page number: ${p}`) + } + return num + }) + + if (pagesArray && pagesArray.length === 0) { + pagesArray = undefined + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Page number format error: ${errorMessage}`) + } + } + + if (pagesArray && pagesArray.length > 0) { + parameters.pages = pagesArray + } + + return parameters + }, + }, + }, + inputs: { + document: { type: 'json', description: 'Document input (file upload or URL reference)' }, + filePath: { type: 'string', description: 'PDF document URL (advanced mode)' }, + fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' }, + apiKey: { type: 'string', description: 'Mistral API key' }, + resultType: { type: 'string', description: 'Output format type' }, + pages: { type: 'string', description: 'Page selection' }, + }, + outputs: { + pages: { type: 'array', description: 'Array of page objects from Mistral OCR' }, + model: { type: 'string', description: 'Mistral OCR model identifier' }, + usage_info: { type: 'json', description: 'Usage statistics from the API' }, + document_annotation: { type: 'string', description: 'Structured annotation data' }, + }, +} diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 212f325d71..0e2f5658fe 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -14,36 +14,24 @@ export const PulseBlock: BlockConfig = { bgColor: '#E0E0E0', icon: PulseIcon, subBlocks: [ - { - id: 'inputMethod', - title: 'Select Input Method', - type: 'dropdown' as SubBlockType, - options: [ - { id: 'url', label: 'Document URL' }, - { id: 'upload', label: 'Upload Document' }, - ], - }, - { - id: 'filePath', - title: 'Document URL', - type: 'short-input' as SubBlockType, - placeholder: 'Enter full URL to a document (https://example.com/document.pdf)', - condition: { - field: 'inputMethod', - value: 'url', - }, - }, { id: 'fileUpload', - title: 'Upload Document', + title: 'Document', type: 'file-upload' as SubBlockType, + canonicalParamId: 'document', acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx', - condition: { - field: 'inputMethod', - value: 'upload', - }, + placeholder: 'Upload a document', + mode: 'basic', maxSize: 50, }, + { + id: 'filePath', + title: 'Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'Document URL', + mode: 'advanced', + }, { id: 'pages', title: 'Specific Pages', @@ -84,17 +72,14 @@ export const PulseBlock: BlockConfig = { apiKey: params.apiKey.trim(), } - const inputMethod = params.inputMethod || 'url' - if (inputMethod === 'url') { - if (!params.filePath || params.filePath.trim() === '') { - throw new Error('Document URL is required') - } - parameters.filePath = params.filePath.trim() - } else if (inputMethod === 'upload') { - if (!params.fileUpload) { - throw new Error('Please upload a document') - } - parameters.fileUpload = params.fileUpload + const documentInput = params.fileUpload || params.filePath || params.document + if (!documentInput) { + throw new Error('Document is required') + } + if (typeof documentInput === 'object') { + parameters.fileUpload = documentInput + } else if (typeof documentInput === 'string') { + parameters.filePath = documentInput.trim() } if (params.pages && params.pages.trim() !== '') { @@ -117,9 +102,9 @@ export const PulseBlock: BlockConfig = { }, }, inputs: { - inputMethod: { type: 'string', description: 'Input method selection' }, - filePath: { type: 'string', description: 'Document URL' }, - fileUpload: { type: 'json', description: 'Uploaded document file' }, + document: { type: 'json', description: 'Document input (file upload or URL reference)' }, + filePath: { type: 'string', description: 'Document URL (advanced mode)' }, + fileUpload: { type: 'json', description: 'Uploaded document file (basic mode)' }, apiKey: { type: 'string', description: 'Pulse API key' }, pages: { type: 'string', description: 'Page range selection' }, chunking: { diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index 5dd33dcb65..681c2aa207 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -13,36 +13,24 @@ export const ReductoBlock: BlockConfig = { bgColor: '#5c0c5c', icon: ReductoIcon, subBlocks: [ - { - id: 'inputMethod', - title: 'Select Input Method', - type: 'dropdown' as SubBlockType, - options: [ - { id: 'url', label: 'PDF Document URL' }, - { id: 'upload', label: 'Upload PDF Document' }, - ], - }, - { - id: 'filePath', - title: 'PDF Document URL', - type: 'short-input' as SubBlockType, - placeholder: 'Enter full URL to a PDF document (https://example.com/document.pdf)', - condition: { - field: 'inputMethod', - value: 'url', - }, - }, { id: 'fileUpload', - title: 'Upload PDF', + title: 'PDF Document', type: 'file-upload' as SubBlockType, + canonicalParamId: 'document', acceptedTypes: 'application/pdf', - condition: { - field: 'inputMethod', - value: 'upload', - }, + placeholder: 'Upload a PDF document', + mode: 'basic', maxSize: 50, }, + { + id: 'filePath', + title: 'PDF Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'Document URL', + mode: 'advanced', + }, { id: 'pages', title: 'Specific Pages', @@ -80,17 +68,15 @@ export const ReductoBlock: BlockConfig = { apiKey: params.apiKey.trim(), } - const inputMethod = params.inputMethod || 'url' - if (inputMethod === 'url') { - if (!params.filePath || params.filePath.trim() === '') { - throw new Error('PDF Document URL is required') - } - parameters.filePath = params.filePath.trim() - } else if (inputMethod === 'upload') { - if (!params.fileUpload) { - throw new Error('Please upload a PDF document') - } - parameters.fileUpload = params.fileUpload + const documentInput = params.fileUpload || params.filePath || params.document + if (!documentInput) { + throw new Error('PDF document is required') + } + + if (typeof documentInput === 'object') { + parameters.fileUpload = documentInput + } else if (typeof documentInput === 'string') { + parameters.filePath = documentInput.trim() } let pagesArray: number[] | undefined @@ -130,9 +116,9 @@ export const ReductoBlock: BlockConfig = { }, }, inputs: { - inputMethod: { type: 'string', description: 'Input method selection' }, - filePath: { type: 'string', description: 'PDF document URL' }, - fileUpload: { type: 'json', description: 'Uploaded PDF file' }, + document: { type: 'json', description: 'Document input (file upload or URL reference)' }, + filePath: { type: 'string', description: 'PDF document URL (advanced mode)' }, + fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' }, apiKey: { type: 'string', description: 'Reducto API key' }, pages: { type: 'string', description: 'Page selection' }, tableOutputFormat: { type: 'string', description: 'Table output format' }, diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index ff0a0d53e3..6dba63175f 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -414,6 +414,10 @@ export const S3Block: BlockConfig = { }, outputs: { url: { type: 'string', description: 'URL of S3 object' }, + uri: { + type: 'string', + description: 'S3 URI (s3://bucket/key) for use with other AWS services', + }, objects: { type: 'json', description: 'List of objects (for list operation)' }, deleted: { type: 'boolean', description: 'Deletion status' }, metadata: { type: 'json', description: 'Operation metadata' }, diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts new file mode 100644 index 0000000000..2b83887089 --- /dev/null +++ b/apps/sim/blocks/blocks/textract.ts @@ -0,0 +1,191 @@ +import { TextractIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import type { TextractParserOutput } from '@/tools/textract/types' + +export const TextractBlock: BlockConfig = { + type: 'textract', + name: 'AWS Textract', + description: 'Extract text, tables, and forms from documents', + authMode: AuthMode.ApiKey, + longDescription: `Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.`, + docsLink: 'https://docs.sim.ai/tools/textract', + category: 'tools', + bgColor: 'linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)', + icon: TextractIcon, + subBlocks: [ + { + id: 'processingMode', + title: 'Processing Mode', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'sync', label: 'Single Page (JPEG, PNG, 1-page PDF)' }, + { id: 'async', label: 'Multi-Page (PDF, TIFF via S3)' }, + ], + tooltip: + 'Single Page uses synchronous API for JPEG, PNG, or single-page PDF. Multi-Page uses async API for multi-page PDF/TIFF stored in S3.', + }, + { + id: 'fileUpload', + title: 'Document', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'document', + acceptedTypes: 'image/jpeg,image/png,application/pdf', + placeholder: 'Upload JPEG, PNG, or single-page PDF (max 10MB)', + condition: { + field: 'processingMode', + value: 'async', + not: true, + }, + mode: 'basic', + maxSize: 10, + }, + { + id: 'filePath', + title: 'Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'URL to JPEG, PNG, or single-page PDF', + condition: { + field: 'processingMode', + value: 'async', + not: true, + }, + mode: 'advanced', + }, + { + id: 's3Uri', + title: 'S3 URI', + type: 'short-input' as SubBlockType, + placeholder: 's3://bucket-name/path/to/document.pdf', + condition: { + field: 'processingMode', + value: 'async', + }, + }, + { + id: 'region', + title: 'AWS Region', + type: 'short-input' as SubBlockType, + placeholder: 'e.g., us-east-1', + required: true, + }, + { + id: 'accessKeyId', + title: 'AWS Access Key ID', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your AWS Access Key ID', + password: true, + required: true, + }, + { + id: 'secretAccessKey', + title: 'AWS Secret Access Key', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your AWS Secret Access Key', + password: true, + required: true, + }, + { + id: 'extractTables', + title: 'Extract Tables', + type: 'switch' as SubBlockType, + }, + { + id: 'extractForms', + title: 'Extract Forms (Key-Value Pairs)', + type: 'switch' as SubBlockType, + }, + { + id: 'detectSignatures', + title: 'Detect Signatures', + type: 'switch' as SubBlockType, + }, + { + id: 'analyzeLayout', + title: 'Analyze Document Layout', + type: 'switch' as SubBlockType, + }, + ], + tools: { + access: ['textract_parser'], + config: { + tool: () => 'textract_parser', + params: (params) => { + if (!params.accessKeyId || params.accessKeyId.trim() === '') { + throw new Error('AWS Access Key ID is required') + } + if (!params.secretAccessKey || params.secretAccessKey.trim() === '') { + throw new Error('AWS Secret Access Key is required') + } + if (!params.region || params.region.trim() === '') { + throw new Error('AWS Region is required') + } + + const processingMode = params.processingMode || 'sync' + const parameters: Record = { + accessKeyId: params.accessKeyId.trim(), + secretAccessKey: params.secretAccessKey.trim(), + region: params.region.trim(), + processingMode, + } + + if (processingMode === 'async') { + if (!params.s3Uri || params.s3Uri.trim() === '') { + throw new Error('S3 URI is required for multi-page processing') + } + parameters.s3Uri = params.s3Uri.trim() + } else { + const documentInput = params.fileUpload || params.filePath || params.document + if (!documentInput) { + throw new Error('Document is required') + } + if (typeof documentInput === 'object') { + parameters.fileUpload = documentInput + } else if (typeof documentInput === 'string') { + parameters.filePath = documentInput.trim() + } + } + + const featureTypes: string[] = [] + if (params.extractTables) featureTypes.push('TABLES') + if (params.extractForms) featureTypes.push('FORMS') + if (params.detectSignatures) featureTypes.push('SIGNATURES') + if (params.analyzeLayout) featureTypes.push('LAYOUT') + + if (featureTypes.length > 0) { + parameters.featureTypes = featureTypes + } + + return parameters + }, + }, + }, + inputs: { + processingMode: { type: 'string', description: 'Document type: single-page or multi-page' }, + document: { type: 'json', description: 'Document input (file upload or URL reference)' }, + filePath: { type: 'string', description: 'Document URL (advanced mode)' }, + fileUpload: { type: 'json', description: 'Uploaded document file (basic mode)' }, + s3Uri: { type: 'string', description: 'S3 URI for multi-page processing (s3://bucket/key)' }, + extractTables: { type: 'boolean', description: 'Extract tables from document' }, + extractForms: { type: 'boolean', description: 'Extract form key-value pairs' }, + detectSignatures: { type: 'boolean', description: 'Detect signatures' }, + analyzeLayout: { type: 'boolean', description: 'Analyze document layout' }, + region: { type: 'string', description: 'AWS region' }, + accessKeyId: { type: 'string', description: 'AWS Access Key ID' }, + secretAccessKey: { type: 'string', description: 'AWS Secret Access Key' }, + }, + outputs: { + blocks: { + type: 'json', + description: 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)', + }, + documentMetadata: { + type: 'json', + description: 'Document metadata containing pages count', + }, + modelVersion: { + type: 'string', + description: 'Version of the Textract model used for processing', + }, + }, +} diff --git a/apps/sim/blocks/blocks/video_generator.ts b/apps/sim/blocks/blocks/video_generator.ts index 86e3576c5e..88672a17be 100644 --- a/apps/sim/blocks/blocks/video_generator.ts +++ b/apps/sim/blocks/blocks/video_generator.ts @@ -4,8 +4,9 @@ import type { VideoBlockResponse } from '@/tools/video/types' export const VideoGeneratorBlock: BlockConfig = { type: 'video_generator', - name: 'Video Generator', + name: 'Video Generator (Legacy)', description: 'Generate videos from text using AI', + hideFromToolbar: true, authMode: AuthMode.ApiKey, longDescription: 'Generate high-quality videos from text prompts using leading AI providers. Supports multiple models, aspect ratios, resolutions, and provider-specific features like world consistency, camera controls, and audio generation.', @@ -427,3 +428,378 @@ export const VideoGeneratorBlock: BlockConfig = { model: { type: 'string', description: 'Model used' }, }, } + +export const VideoGeneratorV2Block: BlockConfig = { + ...VideoGeneratorBlock, + type: 'video_generator_v2', + name: 'Video Generator', + hideFromToolbar: false, + subBlocks: [ + { + id: 'provider', + title: 'Provider', + type: 'dropdown', + options: [ + { label: 'Runway Gen-4', id: 'runway' }, + { label: 'Google Veo 3', id: 'veo' }, + { label: 'Luma Dream Machine', id: 'luma' }, + { label: 'MiniMax Hailuo', id: 'minimax' }, + { label: 'Fal.ai (Multi-Model)', id: 'falai' }, + ], + value: () => 'runway', + required: true, + }, + { + id: 'model', + title: 'Model', + type: 'dropdown', + condition: { field: 'provider', value: 'veo' }, + options: [ + { label: 'Veo 3', id: 'veo-3' }, + { label: 'Veo 3 Fast', id: 'veo-3-fast' }, + { label: 'Veo 3.1', id: 'veo-3.1' }, + ], + value: () => 'veo-3', + required: false, + }, + { + id: 'model', + title: 'Model', + type: 'dropdown', + condition: { field: 'provider', value: 'luma' }, + options: [{ label: 'Ray 2', id: 'ray-2' }], + value: () => 'ray-2', + required: false, + }, + { + id: 'model', + title: 'Model', + type: 'dropdown', + condition: { field: 'provider', value: 'minimax' }, + options: [{ label: 'Hailuo 2.3', id: 'hailuo-02' }], + value: () => 'hailuo-02', + required: false, + }, + { + id: 'endpoint', + title: 'Quality Endpoint', + type: 'dropdown', + condition: { field: 'provider', value: 'minimax' }, + options: [ + { label: 'Pro', id: 'pro' }, + { label: 'Standard', id: 'standard' }, + ], + value: () => 'standard', + required: false, + }, + { + id: 'model', + title: 'Model', + type: 'dropdown', + condition: { field: 'provider', value: 'falai' }, + options: [ + { label: 'Google Veo 3.1', id: 'veo-3.1' }, + { label: 'OpenAI Sora 2', id: 'sora-2' }, + { label: 'Kling 2.5 Turbo Pro', id: 'kling-2.5-turbo-pro' }, + { label: 'Kling 2.1 Pro', id: 'kling-2.1-pro' }, + { label: 'MiniMax Hailuo 2.3 Pro', id: 'minimax-hailuo-2.3-pro' }, + { label: 'MiniMax Hailuo 2.3 Standard', id: 'minimax-hailuo-2.3-standard' }, + { label: 'WAN 2.1', id: 'wan-2.1' }, + { label: 'LTXV 0.9.8', id: 'ltxv-0.9.8' }, + ], + value: () => 'veo-3.1', + required: true, + }, + { + id: 'prompt', + title: 'Prompt', + type: 'long-input', + placeholder: 'Describe the video you want to generate...', + required: true, + }, + { + id: 'duration', + title: 'Duration (seconds)', + type: 'dropdown', + condition: { field: 'provider', value: 'runway' }, + options: [ + { label: '5', id: '5' }, + { label: '10', id: '10' }, + ], + value: () => '5', + required: false, + }, + { + id: 'duration', + title: 'Duration (seconds)', + type: 'dropdown', + condition: { field: 'provider', value: 'veo' }, + options: [ + { label: '4', id: '4' }, + { label: '6', id: '6' }, + { label: '8', id: '8' }, + ], + value: () => '8', + required: false, + }, + { + id: 'duration', + title: 'Duration (seconds)', + type: 'dropdown', + condition: { field: 'provider', value: 'luma' }, + options: [ + { label: '5', id: '5' }, + { label: '9', id: '9' }, + ], + value: () => '5', + required: false, + }, + { + id: 'duration', + title: 'Duration (seconds)', + type: 'dropdown', + condition: { field: 'provider', value: 'minimax' }, + options: [ + { label: '6', id: '6' }, + { label: '10', id: '10' }, + ], + value: () => '6', + required: false, + }, + { + id: 'duration', + title: 'Duration (seconds)', + type: 'dropdown', + condition: { + field: 'model', + value: [ + 'kling-2.5-turbo-pro', + 'kling-2.1-pro', + 'minimax-hailuo-2.3-pro', + 'minimax-hailuo-2.3-standard', + ], + }, + options: [ + { label: '5', id: '5' }, + { label: '8', id: '8' }, + { label: '10', id: '10' }, + ], + value: () => '5', + required: false, + }, + { + id: 'aspectRatio', + title: 'Aspect Ratio', + type: 'dropdown', + condition: { field: 'provider', value: 'veo' }, + options: [ + { label: '16:9', id: '16:9' }, + { label: '9:16', id: '9:16' }, + ], + value: () => '16:9', + required: false, + }, + { + id: 'aspectRatio', + title: 'Aspect Ratio', + type: 'dropdown', + condition: { field: 'provider', value: 'runway' }, + options: [ + { label: '16:9', id: '16:9' }, + { label: '9:16', id: '9:16' }, + { label: '1:1', id: '1:1' }, + ], + value: () => '16:9', + required: false, + }, + { + id: 'aspectRatio', + title: 'Aspect Ratio', + type: 'dropdown', + condition: { field: 'provider', value: 'luma' }, + options: [ + { label: '16:9', id: '16:9' }, + { label: '9:16', id: '9:16' }, + { label: '1:1', id: '1:1' }, + ], + value: () => '16:9', + required: false, + }, + { + id: 'aspectRatio', + title: 'Aspect Ratio', + type: 'dropdown', + condition: { + field: 'model', + value: [ + 'kling-2.5-turbo-pro', + 'kling-2.1-pro', + 'minimax-hailuo-2.3-pro', + 'minimax-hailuo-2.3-standard', + ], + }, + options: [ + { label: '16:9', id: '16:9' }, + { label: '9:16', id: '9:16' }, + ], + value: () => '16:9', + required: false, + }, + { + id: 'resolution', + title: 'Resolution', + type: 'dropdown', + condition: { field: 'provider', value: 'veo' }, + options: [ + { label: '720p', id: '720p' }, + { label: '1080p', id: '1080p' }, + ], + value: () => '1080p', + required: false, + }, + { + id: 'resolution', + title: 'Resolution', + type: 'dropdown', + condition: { field: 'provider', value: 'luma' }, + options: [ + { label: '540p', id: '540p' }, + { label: '720p', id: '720p' }, + { label: '1080p', id: '1080p' }, + ], + value: () => '1080p', + required: false, + }, + { + id: 'visualReferenceUpload', + title: 'Reference Image', + type: 'file-upload', + canonicalParamId: 'visualReference', + condition: { field: 'provider', value: 'runway' }, + placeholder: 'Upload reference image', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: '.jpg,.jpeg,.png,.webp', + }, + { + id: 'visualReferenceInput', + title: 'Reference Image', + type: 'short-input', + canonicalParamId: 'visualReference', + condition: { field: 'provider', value: 'runway' }, + placeholder: 'Reference image from previous blocks', + mode: 'advanced', + }, + { + id: 'cameraControl', + title: 'Camera Controls', + type: 'long-input', + condition: { field: 'provider', value: 'luma' }, + placeholder: 'JSON: [{ "key": "pan_right" }, { "key": "zoom_in" }]', + required: false, + }, + { + id: 'promptOptimizer', + title: 'Prompt Optimizer', + type: 'switch', + condition: { field: 'provider', value: 'minimax' }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your provider API key', + password: true, + required: true, + }, + ], + tools: { + access: ['video_runway', 'video_veo', 'video_luma', 'video_minimax', 'video_falai'], + config: { + tool: (params) => { + switch (params.provider) { + case 'runway': + return 'video_runway' + case 'veo': + return 'video_veo' + case 'luma': + return 'video_luma' + case 'minimax': + return 'video_minimax' + case 'falai': + return 'video_falai' + default: + return 'video_runway' + } + }, + params: (params) => { + const visualRef = + params.visualReferenceUpload || params.visualReferenceInput || params.visualReference + return { + provider: params.provider, + apiKey: params.apiKey, + model: params.model, + endpoint: params.endpoint, + prompt: params.prompt, + duration: params.duration ? Number(params.duration) : undefined, + aspectRatio: params.aspectRatio, + resolution: params.resolution, + visualReference: visualRef, + consistencyMode: params.consistencyMode, + stylePreset: params.stylePreset, + promptOptimizer: params.promptOptimizer, + cameraControl: params.cameraControl + ? typeof params.cameraControl === 'string' + ? JSON.parse(params.cameraControl) + : params.cameraControl + : undefined, + } + }, + }, + }, + inputs: { + provider: { + type: 'string', + description: 'Video generation provider (runway, veo, luma, minimax)', + }, + apiKey: { type: 'string', description: 'Provider API key' }, + model: { + type: 'string', + description: 'Provider-specific model', + }, + endpoint: { + type: 'string', + description: 'Quality endpoint for MiniMax (pro, standard)', + }, + prompt: { type: 'string', description: 'Text prompt for video generation' }, + duration: { type: 'number', description: 'Video duration in seconds' }, + aspectRatio: { + type: 'string', + description: 'Aspect ratio (16:9, 9:16, 1:1) - not available for MiniMax', + }, + resolution: { + type: 'string', + description: 'Video resolution - not available for MiniMax (fixed per endpoint)', + }, + visualReference: { type: 'json', description: 'Reference image for Runway (UserFile)' }, + visualReferenceUpload: { type: 'json', description: 'Uploaded reference image (basic mode)' }, + visualReferenceInput: { + type: 'json', + description: 'Reference image from previous blocks (advanced mode)', + }, + consistencyMode: { + type: 'string', + description: 'Consistency mode for Runway (character, object, style, location)', + }, + stylePreset: { type: 'string', description: 'Style preset for Runway' }, + promptOptimizer: { + type: 'boolean', + description: 'Enable prompt optimization for MiniMax (default: true)', + }, + cameraControl: { + type: 'json', + description: 'Camera controls for Luma (pan, zoom, tilt, truck, tracking)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 544c294322..e28ca604e2 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -14,7 +14,7 @@ import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { CirclebackBlock } from '@/blocks/blocks/circleback' import { ClayBlock } from '@/blocks/blocks/clay' import { ConditionBlock } from '@/blocks/blocks/condition' -import { ConfluenceBlock } from '@/blocks/blocks/confluence' +import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence' import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor' import { DatadogBlock } from '@/blocks/blocks/datadog' import { DiscordBlock } from '@/blocks/blocks/discord' @@ -25,7 +25,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch' import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs' import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { ExaBlock } from '@/blocks/blocks/exa' -import { FileBlock } from '@/blocks/blocks/file' +import { FileBlock, FileV2Block } from '@/blocks/blocks/file' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' import { FirefliesBlock } from '@/blocks/blocks/fireflies' import { FunctionBlock } from '@/blocks/blocks/function' @@ -74,7 +74,7 @@ import { MemoryBlock } from '@/blocks/blocks/memory' import { MicrosoftExcelBlock, MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel' import { MicrosoftPlannerBlock } from '@/blocks/blocks/microsoft_planner' import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams' -import { MistralParseBlock } from '@/blocks/blocks/mistral_parse' +import { MistralParseBlock, MistralParseV2Block } from '@/blocks/blocks/mistral_parse' import { MongoDBBlock } from '@/blocks/blocks/mongodb' import { MySQLBlock } from '@/blocks/blocks/mysql' import { Neo4jBlock } from '@/blocks/blocks/neo4j' @@ -123,6 +123,7 @@ import { SttBlock } from '@/blocks/blocks/stt' import { SupabaseBlock } from '@/blocks/blocks/supabase' import { TavilyBlock } from '@/blocks/blocks/tavily' import { TelegramBlock } from '@/blocks/blocks/telegram' +import { TextractBlock } from '@/blocks/blocks/textract' import { ThinkingBlock } from '@/blocks/blocks/thinking' import { TinybirdBlock } from '@/blocks/blocks/tinybird' import { TranslateBlock } from '@/blocks/blocks/translate' @@ -132,7 +133,7 @@ import { TwilioSMSBlock } from '@/blocks/blocks/twilio' import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice' import { TypeformBlock } from '@/blocks/blocks/typeform' import { VariablesBlock } from '@/blocks/blocks/variables' -import { VideoGeneratorBlock } from '@/blocks/blocks/video_generator' +import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator' import { VisionBlock } from '@/blocks/blocks/vision' import { WaitBlock } from '@/blocks/blocks/wait' import { WealthboxBlock } from '@/blocks/blocks/wealthbox' @@ -169,6 +170,7 @@ export const registry: Record = { clay: ClayBlock, condition: ConditionBlock, confluence: ConfluenceBlock, + confluence_v2: ConfluenceV2Block, cursor: CursorBlock, cursor_v2: CursorV2Block, datadog: DatadogBlock, @@ -181,6 +183,7 @@ export const registry: Record = { evaluator: EvaluatorBlock, exa: ExaBlock, file: FileBlock, + file_v2: FileV2Block, firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, function: FunctionBlock, @@ -236,6 +239,7 @@ export const registry: Record = { microsoft_planner: MicrosoftPlannerBlock, microsoft_teams: MicrosoftTeamsBlock, mistral_parse: MistralParseBlock, + mistral_parse_v2: MistralParseV2Block, mongodb: MongoDBBlock, mysql: MySQLBlock, neo4j: Neo4jBlock, @@ -286,6 +290,7 @@ export const registry: Record = { supabase: SupabaseBlock, tavily: TavilyBlock, telegram: TelegramBlock, + textract: TextractBlock, thinking: ThinkingBlock, tinybird: TinybirdBlock, translate: TranslateBlock, @@ -296,6 +301,7 @@ export const registry: Record = { typeform: TypeformBlock, variables: VariablesBlock, video_generator: VideoGeneratorBlock, + video_generator_v2: VideoGeneratorV2Block, vision: VisionBlock, wait: WaitBlock, wealthbox: WealthboxBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 1c245ffafd..689dbb50a5 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps) { ) } +export function TextractIcon(props: SVGProps) { + return ( + + + + ) +} + export function McpIcon(props: SVGProps) { return ( { }) }) }) + +describe('validateAwsRegion', () => { + describe('valid standard regions', () => { + it.concurrent('should accept us-east-1', () => { + const result = validateAwsRegion('us-east-1') + expect(result.isValid).toBe(true) + expect(result.sanitized).toBe('us-east-1') + }) + + it.concurrent('should accept us-west-2', () => { + const result = validateAwsRegion('us-west-2') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept eu-west-1', () => { + const result = validateAwsRegion('eu-west-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept eu-central-1', () => { + const result = validateAwsRegion('eu-central-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept ap-southeast-1', () => { + const result = validateAwsRegion('ap-southeast-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept ap-northeast-1', () => { + const result = validateAwsRegion('ap-northeast-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept sa-east-1', () => { + const result = validateAwsRegion('sa-east-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept me-south-1', () => { + const result = validateAwsRegion('me-south-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept af-south-1', () => { + const result = validateAwsRegion('af-south-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept ca-central-1', () => { + const result = validateAwsRegion('ca-central-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept il-central-1', () => { + const result = validateAwsRegion('il-central-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept regions with double-digit numbers', () => { + const result = validateAwsRegion('ap-northeast-12') + expect(result.isValid).toBe(true) + }) + }) + + describe('valid GovCloud regions', () => { + it.concurrent('should accept us-gov-west-1', () => { + const result = validateAwsRegion('us-gov-west-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept us-gov-east-1', () => { + const result = validateAwsRegion('us-gov-east-1') + expect(result.isValid).toBe(true) + }) + }) + + describe('valid China regions', () => { + it.concurrent('should accept cn-north-1', () => { + const result = validateAwsRegion('cn-north-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept cn-northwest-1', () => { + const result = validateAwsRegion('cn-northwest-1') + expect(result.isValid).toBe(true) + }) + }) + + describe('valid ISO regions', () => { + it.concurrent('should accept us-iso-east-1', () => { + const result = validateAwsRegion('us-iso-east-1') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept us-isob-east-1', () => { + const result = validateAwsRegion('us-isob-east-1') + expect(result.isValid).toBe(true) + }) + }) + + describe('invalid regions', () => { + it.concurrent('should reject null', () => { + const result = validateAwsRegion(null) + expect(result.isValid).toBe(false) + expect(result.error).toContain('required') + }) + + it.concurrent('should reject empty string', () => { + const result = validateAwsRegion('') + expect(result.isValid).toBe(false) + expect(result.error).toContain('required') + }) + + it.concurrent('should reject uppercase regions', () => { + const result = validateAwsRegion('US-EAST-1') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject invalid format - missing number', () => { + const result = validateAwsRegion('us-east') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject invalid format - wrong separators', () => { + const result = validateAwsRegion('us_east_1') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject invalid format - too many parts', () => { + const result = validateAwsRegion('us-east-1-extra') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject path traversal attempts', () => { + const result = validateAwsRegion('../etc/passwd') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject arbitrary strings', () => { + const result = validateAwsRegion('not-a-region') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject invalid prefix', () => { + const result = validateAwsRegion('xx-east-1') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject invalid direction', () => { + const result = validateAwsRegion('us-middle-1') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should use custom param name in errors', () => { + const result = validateAwsRegion('', 'awsRegion') + expect(result.error).toContain('awsRegion') + }) + }) +}) + +describe('validateS3BucketName', () => { + describe('valid bucket names', () => { + it.concurrent('should accept simple bucket name', () => { + const result = validateS3BucketName('my-bucket') + expect(result.isValid).toBe(true) + expect(result.sanitized).toBe('my-bucket') + }) + + it.concurrent('should accept bucket name with numbers', () => { + const result = validateS3BucketName('bucket123') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept bucket name with periods', () => { + const result = validateS3BucketName('my.bucket.name') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept 3 character bucket name', () => { + const result = validateS3BucketName('abc') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept 63 character bucket name', () => { + const result = validateS3BucketName('a'.repeat(63)) + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept minimum valid bucket name (3 chars)', () => { + const result = validateS3BucketName('a1b') + expect(result.isValid).toBe(true) + }) + }) + + describe('invalid bucket names - null/empty', () => { + it.concurrent('should reject null', () => { + const result = validateS3BucketName(null) + expect(result.isValid).toBe(false) + expect(result.error).toContain('required') + }) + + it.concurrent('should reject empty string', () => { + const result = validateS3BucketName('') + expect(result.isValid).toBe(false) + expect(result.error).toContain('required') + }) + }) + + describe('invalid bucket names - length', () => { + it.concurrent('should reject 2 character bucket name', () => { + const result = validateS3BucketName('ab') + expect(result.isValid).toBe(false) + expect(result.error).toContain('between 3 and 63') + }) + + it.concurrent('should reject 64 character bucket name', () => { + const result = validateS3BucketName('a'.repeat(64)) + expect(result.isValid).toBe(false) + expect(result.error).toContain('between 3 and 63') + }) + }) + + describe('invalid bucket names - format', () => { + it.concurrent('should reject uppercase letters', () => { + const result = validateS3BucketName('MyBucket') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject underscores', () => { + const result = validateS3BucketName('my_bucket') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject starting with hyphen', () => { + const result = validateS3BucketName('-mybucket') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject ending with hyphen', () => { + const result = validateS3BucketName('mybucket-') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject starting with period', () => { + const result = validateS3BucketName('.mybucket') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject ending with period', () => { + const result = validateS3BucketName('mybucket.') + expect(result.isValid).toBe(false) + }) + + it.concurrent('should reject consecutive periods', () => { + const result = validateS3BucketName('my..bucket') + expect(result.isValid).toBe(false) + expect(result.error).toContain('consecutive periods') + }) + + it.concurrent('should reject IP address format', () => { + const result = validateS3BucketName('192.168.1.1') + expect(result.isValid).toBe(false) + expect(result.error).toContain('IP address') + }) + + it.concurrent('should reject special characters', () => { + const result = validateS3BucketName('my@bucket') + expect(result.isValid).toBe(false) + }) + }) + + describe('error messages', () => { + it.concurrent('should use custom param name in errors', () => { + const result = validateS3BucketName('', 's3Bucket') + expect(result.error).toContain('s3Bucket') + }) + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index f15b2412e8..5632761a54 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -6,18 +6,12 @@ import * as ipaddr from 'ipaddr.js' const logger = createLogger('InputValidation') -/** - * Result type for validation functions - */ export interface ValidationResult { isValid: boolean error?: string sanitized?: string } -/** - * Options for path segment validation - */ export interface PathSegmentOptions { /** Name of the parameter for error messages */ paramName?: string @@ -68,7 +62,6 @@ export function validatePathSegment( customPattern, } = options - // Check for null/undefined if (value === null || value === undefined || value === '') { return { isValid: false, @@ -76,7 +69,6 @@ export function validatePathSegment( } } - // Check length if (value.length > maxLength) { logger.warn('Path segment exceeds maximum length', { paramName, @@ -89,7 +81,6 @@ export function validatePathSegment( } } - // Check for null bytes (potential for bypass attacks) if (value.includes('\0') || value.includes('%00')) { logger.warn('Path segment contains null bytes', { paramName }) return { @@ -98,7 +89,6 @@ export function validatePathSegment( } } - // Check for path traversal patterns const pathTraversalPatterns = [ '..', './', @@ -127,7 +117,6 @@ export function validatePathSegment( } } - // Check for directory separators if (value.includes('/') || value.includes('\\')) { logger.warn('Path segment contains directory separators', { paramName }) return { @@ -136,7 +125,6 @@ export function validatePathSegment( } } - // Use custom pattern if provided if (customPattern) { if (!customPattern.test(value)) { logger.warn('Path segment failed custom pattern validation', { @@ -151,7 +139,6 @@ export function validatePathSegment( return { isValid: true, sanitized: value } } - // Build allowed character pattern let pattern = '^[a-zA-Z0-9' if (allowHyphens) pattern += '\\-' if (allowUnderscores) pattern += '_' @@ -1083,6 +1070,138 @@ export function validateAirtableId( return { isValid: true, sanitized: value } } +/** + * Validates an AWS region identifier + * + * Supported region formats: + * - Standard: us-east-1, eu-west-2, ap-southeast-1, sa-east-1, af-south-1 + * - GovCloud: us-gov-east-1, us-gov-west-1 + * - China: cn-north-1, cn-northwest-1 + * - Israel: il-central-1 + * - ISO partitions: us-iso-east-1, us-isob-east-1 + * + * @param value - The AWS region to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + * + * @example + * ```typescript + * const result = validateAwsRegion(region, 'region') + * if (!result.isValid) { + * return NextResponse.json({ error: result.error }, { status: 400 }) + * } + * ``` + */ +export function validateAwsRegion( + value: string | null | undefined, + paramName = 'region' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { + isValid: false, + error: `${paramName} is required`, + } + } + + // AWS region patterns: + // - Standard: af|ap|ca|eu|me|sa|us|il followed by direction and number + // - GovCloud: us-gov-east-1, us-gov-west-1 + // - China: cn-north-1, cn-northwest-1 + // - ISO: us-iso-east-1, us-iso-west-1, us-isob-east-1 + const awsRegionPattern = + /^(af|ap|ca|cn|eu|il|me|sa|us|us-gov|us-iso|us-isob)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ + + if (!awsRegionPattern.test(value)) { + logger.warn('Invalid AWS region format', { + paramName, + value: value.substring(0, 50), + }) + return { + isValid: false, + error: `${paramName} must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)`, + } + } + + return { isValid: true, sanitized: value } +} + +/** + * Validates an S3 bucket name according to AWS naming rules + * + * S3 bucket names must: + * - Be 3-63 characters long + * - Start and end with a letter or number + * - Contain only lowercase letters, numbers, and hyphens + * - Not contain consecutive periods + * - Not be formatted as an IP address + * + * @param value - The S3 bucket name to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + * + * @example + * ```typescript + * const result = validateS3BucketName(bucket, 'bucket') + * if (!result.isValid) { + * return NextResponse.json({ error: result.error }, { status: 400 }) + * } + * ``` + */ +export function validateS3BucketName( + value: string | null | undefined, + paramName = 'bucket' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { + isValid: false, + error: `${paramName} is required`, + } + } + + if (value.length < 3 || value.length > 63) { + logger.warn('S3 bucket name length invalid', { + paramName, + length: value.length, + }) + return { + isValid: false, + error: `${paramName} must be between 3 and 63 characters`, + } + } + + const bucketNamePattern = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$|^[a-z0-9]$/ + + if (!bucketNamePattern.test(value)) { + logger.warn('Invalid S3 bucket name format', { + paramName, + value: value.substring(0, 63), + }) + return { + isValid: false, + error: `${paramName} must start and end with a letter or number, and contain only lowercase letters, numbers, hyphens, and periods`, + } + } + + if (value.includes('..')) { + logger.warn('S3 bucket name contains consecutive periods', { paramName }) + return { + isValid: false, + error: `${paramName} cannot contain consecutive periods`, + } + } + + const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/ + if (ipPattern.test(value)) { + logger.warn('S3 bucket name formatted as IP address', { paramName }) + return { + isValid: false, + error: `${paramName} cannot be formatted as an IP address`, + } + } + + return { isValid: true, sanitized: value } +} + /** * Validates a Google Calendar ID * diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 0a7a004d82..b504db175e 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -455,3 +455,27 @@ export async function generatePresignedDownloadUrl( export function hasCloudStorage(): boolean { return USE_BLOB_STORAGE || USE_S3_STORAGE } + +/** + * Get S3 bucket and key information for a storage key + * Useful for services that need direct S3 access (e.g., AWS Textract async) + */ +export function getS3InfoForKey( + key: string, + context: StorageContext +): { bucket: string; key: string } { + if (!USE_S3_STORAGE) { + throw new Error('S3 storage is not configured. Cannot retrieve S3 info for key.') + } + + const config = getStorageConfig(context) + + if (!config.bucket) { + throw new Error(`S3 bucket not configured for context: ${context}`) + } + + return { + bucket: config.bucket, + key, + } +} diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index dd58a2ff54..df1b24c75a 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -562,14 +562,26 @@ function generateOutputPathsWithTypes( * Gets the tool outputs for a block operation. * * @param blockConfig - The block configuration containing tools config - * @param operation - The selected operation for the tool + * @param subBlocks - SubBlock values to pass to the tool selector * @returns Outputs schema for the tool, or empty object on error */ -export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record { +export function getToolOutputs( + blockConfig: BlockConfig, + subBlocks?: Record +): Record { if (!blockConfig?.tools?.config?.tool) return {} try { - const toolId = blockConfig.tools.config.tool({ operation }) + // Build params object from subBlock values for tool selector + // This allows tool selectors to use any field (operation, provider, etc.) + const params: Record = {} + if (subBlocks) { + for (const [key, subBlock] of Object.entries(subBlocks)) { + params[key] = subBlock.value + } + } + + const toolId = blockConfig.tools.config.tool(params) if (!toolId) return {} const toolConfig = getTool(toolId) @@ -577,7 +589,7 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec return toolConfig.outputs } catch (error) { - logger.warn('Failed to get tool outputs for operation', { operation, error }) + logger.warn('Failed to get tool outputs', { error }) return {} } } @@ -586,16 +598,14 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec * Generates output paths for a tool-based block. * * @param blockConfig - The block configuration containing tools config - * @param operation - The selected operation for the tool - * @param subBlocks - Optional subBlock values for condition evaluation + * @param subBlocks - SubBlock values for tool selection and condition evaluation * @returns Array of output paths for the tool, or empty array on error */ export function getToolOutputPaths( blockConfig: BlockConfig, - operation: string, subBlocks?: Record ): string[] { - const outputs = getToolOutputs(blockConfig, operation) + const outputs = getToolOutputs(blockConfig, subBlocks) if (!outputs || Object.keys(outputs).length === 0) return [] @@ -630,16 +640,16 @@ export function getOutputPathsFromSchema(outputs: Record): string[] * Gets the output type for a specific path in a tool's outputs. * * @param blockConfig - The block configuration containing tools config - * @param operation - The selected operation for the tool + * @param subBlocks - SubBlock values for tool selection * @param path - The dot-separated path to the output field * @returns The type of the output field, or 'any' if not found */ export function getToolOutputType( blockConfig: BlockConfig, - operation: string, + subBlocks: Record | undefined, path: string ): string { - const outputs = getToolOutputs(blockConfig, operation) + const outputs = getToolOutputs(blockConfig, subBlocks) if (!outputs || Object.keys(outputs).length === 0) return 'any' const pathsWithTypes = generateOutputPathsWithTypes(outputs) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 493e64c2e8..9d42bee2a1 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -34,6 +34,51 @@ import { mergeToolParameters } from '@/tools/params' const logger = createLogger('ProviderUtils') +/** + * Checks if a workflow description is a default/placeholder description + */ +function isDefaultWorkflowDescription( + description: string | null | undefined, + name?: string +): boolean { + if (!description) return true + const normalizedDesc = description.toLowerCase().trim() + return ( + description === name || + normalizedDesc === 'new workflow' || + normalizedDesc === 'your first workflow - start building here!' + ) +} + +/** + * Fetches workflow metadata (name and description) from the API + */ +async function fetchWorkflowMetadata( + workflowId: string +): Promise<{ name: string; description: string | null } | null> { + try { + const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') + + const headers = await buildAuthHeaders() + const url = buildAPIUrl(`/api/workflows/${workflowId}`) + + const response = await fetch(url.toString(), { headers }) + if (!response.ok) { + logger.warn(`Failed to fetch workflow metadata for ${workflowId}`) + return null + } + + const { data } = await response.json() + return { + name: data?.name || 'Workflow', + description: data?.description || null, + } + } catch (error) { + logger.error('Error fetching workflow metadata:', error) + return null + } +} + /** * Client-safe provider metadata. * This object contains only model lists and patterns - no executeRequest implementations. @@ -479,16 +524,30 @@ export async function transformBlockTool( const llmSchema = await createLLMToolSchema(toolConfig, userProvidedParams) let uniqueToolId = toolConfig.id + let toolName = toolConfig.name + let toolDescription = toolConfig.description + if (toolId === 'workflow_executor' && userProvidedParams.workflowId) { uniqueToolId = `${toolConfig.id}_${userProvidedParams.workflowId}` + + const workflowMetadata = await fetchWorkflowMetadata(userProvidedParams.workflowId) + if (workflowMetadata) { + toolName = workflowMetadata.name || toolConfig.name + if ( + workflowMetadata.description && + !isDefaultWorkflowDescription(workflowMetadata.description, workflowMetadata.name) + ) { + toolDescription = workflowMetadata.description + } + } } else if (toolId.startsWith('knowledge_') && userProvidedParams.knowledgeBaseId) { uniqueToolId = `${toolConfig.id}_${userProvidedParams.knowledgeBaseId}` } return { id: uniqueToolId, - name: toolConfig.name, - description: toolConfig.description, + name: toolName, + description: toolDescription, params: userProvidedParams, parameters: llmSchema, } diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index d6b6372e0e..236461d1a9 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,3 +1,4 @@ -import { fileParserTool } from '@/tools/file/parser' +import { fileParserTool, fileParserV2Tool } from '@/tools/file/parser' export const fileParseTool = fileParserTool +export { fileParserV2Tool } diff --git a/apps/sim/tools/file/parser.ts b/apps/sim/tools/file/parser.ts index eabf6362d2..a20c6dd4cd 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -177,3 +177,25 @@ export const fileParserTool: ToolConfig = { processedFiles: { type: 'file[]', description: 'Array of UserFile objects for downstream use' }, }, } + +export const fileParserV2Tool: ToolConfig = { + id: 'file_parser_v2', + name: 'File Parser', + description: 'Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.)', + version: '2.0.0', + + params: fileParserTool.params, + request: fileParserTool.request, + transformResponse: fileParserTool.transformResponse, + + outputs: { + files: { + type: 'array', + description: 'Array of parsed files with content, metadata, and file properties', + }, + combinedContent: { + type: 'string', + description: 'All file contents merged into a single text string', + }, + }, +} diff --git a/apps/sim/tools/mistral/index.ts b/apps/sim/tools/mistral/index.ts index 53103913bb..566b90f41b 100644 --- a/apps/sim/tools/mistral/index.ts +++ b/apps/sim/tools/mistral/index.ts @@ -1,3 +1,3 @@ -import { mistralParserTool } from '@/tools/mistral/parser' +import { mistralParserTool, mistralParserV2Tool } from '@/tools/mistral/parser' -export { mistralParserTool } +export { mistralParserTool, mistralParserV2Tool } diff --git a/apps/sim/tools/mistral/parser.ts b/apps/sim/tools/mistral/parser.ts index 3f964dd180..ee348dd70c 100644 --- a/apps/sim/tools/mistral/parser.ts +++ b/apps/sim/tools/mistral/parser.ts @@ -1,6 +1,10 @@ import { createLogger } from '@sim/logger' import { getBaseUrl } from '@/lib/core/utils/urls' -import type { MistralParserInput, MistralParserOutput } from '@/tools/mistral/types' +import type { + MistralParserInput, + MistralParserOutput, + MistralParserV2Output, +} from '@/tools/mistral/types' import type { ToolConfig } from '@/tools/types' const logger = createLogger('MistralParserTool') @@ -415,3 +419,138 @@ export const mistralParserTool: ToolConfig = { + id: 'mistral_parser_v2', + name: 'Mistral PDF Parser', + description: 'Parse PDF documents using Mistral OCR API', + version: '2.0.0', + + params: mistralParserTool.params, + request: mistralParserTool.request, + + transformResponse: async (response: Response) => { + let ocrResult + try { + ocrResult = await response.json() + } catch (jsonError) { + throw new Error( + `Failed to parse Mistral OCR response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}` + ) + } + + if (!ocrResult || typeof ocrResult !== 'object') { + throw new Error('Invalid response format from Mistral OCR API') + } + + // Extract the actual Mistral data (may be nested in output from our API route) + const mistralData = + ocrResult.output && typeof ocrResult.output === 'object' && !ocrResult.pages + ? ocrResult.output + : ocrResult + + // Return raw Mistral API structure - no transformation + return { + success: true, + output: { + pages: mistralData.pages ?? [], + model: mistralData.model ?? 'mistral-ocr-latest', + usage_info: mistralData.usage_info ?? { pages_processed: 0, doc_size_bytes: null }, + document_annotation: mistralData.document_annotation ?? null, + }, + } + }, + + outputs: { + pages: { + type: 'array', + description: 'Array of page objects from Mistral OCR', + items: { + type: 'object', + properties: { + index: { type: 'number', description: 'Page index (zero-based)' }, + markdown: { type: 'string', description: 'Extracted markdown content' }, + images: { + type: 'array', + description: 'Images extracted from this page with bounding boxes', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Image identifier (e.g., img-0.jpeg)' }, + top_left_x: { type: 'number', description: 'Top-left X coordinate in pixels' }, + top_left_y: { type: 'number', description: 'Top-left Y coordinate in pixels' }, + bottom_right_x: { + type: 'number', + description: 'Bottom-right X coordinate in pixels', + }, + bottom_right_y: { + type: 'number', + description: 'Bottom-right Y coordinate in pixels', + }, + image_base64: { + type: 'string', + description: 'Base64-encoded image data (when include_image_base64=true)', + optional: true, + }, + }, + }, + }, + dimensions: { + type: 'object', + description: 'Page dimensions', + properties: { + dpi: { type: 'number', description: 'Dots per inch' }, + height: { type: 'number', description: 'Page height in pixels' }, + width: { type: 'number', description: 'Page width in pixels' }, + }, + }, + tables: { + type: 'array', + description: + 'Extracted tables as HTML/markdown (when table_format is set). Referenced via placeholders like [tbl-0.html]', + }, + hyperlinks: { + type: 'array', + description: + 'Array of URL strings detected in the page (e.g., ["https://...", "mailto:..."])', + items: { + type: 'string', + description: 'URL or mailto link', + }, + }, + header: { + type: 'string', + description: 'Page header content (when extract_header=true)', + optional: true, + }, + footer: { + type: 'string', + description: 'Page footer content (when extract_footer=true)', + optional: true, + }, + }, + }, + }, + model: { + type: 'string', + description: 'Mistral OCR model identifier (e.g., mistral-ocr-latest)', + }, + usage_info: { + type: 'object', + description: 'Usage and processing statistics', + properties: { + pages_processed: { type: 'number', description: 'Total number of pages processed' }, + doc_size_bytes: { + type: 'number', + description: 'Document file size in bytes', + optional: true, + }, + }, + }, + document_annotation: { + type: 'string', + description: 'Structured annotation data as JSON string (when applicable)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/mistral/types.ts b/apps/sim/tools/mistral/types.ts index 8fe0f20bfa..9e138b8ca4 100644 --- a/apps/sim/tools/mistral/types.ts +++ b/apps/sim/tools/mistral/types.ts @@ -1,98 +1,80 @@ import type { ToolResponse } from '@/tools/types' -/** - * Input parameters for the Mistral OCR parser tool - */ export interface MistralParserInput { - /** URL to a PDF document to be processed */ filePath: string - - /** File upload data (from file-upload component) */ fileUpload?: any - - /** Internal file path flag (for presigned URL conversion) */ _internalFilePath?: string - - /** Mistral API key for authentication */ apiKey: string - - /** Output format for the extracted content (default: 'markdown') */ resultType?: 'markdown' | 'text' | 'json' - - /** Whether to include base64-encoded images in the response */ includeImageBase64?: boolean - - /** Specific pages to process (zero-indexed) */ pages?: number[] - - /** Maximum number of images to extract from the PDF */ imageLimit?: number - - /** Minimum height and width (in pixels) for images to extract */ imageMinSize?: number } -/** - * Usage information returned by the Mistral OCR API - */ export interface MistralOcrUsageInfo { - /** Number of pages processed in the document */ pagesProcessed: number - - /** Size of the document in bytes */ docSizeBytes: number | null } -/** - * Metadata about the processed document - */ export interface MistralParserMetadata { - /** Unique identifier for this OCR job */ jobId: string - - /** File type of the document (typically 'pdf') */ fileType: string - - /** Filename extracted from the document URL */ fileName: string - - /** Source type (always 'url' for now) */ source: 'url' - - /** Original URL to the document (only included for user-provided URLs) */ sourceUrl?: string - - /** Total number of pages in the document */ pageCount: number - - /** Usage statistics from the OCR processing */ usageInfo?: MistralOcrUsageInfo - - /** The Mistral OCR model used for processing */ model: string - - /** The output format that was requested */ resultType?: 'markdown' | 'text' | 'json' - - /** ISO timestamp when the document was processed */ processedAt: string } -/** - * Output data structure from the Mistral OCR parser - */ export interface MistralParserOutputData { - /** Extracted content in the requested format */ content: string - - /** Metadata about the parsed document and processing */ metadata: MistralParserMetadata } -/** - * Complete response from the Mistral OCR parser tool - */ export interface MistralParserOutput extends ToolResponse { - /** The output data containing content and metadata */ output: MistralParserOutputData } + +export interface MistralOcrImage { + id: string + top_left_x: number + top_left_y: number + bottom_right_x: number + bottom_right_y: number + image_base64?: string +} + +export interface MistralOcrDimensions { + dpi: number + height: number + width: number +} + +export interface MistralOcrPage { + index: number + markdown: string + images: MistralOcrImage[] + dimensions: MistralOcrDimensions + tables: unknown[] + hyperlinks: unknown[] + header: string | null + footer: string | null +} + +export interface MistralOcrUsageInfoRaw { + pages_processed: number + doc_size_bytes: number | null +} + +export interface MistralParserV2Output extends ToolResponse { + output: { + pages: MistralOcrPage[] + model: string + usage_info: MistralOcrUsageInfoRaw + document_annotation: string | null + } +} diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index ee6f3076ad..e957f12ce2 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -518,7 +518,7 @@ async function applyDynamicSchemaForWorkflow( for (const field of workflowInputFields) { propertySchema.properties[field.name] = { type: field.type || 'string', - description: `Input field: ${field.name}`, + description: field.description || `Input field: ${field.name}`, } propertySchema.required.push(field.name) } @@ -533,11 +533,10 @@ async function applyDynamicSchemaForWorkflow( /** * Fetches workflow input fields from the API. - * No local caching - relies on React Query caching on the client side. */ async function fetchWorkflowInputFields( workflowId: string -): Promise> { +): Promise> { try { const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2a8088477e..c8a51881e7 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -204,7 +204,7 @@ import { exaResearchTool, exaSearchTool, } from '@/tools/exa' -import { fileParseTool } from '@/tools/file' +import { fileParserV2Tool, fileParseTool } from '@/tools/file' import { firecrawlAgentTool, firecrawlCrawlTool, @@ -979,7 +979,7 @@ import { microsoftTeamsWriteChannelTool, microsoftTeamsWriteChatTool, } from '@/tools/microsoft_teams' -import { mistralParserTool } from '@/tools/mistral' +import { mistralParserTool, mistralParserV2Tool } from '@/tools/mistral' import { mongodbDeleteTool, mongodbExecuteTool, @@ -1500,6 +1500,7 @@ import { telegramSendPhotoTool, telegramSendVideoTool, } from '@/tools/telegram' +import { textractParserTool } from '@/tools/textract' import { thinkingTool } from '@/tools/thinking' import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird' import { @@ -1682,6 +1683,7 @@ export const tools: Record = { function_execute: functionExecuteTool, vision_tool: visionTool, file_parser: fileParseTool, + file_parser_v2: fileParserV2Tool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, firecrawl_crawl: firecrawlCrawlTool, @@ -2455,7 +2457,9 @@ export const tools: Record = { apollo_task_search: apolloTaskSearchTool, apollo_email_accounts: apolloEmailAccountsTool, mistral_parser: mistralParserTool, + mistral_parser_v2: mistralParserV2Tool, reducto_parser: reductoParserTool, + textract_parser: textractParserTool, thinking_tool: thinkingTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool, diff --git a/apps/sim/tools/s3/copy_object.ts b/apps/sim/tools/s3/copy_object.ts index da583ca307..339106612e 100644 --- a/apps/sim/tools/s3/copy_object.ts +++ b/apps/sim/tools/s3/copy_object.ts @@ -95,6 +95,7 @@ export const s3CopyObjectTool: ToolConfig = { success: true, output: { url: data.output.url, + uri: data.output.uri, metadata: { copySourceVersionId: data.output.copySourceVersionId, versionId: data.output.versionId, @@ -109,6 +110,10 @@ export const s3CopyObjectTool: ToolConfig = { type: 'string', description: 'URL of the copied S3 object', }, + uri: { + type: 'string', + description: 'S3 URI of the copied object (s3://bucket/key)', + }, metadata: { type: 'object', description: 'Copy operation metadata', diff --git a/apps/sim/tools/s3/put_object.ts b/apps/sim/tools/s3/put_object.ts index 6a1f596b6d..92ffdf07d7 100644 --- a/apps/sim/tools/s3/put_object.ts +++ b/apps/sim/tools/s3/put_object.ts @@ -102,6 +102,7 @@ export const s3PutObjectTool: ToolConfig = { success: true, output: { url: data.output.url, + uri: data.output.uri, metadata: { etag: data.output.etag, location: data.output.location, @@ -117,6 +118,10 @@ export const s3PutObjectTool: ToolConfig = { type: 'string', description: 'URL of the uploaded S3 object', }, + uri: { + type: 'string', + description: 'S3 URI of the uploaded object (s3://bucket/key)', + }, metadata: { type: 'object', description: 'Upload metadata including ETag and location', diff --git a/apps/sim/tools/textract/index.ts b/apps/sim/tools/textract/index.ts new file mode 100644 index 0000000000..5f618a8b4c --- /dev/null +++ b/apps/sim/tools/textract/index.ts @@ -0,0 +1,2 @@ +export { textractParserTool } from '@/tools/textract/parser' +export * from '@/tools/textract/types' diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts new file mode 100644 index 0000000000..a7b95564c5 --- /dev/null +++ b/apps/sim/tools/textract/parser.ts @@ -0,0 +1,288 @@ +import { createLogger } from '@sim/logger' +import type { TextractParserInput, TextractParserOutput } from '@/tools/textract/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('TextractParserTool') + +export const textractParserTool: ToolConfig = { + id: 'textract_parser', + name: 'AWS Textract Parser', + description: 'Parse documents using AWS Textract OCR and document analysis', + version: '1.0.0', + + params: { + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS Access Key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS Secret Access Key', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region for Textract service (e.g., us-east-1)', + }, + processingMode: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Document type: single-page or multi-page. Defaults to single-page.', + }, + filePath: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'URL to a document to be processed (JPEG, PNG, or single-page PDF).', + }, + s3Uri: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'S3 URI for multi-page processing (s3://bucket/key).', + }, + fileUpload: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'File upload data from file-upload component', + }, + featureTypes: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed.', + items: { + type: 'string', + description: 'Feature type', + }, + }, + queries: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Custom queries to extract specific information. Only used when featureTypes includes QUERIES.', + items: { + type: 'object', + description: 'Query configuration', + properties: { + Text: { type: 'string', description: 'The query text' }, + Alias: { type: 'string', description: 'Optional alias for the result' }, + }, + }, + }, + }, + + request: { + url: '/api/tools/textract/parse', + method: 'POST', + headers: () => { + return { + 'Content-Type': 'application/json', + Accept: 'application/json', + } + }, + body: (params) => { + const processingMode = params.processingMode || 'sync' + + const requestBody: Record = { + accessKeyId: params.accessKeyId?.trim(), + secretAccessKey: params.secretAccessKey?.trim(), + region: params.region?.trim(), + processingMode, + } + + if (processingMode === 'async') { + requestBody.s3Uri = params.s3Uri?.trim() + } else { + // Handle file upload by extracting the path + if (params.fileUpload && !params.filePath) { + const uploadPath = params.fileUpload.path || params.fileUpload.url + if (uploadPath) { + requestBody.filePath = uploadPath + } + } else { + requestBody.filePath = params.filePath?.trim() + } + } + + if (params.featureTypes && Array.isArray(params.featureTypes)) { + requestBody.featureTypes = params.featureTypes + } + + if (params.queries && Array.isArray(params.queries)) { + requestBody.queries = params.queries + } + + return requestBody + }, + }, + + transformResponse: async (response) => { + try { + let apiResult + try { + apiResult = await response.json() + } catch (jsonError) { + throw new Error( + `Failed to parse Textract response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}` + ) + } + + if (!apiResult || typeof apiResult !== 'object') { + throw new Error('Invalid response format from Textract API') + } + + if (!apiResult.success) { + throw new Error(apiResult.error || 'Request failed') + } + + const textractData = apiResult.output ?? apiResult + + return { + success: true, + output: { + blocks: textractData.Blocks ?? textractData.blocks ?? [], + documentMetadata: { + pages: + textractData.DocumentMetadata?.Pages ?? textractData.documentMetadata?.pages ?? 0, + }, + modelVersion: + textractData.modelVersion ?? + textractData.AnalyzeDocumentModelVersion ?? + textractData.analyzeDocumentModelVersion ?? + textractData.DetectDocumentTextModelVersion ?? + textractData.detectDocumentTextModelVersion ?? + undefined, + }, + } + } catch (error) { + logger.error('Error processing Textract result:', error) + throw error + } + }, + + outputs: { + blocks: { + type: 'array', + description: + 'Array of Block objects containing detected text, tables, forms, and other elements', + items: { + type: 'object', + properties: { + BlockType: { + type: 'string', + description: 'Type of block (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)', + }, + Id: { type: 'string', description: 'Unique identifier for the block' }, + Text: { + type: 'string', + description: 'The text content (for LINE and WORD blocks)', + optional: true, + }, + TextType: { + type: 'string', + description: 'Type of text (PRINTED or HANDWRITING)', + optional: true, + }, + Confidence: { type: 'number', description: 'Confidence score (0-100)', optional: true }, + Page: { type: 'number', description: 'Page number', optional: true }, + Geometry: { + type: 'object', + description: 'Location and bounding box information', + optional: true, + properties: { + BoundingBox: { + type: 'object', + properties: { + Height: { type: 'number', description: 'Height as ratio of document height' }, + Left: { type: 'number', description: 'Left position as ratio of document width' }, + Top: { type: 'number', description: 'Top position as ratio of document height' }, + Width: { type: 'number', description: 'Width as ratio of document width' }, + }, + }, + Polygon: { + type: 'array', + description: 'Polygon coordinates', + items: { + type: 'object', + properties: { + X: { type: 'number', description: 'X coordinate' }, + Y: { type: 'number', description: 'Y coordinate' }, + }, + }, + }, + }, + }, + Relationships: { + type: 'array', + description: 'Relationships to other blocks', + optional: true, + items: { + type: 'object', + properties: { + Type: { + type: 'string', + description: 'Relationship type (CHILD, VALUE, ANSWER, etc.)', + }, + Ids: { type: 'array', description: 'IDs of related blocks' }, + }, + }, + }, + EntityTypes: { + type: 'array', + description: 'Entity types for KEY_VALUE_SET (KEY or VALUE)', + optional: true, + }, + SelectionStatus: { + type: 'string', + description: 'For checkboxes: SELECTED or NOT_SELECTED', + optional: true, + }, + RowIndex: { type: 'number', description: 'Row index for table cells', optional: true }, + ColumnIndex: { + type: 'number', + description: 'Column index for table cells', + optional: true, + }, + RowSpan: { type: 'number', description: 'Row span for merged cells', optional: true }, + ColumnSpan: { + type: 'number', + description: 'Column span for merged cells', + optional: true, + }, + Query: { + type: 'object', + description: 'Query information for QUERY blocks', + optional: true, + properties: { + Text: { type: 'string', description: 'Query text' }, + Alias: { type: 'string', description: 'Query alias', optional: true }, + Pages: { type: 'array', description: 'Pages to search', optional: true }, + }, + }, + }, + }, + }, + documentMetadata: { + type: 'object', + description: 'Metadata about the analyzed document', + properties: { + pages: { type: 'number', description: 'Number of pages in the document' }, + }, + }, + modelVersion: { + type: 'string', + description: 'Version of the Textract model used for processing', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/textract/types.ts b/apps/sim/tools/textract/types.ts new file mode 100644 index 0000000000..7adc46f28b --- /dev/null +++ b/apps/sim/tools/textract/types.ts @@ -0,0 +1,110 @@ +import type { ToolResponse } from '@/tools/types' + +export type TextractProcessingMode = 'sync' | 'async' + +export interface TextractParserInput { + accessKeyId: string + secretAccessKey: string + region: string + processingMode?: TextractProcessingMode + filePath?: string + s3Uri?: string + fileUpload?: { + url?: string + path?: string + } + featureTypes?: TextractFeatureType[] + queries?: TextractQuery[] +} + +export type TextractFeatureType = 'TABLES' | 'FORMS' | 'QUERIES' | 'SIGNATURES' | 'LAYOUT' + +export interface TextractQuery { + Text: string + Alias?: string + Pages?: string[] +} + +export interface TextractBoundingBox { + Height: number + Left: number + Top: number + Width: number +} + +export interface TextractPolygonPoint { + X: number + Y: number +} + +export interface TextractGeometry { + BoundingBox: TextractBoundingBox + Polygon: TextractPolygonPoint[] + RotationAngle?: number +} + +export interface TextractRelationship { + Type: string + Ids: string[] +} + +export interface TextractBlock { + BlockType: string + Id: string + Text?: string + TextType?: string + Confidence?: number + Geometry?: TextractGeometry + Relationships?: TextractRelationship[] + Page?: number + EntityTypes?: string[] + SelectionStatus?: string + RowIndex?: number + ColumnIndex?: number + RowSpan?: number + ColumnSpan?: number + Query?: { + Text: string + Alias?: string + Pages?: string[] + } +} + +export interface TextractDocumentMetadataRaw { + Pages: number +} + +export interface TextractDocumentMetadata { + pages: number +} + +export interface TextractApiResponse { + Blocks: TextractBlock[] + DocumentMetadata: TextractDocumentMetadataRaw + AnalyzeDocumentModelVersion?: string + DetectDocumentTextModelVersion?: string +} + +export interface TextractNormalizedOutput { + blocks: TextractBlock[] + documentMetadata: TextractDocumentMetadata + modelVersion?: string +} + +export interface TextractAsyncJobResponse { + JobStatus: 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'PARTIAL_SUCCESS' + StatusMessage?: string + Blocks?: TextractBlock[] + DocumentMetadata?: TextractDocumentMetadataRaw + NextToken?: string + AnalyzeDocumentModelVersion?: string + DetectDocumentTextModelVersion?: string +} + +export interface TextractStartJobResponse { + JobId: string +} + +export interface TextractParserOutput extends ToolResponse { + output: TextractNormalizedOutput +}