From ecf39c5a54c2bf1dc4ade313a75d39d80dedf432 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 11:06:38 -0800 Subject: [PATCH 01/15] feat(tools): added textract --- apps/docs/components/icons.tsx | 17 + apps/docs/components/ui/icon-mapping.ts | 2 + apps/docs/content/docs/en/tools/meta.json | 1 + apps/docs/content/docs/en/tools/textract.mdx | 120 ++++ .../sim/app/api/tools/textract/parse/route.ts | 617 ++++++++++++++++++ apps/sim/blocks/blocks/textract.ts | 251 +++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 17 + .../sim/lib/core/security/input-validation.ts | 137 +++- apps/sim/lib/uploads/core/storage-service.ts | 24 + apps/sim/tools/registry.ts | 2 + apps/sim/tools/textract/index.ts | 2 + apps/sim/tools/textract/parser.ts | 413 ++++++++++++ apps/sim/tools/textract/types.ts | 114 ++++ 14 files changed, 1706 insertions(+), 13 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/textract.mdx create mode 100644 apps/sim/app/api/tools/textract/parse/route.ts create mode 100644 apps/sim/blocks/blocks/textract.ts create mode 100644 apps/sim/tools/textract/index.ts create mode 100644 apps/sim/tools/textract/parser.ts create mode 100644 apps/sim/tools/textract/types.ts 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 ( = { supabase: SupabaseIcon, tavily: TavilyIcon, telegram: TelegramIcon, + textract: TextractIcon, tinybird: TinybirdIcon, translate: TranslateIcon, trello: TrelloIcon, 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/textract.mdx b/apps/docs/content/docs/en/tools/textract.mdx new file mode 100644 index 0000000000..7c106f37d2 --- /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. Sync mode supports JPEG, PNG, and single-page PDF. Async mode supports multi-page PDF and TIFF via S3. + + + +## 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, PDF, or TIFF\). Required for sync mode. | +| `s3Uri` | string | No | S3 URI for async processing \(s3://bucket/key\). Required for async mode with S3 input. | +| `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/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts new file mode 100644 index 0000000000..ad1cddd0e2 --- /dev/null +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -0,0 +1,617 @@ +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 { getBaseUrl } from '@/lib/core/utils/urls' +import { StorageService } from '@/lib/uploads' +import { extractStorageKey, inferContextFromKey } 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'], + }) + } + }) + +/** + * Generate AWS Signature Version 4 signing key + */ +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}`) + return result + } + + 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, + s3Uri: validatedData.s3Uri, + featureTypes, + userId, + }) + + if (processingMode === 'async') { + if (!validatedData.s3Uri && !validatedData.filePath) { + return NextResponse.json( + { + success: false, + error: 'S3 URI or file path is required for async processing', + }, + { status: 400 } + ) + } + + let s3Bucket: string + let s3Key: string + + if (validatedData.s3Uri) { + const parsed = parseS3Uri(validatedData.s3Uri) + s3Bucket = parsed.bucket + s3Key = parsed.key + } else if (validatedData.filePath?.includes('/api/files/serve/')) { + const storageKey = extractStorageKey(validatedData.filePath) + const context = inferContextFromKey(storageKey) + + const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + if (!hasAccess) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } + + const s3Info = StorageService.getS3InfoForKey(storageKey, context) + s3Bucket = s3Info.bucket + s3Key = s3Info.key + } else { + return NextResponse.json( + { + success: false, + error: 'Async mode requires an S3 URI (s3://bucket/key) or an uploaded file', + }, + { status: 400 } + ) + } + + 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 sync processing', + }, + { status: 400 } + ) + } + + let fileUrl = validatedData.filePath + + if (validatedData.filePath?.includes('/api/files/serve/')) { + 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('/')) { + const baseUrl = getBaseUrl() + fileUrl = `${baseUrl}${validatedData.filePath}` + } 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 } = await fetchDocumentBytes(fileUrl) + + 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}` + 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 + } + + 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/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts new file mode 100644 index 0000000000..6f3da8b754 --- /dev/null +++ b/apps/sim/blocks/blocks/textract.ts @@ -0,0 +1,251 @@ +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. Sync mode supports JPEG, PNG, and single-page PDF. Async mode supports multi-page PDF and TIFF via S3.`, + docsLink: 'https://docs.sim.ai/tools/textract', + category: 'tools', + bgColor: 'linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)', + icon: TextractIcon, + subBlocks: [ + { + id: 'processingMode', + title: 'Document Type', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'sync', label: 'Single Page' }, + { id: 'async', label: 'Multi-Page' }, + ], + }, + { + id: 'inputMethod', + title: 'Select Input Method', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'url', label: 'Document URL' }, + { id: 'upload', label: 'Upload Document' }, + ], + condition: { + field: 'processingMode', + value: 'async', + not: true, + }, + }, + { + id: 'asyncInputMethod', + title: 'Select Input Method', + type: 'dropdown' as SubBlockType, + options: [ + { id: 's3', label: 'S3 URI' }, + { id: 'upload', label: 'Upload Document' }, + ], + condition: { + field: 'processingMode', + value: 'async', + }, + }, + { + id: 'filePath', + title: 'Document URL', + type: 'short-input' as SubBlockType, + placeholder: 'Enter full URL to a document (JPEG, PNG, or single-page PDF)', + condition: { + field: 'inputMethod', + value: 'url', + and: { + field: 'processingMode', + value: 'async', + not: true, + }, + }, + }, + { + id: 's3Uri', + title: 'S3 URI', + type: 'short-input' as SubBlockType, + placeholder: 's3://bucket-name/path/to/document.pdf', + condition: { + field: 'asyncInputMethod', + value: 's3', + and: { + field: 'processingMode', + value: 'async', + }, + }, + }, + { + id: 'fileUpload', + title: 'Upload Document', + type: 'file-upload' as SubBlockType, + acceptedTypes: 'application/pdf,image/jpeg,image/png,image/tiff', + condition: { + field: 'inputMethod', + value: 'upload', + and: { + field: 'processingMode', + value: 'async', + not: true, + }, + }, + maxSize: 10, + }, + { + id: 'asyncFileUpload', + title: 'Upload Document', + type: 'file-upload' as SubBlockType, + acceptedTypes: 'application/pdf,image/jpeg,image/png,image/tiff', + condition: { + field: 'asyncInputMethod', + value: 'upload', + and: { + field: 'processingMode', + value: 'async', + }, + }, + maxSize: 50, + }, + { + 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') { + const asyncInputMethod = params.asyncInputMethod || 's3' + if (asyncInputMethod === 's3') { + if (!params.s3Uri || params.s3Uri.trim() === '') { + throw new Error('S3 URI is required for async processing') + } + parameters.s3Uri = params.s3Uri.trim() + } else if (asyncInputMethod === 'upload') { + if (!params.asyncFileUpload) { + throw new Error('Please upload a document') + } + parameters.fileUpload = params.asyncFileUpload + } + } else { + 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 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' }, + inputMethod: { type: 'string', description: 'Input method selection for sync mode' }, + asyncInputMethod: { type: 'string', description: 'Input method selection for async mode' }, + filePath: { type: 'string', description: 'Document URL' }, + s3Uri: { type: 'string', description: 'S3 URI for async processing (s3://bucket/key)' }, + fileUpload: { type: 'json', description: 'Uploaded document file for sync mode' }, + asyncFileUpload: { type: 'json', description: 'Uploaded document file for async mode' }, + 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/registry.ts b/apps/sim/blocks/registry.ts index 544c294322..201ea3b4bb 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -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' @@ -285,6 +286,7 @@ export const registry: Record = { stt: SttBlock, supabase: SupabaseBlock, tavily: TavilyBlock, + textract: TextractBlock, telegram: TelegramBlock, thinking: ThinkingBlock, tinybird: TinybirdBlock, 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 ( maxLength) { logger.warn('Path segment exceeds maximum length', { paramName, @@ -86,7 +78,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 { @@ -95,7 +86,6 @@ export function validatePathSegment( } } - // Check for path traversal patterns const pathTraversalPatterns = [ '..', './', @@ -124,7 +114,6 @@ export function validatePathSegment( } } - // Check for directory separators if (value.includes('/') || value.includes('\\')) { logger.warn('Path segment contains directory separators', { paramName }) return { @@ -133,7 +122,6 @@ export function validatePathSegment( } } - // Use custom pattern if provided if (customPattern) { if (!customPattern.test(value)) { logger.warn('Path segment failed custom pattern validation', { @@ -148,7 +136,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 += '_' @@ -947,6 +934,130 @@ export function validateAirtableId( return { isValid: true, sanitized: value } } +/** + * Validates an AWS region identifier + * + * AWS regions follow the pattern: {area}-{sub-area}-{number} + * Examples: us-east-1, eu-west-2, ap-southeast-1, sa-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 format: {area}-{sub-area}-{number} + // Examples: us-east-1, eu-west-2, ap-southeast-1, me-south-1, af-south-1 + const awsRegionPattern = /^[a-z]{2}-[a-z]+-\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)`, + } + } + + 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/tools/registry.ts b/apps/sim/tools/registry.ts index 2a8088477e..d15f72b5fb 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -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 { @@ -2456,6 +2457,7 @@ export const tools: Record = { apollo_email_accounts: apolloEmailAccountsTool, mistral_parser: mistralParserTool, reducto_parser: reductoParserTool, + textract_parser: textractParserTool, thinking_tool: thinkingTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool, 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..58aa54c785 --- /dev/null +++ b/apps/sim/tools/textract/parser.ts @@ -0,0 +1,413 @@ +import { createLogger } from '@sim/logger' +import { getBaseUrl } from '@/lib/core/utils/urls' +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, PDF, or TIFF). Required for sync mode.', + }, + s3Uri: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'S3 URI for async processing (s3://bucket/key). Required for async mode with S3 input.', + }, + 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) => { + if (!params || typeof params !== 'object') { + throw new Error('Invalid parameters: Parameters must be provided as an object') + } + + if ( + !params.accessKeyId || + typeof params.accessKeyId !== 'string' || + params.accessKeyId.trim() === '' + ) { + throw new Error('Missing or invalid AWS Access Key ID') + } + + if ( + !params.secretAccessKey || + typeof params.secretAccessKey !== 'string' || + params.secretAccessKey.trim() === '' + ) { + throw new Error('Missing or invalid AWS Secret Access Key') + } + + if (!params.region || typeof params.region !== 'string' || params.region.trim() === '') { + throw new Error('Missing or invalid AWS region') + } + + const processingMode = params.processingMode || 'sync' + + const requestBody: Record = { + accessKeyId: params.accessKeyId.trim(), + secretAccessKey: params.secretAccessKey.trim(), + region: params.region.trim(), + processingMode, + } + + if (processingMode === 'async') { + if (params.s3Uri && typeof params.s3Uri === 'string' && params.s3Uri.trim() !== '') { + const s3UriTrimmed = params.s3Uri.trim() + if (!s3UriTrimmed.match(/^s3:\/\/[^/]+\/.+$/)) { + throw new Error('Invalid S3 URI format. Expected: s3://bucket-name/path/to/object') + } + requestBody.s3Uri = s3UriTrimmed + } else if (params.fileUpload) { + if ( + typeof params.fileUpload === 'object' && + params.fileUpload !== null && + (params.fileUpload.url || params.fileUpload.path) + ) { + const uploadedFilePath = (params.fileUpload.path || params.fileUpload.url) as string + if (uploadedFilePath.startsWith('/api/files/serve/')) { + requestBody.filePath = uploadedFilePath + } else { + throw new Error('Async mode with upload requires files stored in S3') + } + } else { + throw new Error('Invalid file upload: Upload data is missing or invalid') + } + } else { + throw new Error('Async mode requires either an S3 URI or an uploaded file') + } + } else { + if ( + params.fileUpload && + (!params.filePath || params.filePath === 'null' || params.filePath === '') + ) { + if ( + typeof params.fileUpload === 'object' && + params.fileUpload !== null && + (params.fileUpload.url || params.fileUpload.path) + ) { + let uploadedFilePath = (params.fileUpload.url || params.fileUpload.path) as string + + if (uploadedFilePath.startsWith('/')) { + const baseUrl = getBaseUrl() + if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') + uploadedFilePath = `${baseUrl}${uploadedFilePath}` + } + + params.filePath = uploadedFilePath + logger.info('Using uploaded file:', uploadedFilePath) + } else { + throw new Error('Invalid file upload: Upload data is missing or invalid') + } + } + + if ( + !params.filePath || + typeof params.filePath !== 'string' || + params.filePath.trim() === '' + ) { + throw new Error('Missing or invalid file path: Please provide a URL to a document') + } + + let filePathToValidate = params.filePath.trim() + if (filePathToValidate.startsWith('/')) { + const baseUrl = getBaseUrl() + if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') + filePathToValidate = `${baseUrl}${filePathToValidate}` + } + + let url + try { + url = new URL(filePathToValidate) + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error( + `Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol` + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a document.` + ) + } + + requestBody.filePath = url.toString() + + if (params.fileUpload?.path?.startsWith('/api/files/serve/')) { + requestBody.filePath = params.fileUpload.path + } + } + + if (params.featureTypes && Array.isArray(params.featureTypes)) { + const validFeatures = ['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT'] + const filteredFeatures = params.featureTypes.filter((f) => + validFeatures.includes(f as string) + ) + if (filteredFeatures.length > 0) { + requestBody.featureTypes = filteredFeatures + } + } + + if (params.queries && Array.isArray(params.queries) && params.queries.length > 0) { + const validQueries = params.queries + .filter((q) => q && typeof q === 'object' && typeof q.Text === 'string' && q.Text.trim()) + .map((q) => ({ + Text: q.Text.trim(), + Alias: q.Alias?.trim() || undefined, + Pages: q.Pages || undefined, + })) + + if (validQueries.length > 0) { + requestBody.queries = validQueries + + if (!requestBody.featureTypes) { + requestBody.featureTypes = ['QUERIES'] + } else if ( + Array.isArray(requestBody.featureTypes) && + !requestBody.featureTypes.includes('QUERIES') + ) { + ;(requestBody.featureTypes as string[]).push('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 && apiResult.error) { + throw new Error(apiResult.error) + } + + 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.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..4fb66eea2b --- /dev/null +++ b/apps/sim/tools/textract/types.ts @@ -0,0 +1,114 @@ +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[] + } +} + +/** AWS Textract DocumentMetadata - exact API format */ +export interface TextractDocumentMetadataRaw { + Pages: number +} + +/** Normalized DocumentMetadata (camelCase) */ +export interface TextractDocumentMetadata { + pages: number +} + +/** AWS Textract API Response - exact API format */ +export interface TextractApiResponse { + Blocks: TextractBlock[] + DocumentMetadata: TextractDocumentMetadataRaw + AnalyzeDocumentModelVersion?: string + DetectDocumentTextModelVersion?: string +} + +export interface TextractNormalizedOutput { + blocks: TextractBlock[] + documentMetadata: TextractDocumentMetadata + modelVersion?: string +} + +/** Async job status response from Textract */ +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 +} From dbee20e9e5b7fda15acd54cf55b4ed94c51912b5 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 11:13:29 -0800 Subject: [PATCH 02/15] cleanup --- apps/docs/content/docs/en/tools/textract.mdx | 6 +- .../sim/app/api/tools/textract/parse/route.ts | 9 +- apps/sim/blocks/blocks/textract.ts | 14 +- .../core/security/input-validation.test.ts | 215 ++++++++++++++++++ apps/sim/tools/textract/parser.ts | 10 +- apps/sim/tools/textract/types.ts | 4 - 6 files changed, 232 insertions(+), 26 deletions(-) diff --git a/apps/docs/content/docs/en/tools/textract.mdx b/apps/docs/content/docs/en/tools/textract.mdx index 7c106f37d2..a09c71cf0b 100644 --- a/apps/docs/content/docs/en/tools/textract.mdx +++ b/apps/docs/content/docs/en/tools/textract.mdx @@ -27,7 +27,7 @@ In Sim, the AWS Textract integration empowers your agents to intelligently proce ## Usage Instructions -Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Sync mode supports JPEG, PNG, and single-page PDF. Async mode supports multi-page PDF and TIFF via S3. +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. @@ -45,8 +45,8 @@ Parse documents using AWS Textract OCR and document analysis | `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, PDF, or TIFF\). Required for sync mode. | -| `s3Uri` | string | No | S3 URI for async processing \(s3://bucket/key\). Required for async mode with S3 input. | +| `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 | diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index ad1cddd0e2..ba54da04b2 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -49,9 +49,6 @@ const TextractParseSchema = z } }) -/** - * Generate AWS Signature Version 4 signing key - */ function getSignatureKey( key: string, dateStamp: string, @@ -309,7 +306,7 @@ export async function POST(request: NextRequest) { return NextResponse.json( { success: false, - error: 'S3 URI or file path is required for async processing', + error: 'S3 URI or file path is required for multi-page processing', }, { status: 400 } ) @@ -338,7 +335,7 @@ export async function POST(request: NextRequest) { return NextResponse.json( { success: false, - error: 'Async mode requires an S3 URI (s3://bucket/key) or an uploaded file', + error: 'Multi-page mode requires an S3 URI (s3://bucket/key) or an uploaded file', }, { status: 400 } ) @@ -425,7 +422,7 @@ export async function POST(request: NextRequest) { return NextResponse.json( { success: false, - error: 'File path is required for sync processing', + error: 'File path is required for single-page processing', }, { status: 400 } ) diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 6f3da8b754..12104ae83b 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -7,7 +7,7 @@ export const TextractBlock: BlockConfig = { 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. Sync mode supports JPEG, PNG, and single-page PDF. Async mode supports multi-page PDF and TIFF via S3.`, + 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%)', @@ -180,7 +180,7 @@ export const TextractBlock: BlockConfig = { const asyncInputMethod = params.asyncInputMethod || 's3' if (asyncInputMethod === 's3') { if (!params.s3Uri || params.s3Uri.trim() === '') { - throw new Error('S3 URI is required for async processing') + throw new Error('S3 URI is required for multi-page processing') } parameters.s3Uri = params.s3Uri.trim() } else if (asyncInputMethod === 'upload') { @@ -220,12 +220,12 @@ export const TextractBlock: BlockConfig = { }, inputs: { processingMode: { type: 'string', description: 'Document type: single-page or multi-page' }, - inputMethod: { type: 'string', description: 'Input method selection for sync mode' }, - asyncInputMethod: { type: 'string', description: 'Input method selection for async mode' }, + inputMethod: { type: 'string', description: 'Input method selection for single-page mode' }, + asyncInputMethod: { type: 'string', description: 'Input method selection for multi-page mode' }, filePath: { type: 'string', description: 'Document URL' }, - s3Uri: { type: 'string', description: 'S3 URI for async processing (s3://bucket/key)' }, - fileUpload: { type: 'json', description: 'Uploaded document file for sync mode' }, - asyncFileUpload: { type: 'json', description: 'Uploaded document file for async mode' }, + s3Uri: { type: 'string', description: 'S3 URI for multi-page processing (s3://bucket/key)' }, + fileUpload: { type: 'json', description: 'Uploaded document file for single-page mode' }, + asyncFileUpload: { type: 'json', description: 'Uploaded document file for multi-page mode' }, extractTables: { type: 'boolean', description: 'Extract tables from document' }, extractForms: { type: 'boolean', description: 'Extract form key-value pairs' }, detectSignatures: { type: 'boolean', description: 'Detect signatures' }, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 7f455cb97e..f15259a932 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -4,6 +4,7 @@ import { createPinnedUrl, validateAirtableId, validateAlphanumericId, + validateAwsRegion, validateEnum, validateExternalUrl, validateFileExtension, @@ -17,6 +18,7 @@ import { validateNumericId, validatePathSegment, validateProxyUrl, + validateS3BucketName, validateUrlWithDNS, } from '@/lib/core/security/input-validation' import { sanitizeForLogging } from '@/lib/core/security/redaction' @@ -1192,3 +1194,216 @@ describe('validateAirtableId', () => { }) }) }) + +describe('validateAwsRegion', () => { + describe('valid 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 ap-southeast-1', () => { + const result = validateAwsRegion('ap-southeast-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 regions with double-digit numbers', () => { + const result = validateAwsRegion('ap-northeast-12') + 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 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/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index 58aa54c785..102210653f 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -40,15 +40,13 @@ export const textractParserTool: ToolConfig Date: Tue, 20 Jan 2026 11:24:03 -0800 Subject: [PATCH 03/15] ack pr comments --- .../sim/app/api/tools/textract/parse/route.ts | 39 +++++++++++++++++-- apps/sim/tools/textract/parser.ts | 1 + 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index ba54da04b2..2aae06fd1c 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -253,7 +253,27 @@ async function pollForJobCompletion( if (jobStatus === 'PARTIAL_SUCCESS') { logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) - return result + + 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}`) @@ -295,8 +315,8 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Textract parse request`, { processingMode, - filePath: validatedData.filePath, - s3Uri: validatedData.s3Uri, + filePath: validatedData.filePath?.substring(0, 50), + s3Uri: validatedData.s3Uri?.substring(0, 50), featureTypes, userId, }) @@ -465,6 +485,19 @@ export async function POST(request: NextRequest) { ) } } else if (validatedData.filePath?.startsWith('/')) { + if (!validatedData.filePath.startsWith('/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 } + ) + } const baseUrl = getBaseUrl() fileUrl = `${baseUrl}${validatedData.filePath}` } else { diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index 102210653f..f367e9ba4a 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -280,6 +280,7 @@ export const textractParserTool: ToolConfig Date: Tue, 20 Jan 2026 11:31:57 -0800 Subject: [PATCH 04/15] reorder --- apps/sim/blocks/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 201ea3b4bb..1a37b2a14d 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -286,8 +286,8 @@ export const registry: Record = { stt: SttBlock, supabase: SupabaseBlock, tavily: TavilyBlock, - textract: TextractBlock, telegram: TelegramBlock, + textract: TextractBlock, thinking: ThinkingBlock, tinybird: TinybirdBlock, translate: TranslateBlock, From c5d3405c7aa08417a6a39ece7779ab258a170f30 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 11:44:24 -0800 Subject: [PATCH 05/15] removed upload for textract async version --- apps/docs/content/docs/en/tools/s3.mdx | 2 + .../sim/app/api/tools/s3/copy-object/route.ts | 2 + apps/sim/app/api/tools/s3/put-object/route.ts | 2 + .../sim/app/api/tools/textract/parse/route.ts | 33 +-------- apps/sim/blocks/blocks/s3.ts | 4 ++ apps/sim/blocks/blocks/textract.ts | 52 ++------------ .../core/security/input-validation.test.ts | 68 ++++++++++++++++++- .../sim/lib/core/security/input-validation.ts | 20 ++++-- apps/sim/tools/s3/copy_object.ts | 5 ++ apps/sim/tools/s3/put_object.ts | 5 ++ 10 files changed, 109 insertions(+), 84 deletions(-) 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/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 index 2aae06fd1c..8e2696e484 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -322,44 +322,17 @@ export async function POST(request: NextRequest) { }) if (processingMode === 'async') { - if (!validatedData.s3Uri && !validatedData.filePath) { + if (!validatedData.s3Uri) { return NextResponse.json( { success: false, - error: 'S3 URI or file path is required for multi-page processing', + error: 'S3 URI is required for multi-page processing (s3://bucket/key)', }, { status: 400 } ) } - let s3Bucket: string - let s3Key: string - - if (validatedData.s3Uri) { - const parsed = parseS3Uri(validatedData.s3Uri) - s3Bucket = parsed.bucket - s3Key = parsed.key - } else if (validatedData.filePath?.includes('/api/files/serve/')) { - const storageKey = extractStorageKey(validatedData.filePath) - const context = inferContextFromKey(storageKey) - - const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) - if (!hasAccess) { - return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) - } - - const s3Info = StorageService.getS3InfoForKey(storageKey, context) - s3Bucket = s3Info.bucket - s3Key = s3Info.key - } else { - return NextResponse.json( - { - success: false, - error: 'Multi-page mode requires an S3 URI (s3://bucket/key) or an uploaded file', - }, - { status: 400 } - ) - } + const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri) logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key }) 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 index 12104ae83b..ee76504e49 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -36,19 +36,6 @@ export const TextractBlock: BlockConfig = { not: true, }, }, - { - id: 'asyncInputMethod', - title: 'Select Input Method', - type: 'dropdown' as SubBlockType, - options: [ - { id: 's3', label: 'S3 URI' }, - { id: 'upload', label: 'Upload Document' }, - ], - condition: { - field: 'processingMode', - value: 'async', - }, - }, { id: 'filePath', title: 'Document URL', @@ -70,12 +57,8 @@ export const TextractBlock: BlockConfig = { type: 'short-input' as SubBlockType, placeholder: 's3://bucket-name/path/to/document.pdf', condition: { - field: 'asyncInputMethod', - value: 's3', - and: { - field: 'processingMode', - value: 'async', - }, + field: 'processingMode', + value: 'async', }, }, { @@ -94,21 +77,6 @@ export const TextractBlock: BlockConfig = { }, maxSize: 10, }, - { - id: 'asyncFileUpload', - title: 'Upload Document', - type: 'file-upload' as SubBlockType, - acceptedTypes: 'application/pdf,image/jpeg,image/png,image/tiff', - condition: { - field: 'asyncInputMethod', - value: 'upload', - and: { - field: 'processingMode', - value: 'async', - }, - }, - maxSize: 50, - }, { id: 'region', title: 'AWS Region', @@ -177,18 +145,10 @@ export const TextractBlock: BlockConfig = { } if (processingMode === 'async') { - const asyncInputMethod = params.asyncInputMethod || 's3' - if (asyncInputMethod === 's3') { - if (!params.s3Uri || params.s3Uri.trim() === '') { - throw new Error('S3 URI is required for multi-page processing') - } - parameters.s3Uri = params.s3Uri.trim() - } else if (asyncInputMethod === 'upload') { - if (!params.asyncFileUpload) { - throw new Error('Please upload a document') - } - parameters.fileUpload = params.asyncFileUpload + if (!params.s3Uri || params.s3Uri.trim() === '') { + throw new Error('S3 URI is required for multi-page processing') } + parameters.s3Uri = params.s3Uri.trim() } else { const inputMethod = params.inputMethod || 'url' if (inputMethod === 'url') { @@ -221,11 +181,9 @@ export const TextractBlock: BlockConfig = { inputs: { processingMode: { type: 'string', description: 'Document type: single-page or multi-page' }, inputMethod: { type: 'string', description: 'Input method selection for single-page mode' }, - asyncInputMethod: { type: 'string', description: 'Input method selection for multi-page mode' }, filePath: { type: 'string', description: 'Document URL' }, s3Uri: { type: 'string', description: 'S3 URI for multi-page processing (s3://bucket/key)' }, fileUpload: { type: 'json', description: 'Uploaded document file for single-page mode' }, - asyncFileUpload: { type: 'json', description: 'Uploaded document file for multi-page mode' }, extractTables: { type: 'boolean', description: 'Extract tables from document' }, extractForms: { type: 'boolean', description: 'Extract form key-value pairs' }, detectSignatures: { type: 'boolean', description: 'Detect signatures' }, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index f15259a932..1a5d50d471 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1196,7 +1196,7 @@ describe('validateAirtableId', () => { }) describe('validateAwsRegion', () => { - describe('valid regions', () => { + describe('valid standard regions', () => { it.concurrent('should accept us-east-1', () => { const result = validateAwsRegion('us-east-1') expect(result.isValid).toBe(true) @@ -1213,11 +1213,21 @@ describe('validateAwsRegion', () => { 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) @@ -1233,12 +1243,58 @@ describe('validateAwsRegion', () => { 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) @@ -1282,6 +1338,16 @@ describe('validateAwsRegion', () => { 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') diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 36493b42f0..331f2b7fce 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -937,8 +937,12 @@ export function validateAirtableId( /** * Validates an AWS region identifier * - * AWS regions follow the pattern: {area}-{sub-area}-{number} - * Examples: us-east-1, eu-west-2, ap-southeast-1, sa-east-1 + * 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 @@ -963,9 +967,13 @@ export function validateAwsRegion( } } - // AWS region format: {area}-{sub-area}-{number} - // Examples: us-east-1, eu-west-2, ap-southeast-1, me-south-1, af-south-1 - const awsRegionPattern = /^[a-z]{2}-[a-z]+-\d{1,2}$/ + // 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', { @@ -974,7 +982,7 @@ export function validateAwsRegion( }) return { isValid: false, - error: `${paramName} must be a valid AWS region (e.g., us-east-1, eu-west-2)`, + error: `${paramName} must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)`, } } 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', From dcaae1df7c1f2433172821761cdf580bac87c834 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 11:51:22 -0800 Subject: [PATCH 06/15] fix additional fields dropdown in editor, update parser to leave validation to be done on the server --- .../panel/components/editor/editor.tsx | 5 +- apps/sim/tools/textract/parser.ts | 152 ++---------------- 2 files changed, 18 insertions(+), 139 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index d9fa9a2062..306ed8e8e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -129,7 +129,10 @@ export function Editor() { blockSubBlockValues, canonicalIndex ) - const displayAdvancedOptions = advancedMode || advancedValuesPresent + // When user can edit, respect their toggle; otherwise show if values present + const displayAdvancedOptions = userPermissions.canEdit + ? advancedMode + : advancedMode || advancedValuesPresent const hasAdvancedOnlyFields = useMemo(() => { for (const subBlock of subBlocksForCanonical) { diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index f367e9ba4a..75bc5fb54e 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getBaseUrl } from '@/lib/core/utils/urls' import type { TextractParserInput, TextractParserOutput } from '@/tools/textract/types' import type { ToolConfig } from '@/tools/types' @@ -92,158 +91,35 @@ export const textractParserTool: ToolConfig { - if (!params || typeof params !== 'object') { - throw new Error('Invalid parameters: Parameters must be provided as an object') - } - - if ( - !params.accessKeyId || - typeof params.accessKeyId !== 'string' || - params.accessKeyId.trim() === '' - ) { - throw new Error('Missing or invalid AWS Access Key ID') - } - - if ( - !params.secretAccessKey || - typeof params.secretAccessKey !== 'string' || - params.secretAccessKey.trim() === '' - ) { - throw new Error('Missing or invalid AWS Secret Access Key') - } - - if (!params.region || typeof params.region !== 'string' || params.region.trim() === '') { - throw new Error('Missing or invalid AWS region') - } - const processingMode = params.processingMode || 'sync' const requestBody: Record = { - accessKeyId: params.accessKeyId.trim(), - secretAccessKey: params.secretAccessKey.trim(), - region: params.region.trim(), + accessKeyId: params.accessKeyId?.trim(), + secretAccessKey: params.secretAccessKey?.trim(), + region: params.region?.trim(), processingMode, } if (processingMode === 'async') { - if (params.s3Uri && typeof params.s3Uri === 'string' && params.s3Uri.trim() !== '') { - const s3UriTrimmed = params.s3Uri.trim() - if (!s3UriTrimmed.match(/^s3:\/\/[^/]+\/.+$/)) { - throw new Error('Invalid S3 URI format. Expected: s3://bucket-name/path/to/object') - } - requestBody.s3Uri = s3UriTrimmed - } else if (params.fileUpload) { - if ( - typeof params.fileUpload === 'object' && - params.fileUpload !== null && - (params.fileUpload.url || params.fileUpload.path) - ) { - const uploadedFilePath = (params.fileUpload.path || params.fileUpload.url) as string - if (uploadedFilePath.startsWith('/api/files/serve/')) { - requestBody.filePath = uploadedFilePath - } else { - throw new Error('Multi-page mode with upload requires files stored in S3') - } - } else { - throw new Error('Invalid file upload: Upload data is missing or invalid') - } - } else { - throw new Error('Multi-page mode requires either an S3 URI or an uploaded file') - } + requestBody.s3Uri = params.s3Uri?.trim() } else { - if ( - params.fileUpload && - (!params.filePath || params.filePath === 'null' || params.filePath === '') - ) { - if ( - typeof params.fileUpload === 'object' && - params.fileUpload !== null && - (params.fileUpload.url || params.fileUpload.path) - ) { - let uploadedFilePath = (params.fileUpload.url || params.fileUpload.path) as string - - if (uploadedFilePath.startsWith('/')) { - const baseUrl = getBaseUrl() - if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') - uploadedFilePath = `${baseUrl}${uploadedFilePath}` - } - - params.filePath = uploadedFilePath - logger.info('Using uploaded file:', uploadedFilePath) - } else { - throw new Error('Invalid file upload: Upload data is missing or invalid') - } - } - - if ( - !params.filePath || - typeof params.filePath !== 'string' || - params.filePath.trim() === '' - ) { - throw new Error('Missing or invalid file path: Please provide a URL to a document') - } - - let filePathToValidate = params.filePath.trim() - if (filePathToValidate.startsWith('/')) { - const baseUrl = getBaseUrl() - if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') - filePathToValidate = `${baseUrl}${filePathToValidate}` - } - - let url - try { - url = new URL(filePathToValidate) - - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error( - `Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol` - ) + // 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 } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a document.` - ) - } - - requestBody.filePath = url.toString() - - if (params.fileUpload?.path?.startsWith('/api/files/serve/')) { - requestBody.filePath = params.fileUpload.path + } else { + requestBody.filePath = params.filePath?.trim() } } if (params.featureTypes && Array.isArray(params.featureTypes)) { - const validFeatures = ['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT'] - const filteredFeatures = params.featureTypes.filter((f) => - validFeatures.includes(f as string) - ) - if (filteredFeatures.length > 0) { - requestBody.featureTypes = filteredFeatures - } + requestBody.featureTypes = params.featureTypes } - if (params.queries && Array.isArray(params.queries) && params.queries.length > 0) { - const validQueries = params.queries - .filter((q) => q && typeof q === 'object' && typeof q.Text === 'string' && q.Text.trim()) - .map((q) => ({ - Text: q.Text.trim(), - Alias: q.Alias?.trim() || undefined, - Pages: q.Pages || undefined, - })) - - if (validQueries.length > 0) { - requestBody.queries = validQueries - - if (!requestBody.featureTypes) { - requestBody.featureTypes = ['QUERIES'] - } else if ( - Array.isArray(requestBody.featureTypes) && - !requestBody.featureTypes.includes('QUERIES') - ) { - ;(requestBody.featureTypes as string[]).push('QUERIES') - } - } + if (params.queries && Array.isArray(params.queries)) { + requestBody.queries = params.queries } return requestBody From 59578dd1401ea2b390de3d1d64e347fef7f8cb06 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 15:02:27 -0800 Subject: [PATCH 07/15] added mistral v2, files v2, and finalized textract --- apps/docs/components/ui/icon-mapping.ts | 4 +- apps/docs/content/docs/en/tools/file.mdx | 6 +- .../content/docs/en/tools/mistral_parse.mdx | 12 +- .../sim/app/api/tools/textract/parse/route.ts | 7 +- apps/sim/blocks/blocks/file.ts | 93 +++++++++- apps/sim/blocks/blocks/mistral_parse.ts | 171 ++++++++++++++---- apps/sim/blocks/blocks/pulse.ts | 61 +++---- apps/sim/blocks/blocks/reducto.ts | 62 +++---- apps/sim/blocks/blocks/textract.ts | 72 +++----- apps/sim/blocks/registry.ts | 6 +- apps/sim/tools/file/index.ts | 3 +- apps/sim/tools/file/parser.ts | 22 +++ apps/sim/tools/mistral/index.ts | 4 +- apps/sim/tools/mistral/parser.ts | 23 +++ apps/sim/tools/registry.ts | 6 +- apps/sim/tools/textract/parser.ts | 4 +- 16 files changed, 368 insertions(+), 188 deletions(-) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index c80cceb566..6a956a45eb 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -154,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, @@ -196,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, 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/mistral_parse.mdx b/apps/docs/content/docs/en/tools/mistral_parse.mdx index b919be56ad..ac0b2150ca 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" @@ -57,15 +57,5 @@ Parse PDF documents using Mistral OCR API | `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 | diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 8e2696e484..96bc1121a1 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -423,7 +423,12 @@ export async function POST(request: NextRequest) { let fileUrl = validatedData.filePath - if (validatedData.filePath?.includes('/api/files/serve/')) { + const isInternalFilePath = + validatedData.filePath?.startsWith('/api/files/serve/') || + (validatedData.filePath?.startsWith('/') && + validatedData.filePath?.includes('/api/files/serve/')) + + if (isInternalFilePath) { try { const storageKey = extractStorageKey(validatedData.filePath) const context = inferContextFromKey(storageKey) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 46bf0f1380..bfc0b903c6 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', @@ -123,3 +125,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 or reference from previous block', + 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..2cba8700a6 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,127 @@ 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 or reference from previous block', + mode: 'advanced', + }, + { + id: 'resultType', + title: 'Output Format', + type: 'dropdown', + options: [ + { id: 'markdown', label: 'Markdown (Formatted)' }, + { id: 'text', label: 'Plain Text' }, + { id: 'json', label: 'JSON (Raw)' }, + ], + }, + { + 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: { + content: { type: 'string', description: 'Extracted content' }, + metadata: { type: 'json', description: 'Processing metadata' }, + }, +} diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 212f325d71..7f36b87ed9 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 or reference from previous block', + 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..a7c7a28c29 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 or reference from previous block', + 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/textract.ts b/apps/sim/blocks/blocks/textract.ts index ee76504e49..1acbb71a61 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -23,33 +23,32 @@ export const TextractBlock: BlockConfig = { ], }, { - id: 'inputMethod', - title: 'Select Input Method', - type: 'dropdown' as SubBlockType, - options: [ - { id: 'url', label: 'Document URL' }, - { id: 'upload', label: 'Upload Document' }, - ], + id: 'fileUpload', + title: 'Document', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'document', + acceptedTypes: 'application/pdf,image/jpeg,image/png,image/tiff', + placeholder: 'Upload a document', condition: { field: 'processingMode', value: 'async', not: true, }, + mode: 'basic', + maxSize: 10, }, { id: 'filePath', - title: 'Document URL', + title: 'Document', type: 'short-input' as SubBlockType, - placeholder: 'Enter full URL to a document (JPEG, PNG, or single-page PDF)', + canonicalParamId: 'document', + placeholder: 'Document URL or reference from previous block', condition: { - field: 'inputMethod', - value: 'url', - and: { - field: 'processingMode', - value: 'async', - not: true, - }, + field: 'processingMode', + value: 'async', + not: true, }, + mode: 'advanced', }, { id: 's3Uri', @@ -61,22 +60,6 @@ export const TextractBlock: BlockConfig = { value: 'async', }, }, - { - id: 'fileUpload', - title: 'Upload Document', - type: 'file-upload' as SubBlockType, - acceptedTypes: 'application/pdf,image/jpeg,image/png,image/tiff', - condition: { - field: 'inputMethod', - value: 'upload', - and: { - field: 'processingMode', - value: 'async', - not: true, - }, - }, - maxSize: 10, - }, { id: 'region', title: 'AWS Region', @@ -150,17 +133,14 @@ export const TextractBlock: BlockConfig = { } parameters.s3Uri = params.s3Uri.trim() } else { - 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() } } @@ -180,10 +160,10 @@ export const TextractBlock: BlockConfig = { }, inputs: { processingMode: { type: 'string', description: 'Document type: single-page or multi-page' }, - inputMethod: { type: 'string', description: 'Input method selection for single-page mode' }, - filePath: { type: 'string', description: 'Document URL' }, + 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)' }, - fileUpload: { type: 'json', description: 'Uploaded document file for single-page mode' }, extractTables: { type: 'boolean', description: 'Extract tables from document' }, extractForms: { type: 'boolean', description: 'Extract form key-value pairs' }, detectSignatures: { type: 'boolean', description: 'Detect signatures' }, diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 1a37b2a14d..80efc620e6 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -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' @@ -182,6 +182,7 @@ export const registry: Record = { evaluator: EvaluatorBlock, exa: ExaBlock, file: FileBlock, + file_v2: FileV2Block, firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, function: FunctionBlock, @@ -237,6 +238,7 @@ export const registry: Record = { microsoft_planner: MicrosoftPlannerBlock, microsoft_teams: MicrosoftTeamsBlock, mistral_parse: MistralParseBlock, + mistral_parse_v2: MistralParseV2Block, mongodb: MongoDBBlock, mysql: MySQLBlock, neo4j: Neo4jBlock, 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 6076e4248c..e3f381403e 100644 --- a/apps/sim/tools/file/parser.ts +++ b/apps/sim/tools/file/parser.ts @@ -146,3 +146,25 @@ export const fileParserTool: ToolConfig = { combinedContent: { type: 'string', description: 'Combined content of all parsed files' }, }, } + +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..a47f22ba4f 100644 --- a/apps/sim/tools/mistral/parser.ts +++ b/apps/sim/tools/mistral/parser.ts @@ -415,3 +415,26 @@ 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: mistralParserTool.transformResponse, + + outputs: { + success: { type: 'boolean', description: 'Whether the PDF was parsed successfully' }, + content: { + type: 'string', + description: 'Extracted content in the requested format (markdown, text, or JSON)', + }, + metadata: { + type: 'object', + description: 'Processing metadata including jobId, fileType, pageCount, and usage info', + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index d15f72b5fb..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, @@ -1683,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, @@ -2456,6 +2457,7 @@ 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, diff --git a/apps/sim/tools/textract/parser.ts b/apps/sim/tools/textract/parser.ts index 75bc5fb54e..a7b95564c5 100644 --- a/apps/sim/tools/textract/parser.ts +++ b/apps/sim/tools/textract/parser.ts @@ -141,8 +141,8 @@ export const textractParserTool: ToolConfig Date: Tue, 20 Jan 2026 15:41:06 -0800 Subject: [PATCH 08/15] updated the rest of the old file patterns, updated mistral outputs for v2 --- apps/docs/components/ui/icon-mapping.ts | 4 +- .../docs/content/docs/en/tools/confluence.mdx | 2 +- .../content/docs/en/tools/mistral_parse.mdx | 35 +- .../content/docs/en/tools/video_generator.mdx | 2 +- .../components/file-upload/file-upload.tsx | 70 +++- .../components/tag-dropdown/tag-dropdown.tsx | 22 +- .../panel/components/editor/editor.tsx | 1 - apps/sim/blocks/blocks/a2a.ts | 22 +- apps/sim/blocks/blocks/confluence.ts | 342 +++++++++++++++- apps/sim/blocks/blocks/mistral_parse.ts | 10 +- apps/sim/blocks/blocks/video_generator.ts | 378 +++++++++++++++++- apps/sim/blocks/registry.ts | 6 +- apps/sim/tools/mistral/parser.ts | 132 +++++- apps/sim/tools/mistral/types.ts | 78 ++++ 14 files changed, 1056 insertions(+), 48 deletions(-) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 6a956a45eb..4011e9340f 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -144,7 +144,7 @@ export const blockTypeToIconMap: Record = { calendly: CalendlyIcon, circleback: CirclebackIcon, clay: ClayIcon, - confluence: ConfluenceIcon, + confluence_v2: ConfluenceIcon, cursor_v2: CursorIcon, datadog: DatadogIcon, discord: DiscordIcon, @@ -246,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/mistral_parse.mdx b/apps/docs/content/docs/en/tools/mistral_parse.mdx index ac0b2150ca..c45023367f 100644 --- a/apps/docs/content/docs/en/tools/mistral_parse.mdx +++ b/apps/docs/content/docs/en/tools/mistral_parse.mdx @@ -54,8 +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 | +| `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/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/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..2ee395a17f 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 @@ -85,14 +85,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, accepted: string): boolean => { + if (accepted === '*') return true + + 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) // 'pdf' from '.pdf' + return ( + normalizedFileType.endsWith(`/${extension}`) || + normalizedFileType.includes(`${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 +454,23 @@ export function FileUpload({ return (
-
+
{truncateMiddle(file.name)} ({formatFileSize(file.size)})
@@ -468,19 +501,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/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 d5fde31199..220be80914 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 @@ -241,11 +241,9 @@ const getOutputTypeForPath = ( const blockState = useWorkflowStore.getState().blocks[blockId] const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) return getBlockOutputType(block.type, outputPath, subBlocks) - } else { + } else if (blockConfig) { const operationValue = getSubBlockValue(blockId, 'operation') - if (blockConfig && operationValue) { - return getToolOutputType(blockConfig, operationValue, outputPath) - } + return getToolOutputType(blockConfig, operationValue || '', outputPath) } return 'any' } @@ -1213,9 +1211,11 @@ export const TagDropdown: React.FC = ({ } else { const operationValue = mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation') - const toolOutputPaths = operationValue - ? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks) - : [] + const toolOutputPaths = getToolOutputPaths( + blockConfig, + operationValue || '', + mergedSubBlocks + ) if (toolOutputPaths.length > 0) { blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`) @@ -1545,9 +1545,11 @@ export const TagDropdown: React.FC = ({ } else { const operationValue = mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation') - const toolOutputPaths = operationValue - ? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks) - : [] + const toolOutputPaths = getToolOutputPaths( + blockConfig, + operationValue || '', + mergedSubBlocks + ) if (toolOutputPaths.length > 0) { blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 306ed8e8e3..e2db3de277 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -129,7 +129,6 @@ export function Editor() { blockSubBlockValues, canonicalIndex ) - // When user can edit, respect their toggle; otherwise show if values present const displayAdvancedOptions = userPermissions.canEdit ? advancedMode : advancedMode || advancedValuesPresent 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/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index 2cba8700a6..bf76cd8eee 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -173,9 +173,9 @@ export const MistralParseV2Block: BlockConfig = { title: 'Output Format', type: 'dropdown', options: [ - { id: 'markdown', label: 'Markdown (Formatted)' }, + { id: 'markdown', label: 'Markdown' }, { id: 'text', label: 'Plain Text' }, - { id: 'json', label: 'JSON (Raw)' }, + { id: 'json', label: 'JSON' }, ], }, { @@ -262,7 +262,9 @@ export const MistralParseV2Block: BlockConfig = { pages: { type: 'string', description: 'Page selection' }, }, outputs: { - content: { type: 'string', description: 'Extracted content' }, - metadata: { type: 'json', description: 'Processing metadata' }, + 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/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 80efc620e6..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' @@ -133,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' @@ -170,6 +170,7 @@ export const registry: Record = { clay: ClayBlock, condition: ConditionBlock, confluence: ConfluenceBlock, + confluence_v2: ConfluenceV2Block, cursor: CursorBlock, cursor_v2: CursorV2Block, datadog: DatadogBlock, @@ -300,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/tools/mistral/parser.ts b/apps/sim/tools/mistral/parser.ts index a47f22ba4f..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') @@ -416,7 +420,7 @@ export const mistralParserTool: ToolConfig = { +export const mistralParserV2Tool: ToolConfig = { id: 'mistral_parser_v2', name: 'Mistral PDF Parser', description: 'Parse PDF documents using Mistral OCR API', @@ -424,17 +428,129 @@ export const mistralParserV2Tool: ToolConfig { + 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: { - success: { type: 'boolean', description: 'Whether the PDF was parsed successfully' }, - content: { + 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: 'Extracted content in the requested format (markdown, text, or JSON)', + description: 'Mistral OCR model identifier (e.g., mistral-ocr-latest)', }, - metadata: { + usage_info: { type: 'object', - description: 'Processing metadata including jobId, fileType, pageCount, and usage info', + 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..f8f9e62076 100644 --- a/apps/sim/tools/mistral/types.ts +++ b/apps/sim/tools/mistral/types.ts @@ -96,3 +96,81 @@ export interface MistralParserOutput extends ToolResponse { /** The output data containing content and metadata */ output: MistralParserOutputData } + +/** + * Image bounding box and data from Mistral OCR API + */ +export interface MistralOcrImage { + /** Image identifier */ + id: string + /** Top-left X coordinate */ + top_left_x: number + /** Top-left Y coordinate */ + top_left_y: number + /** Bottom-right X coordinate */ + bottom_right_x: number + /** Bottom-right Y coordinate */ + bottom_right_y: number + /** Base64-encoded image data (if includeImageBase64 was true) */ + image_base64?: string +} + +/** + * Page dimensions from Mistral OCR API + */ +export interface MistralOcrDimensions { + /** DPI of the page */ + dpi: number + /** Page height in pixels */ + height: number + /** Page width in pixels */ + width: number +} + +/** + * Page data from Mistral OCR API + */ +export interface MistralOcrPage { + /** Page index (zero-based) */ + index: number + /** Markdown content extracted from this page */ + markdown: string + /** Images extracted from this page */ + images: MistralOcrImage[] + /** Page dimensions */ + dimensions: MistralOcrDimensions + /** Tables extracted from this page */ + tables: unknown[] + /** Hyperlinks found on this page */ + hyperlinks: unknown[] + /** Header content if detected */ + header: string | null + /** Footer content if detected */ + footer: string | null +} + +/** + * Raw usage info from Mistral OCR API + */ +export interface MistralOcrUsageInfoRaw { + /** Number of pages processed */ + pages_processed: number + /** Document size in bytes */ + doc_size_bytes: number | null +} + +/** + * V2 Output - Returns raw Mistral API response structure + */ +export interface MistralParserV2Output extends ToolResponse { + output: { + /** Array of page objects with full OCR data */ + pages: MistralOcrPage[] + /** Model used for OCR processing */ + model: string + /** Usage statistics from the API */ + usage_info: MistralOcrUsageInfoRaw + /** Structured annotation data as JSON string (when applicable) */ + document_annotation: string | null + } +} From c3634c2e3877e36b1df2018da6076d3c2825a67d Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 15:59:40 -0800 Subject: [PATCH 09/15] updated tag dropdown to parse non-operation fields as well --- .../output-select/output-select.tsx | 18 ++++++++--- .../components/tag-dropdown/tag-dropdown.tsx | 24 ++++---------- .../sim/lib/workflows/blocks/block-outputs.ts | 32 ++++++++++++------- 3 files changed, 42 insertions(+), 32 deletions(-) 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..a8a3730c2b 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 @@ -146,10 +146,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/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 220be80914..465d2c0797 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 @@ -242,8 +242,10 @@ const getOutputTypeForPath = ( const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) return getBlockOutputType(block.type, outputPath, subBlocks) } else if (blockConfig) { - const operationValue = getSubBlockValue(blockId, 'operation') - return getToolOutputType(blockConfig, operationValue || '', outputPath) + // Pass full subBlocks to support tool selectors that use any field (operation, provider, etc.) + const blockState = useWorkflowStore.getState().blocks[blockId] + const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) + return getToolOutputType(blockConfig, subBlocks, outputPath) } return 'any' } @@ -1209,13 +1211,8 @@ export const TagDropdown: React.FC = ({ : allTags } } else { - const operationValue = - mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation') - const toolOutputPaths = getToolOutputPaths( - blockConfig, - operationValue || '', - mergedSubBlocks - ) + // Pass full subBlocks to support tool selectors that use any field (operation, provider, etc.) + const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks) if (toolOutputPaths.length > 0) { blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`) @@ -1535,7 +1532,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 @@ -1543,13 +1539,7 @@ export const TagDropdown: React.FC = ({ blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`] } } else { - const operationValue = - mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation') - const toolOutputPaths = getToolOutputPaths( - blockConfig, - operationValue || '', - mergedSubBlocks - ) + const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks) if (toolOutputPaths.length > 0) { blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`) diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index c914227ab5..4dccfcb434 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -545,14 +545,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) @@ -560,7 +572,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 {} } } @@ -569,16 +581,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 [] @@ -613,16 +623,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) From 233a3ee0b4cf04d494902bd198120a9a5ce89214 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 16:02:54 -0800 Subject: [PATCH 10/15] updated extension finder --- .../sub-block/components/file-upload/file-upload.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 2ee395a17f..2848f39108 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') @@ -104,11 +105,10 @@ export function FileUpload({ } if (acceptedType.startsWith('.')) { - const extension = acceptedType.slice(1) // 'pdf' from '.pdf' - return ( - normalizedFileType.endsWith(`/${extension}`) || - normalizedFileType.includes(`${extension}`) - ) + const extension = acceptedType.slice(1).toLowerCase() + const fileExtension = getExtensionFromMimeType(normalizedFileType) + if (fileExtension === extension) return true + return normalizedFileType.endsWith(`/${extension}`) } return false From c9239b55efbdc3e1c6e9fdd63912791a4fe68e06 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 16:34:21 -0800 Subject: [PATCH 11/15] cleanup --- .../sim/app/api/tools/textract/parse/route.ts | 19 +++- .../output-select/output-select.tsx | 4 - .../components/tag-dropdown/tag-dropdown.tsx | 2 - apps/sim/blocks/blocks/file.ts | 2 +- apps/sim/blocks/blocks/mistral_parse.ts | 2 +- apps/sim/blocks/blocks/pulse.ts | 2 +- apps/sim/blocks/blocks/reducto.ts | 2 +- apps/sim/blocks/blocks/textract.ts | 14 +-- apps/sim/tools/mistral/types.ts | 96 ------------------- 9 files changed, 29 insertions(+), 114 deletions(-) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 96bc1121a1..d3e0884b92 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -496,7 +496,10 @@ export async function POST(request: NextRequest) { } } - const { bytes } = await fetchDocumentBytes(fileUrl) + 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 = '/' @@ -559,6 +562,7 @@ export async function POST(request: NextRequest) { 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) { @@ -566,8 +570,19 @@ export async function POST(request: NextRequest) { } 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 { - // Use default error message + 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( 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 a8a3730c2b..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 = {} 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 465d2c0797..96d9384476 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 @@ -242,7 +242,6 @@ const getOutputTypeForPath = ( const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) return getBlockOutputType(block.type, outputPath, subBlocks) } else if (blockConfig) { - // Pass full subBlocks to support tool selectors that use any field (operation, provider, etc.) const blockState = useWorkflowStore.getState().blocks[blockId] const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) return getToolOutputType(blockConfig, subBlocks, outputPath) @@ -1211,7 +1210,6 @@ export const TagDropdown: React.FC = ({ : allTags } } else { - // Pass full subBlocks to support tool selectors that use any field (operation, provider, etc.) const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks) if (toolOutputPaths.length > 0) { diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index bfc0b903c6..c5cd01976f 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -150,7 +150,7 @@ export const FileV2Block: BlockConfig = { title: 'Files', type: 'short-input' as SubBlockType, canonicalParamId: 'fileInput', - placeholder: 'File URL or reference from previous block', + placeholder: 'File URL', mode: 'advanced', }, ], diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index bf76cd8eee..62773a71cd 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -165,7 +165,7 @@ export const MistralParseV2Block: BlockConfig = { title: 'PDF Document', type: 'short-input' as SubBlockType, canonicalParamId: 'document', - placeholder: 'Document URL or reference from previous block', + placeholder: 'Document URL', mode: 'advanced', }, { diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts index 7f36b87ed9..0e2f5658fe 100644 --- a/apps/sim/blocks/blocks/pulse.ts +++ b/apps/sim/blocks/blocks/pulse.ts @@ -29,7 +29,7 @@ export const PulseBlock: BlockConfig = { title: 'Document', type: 'short-input' as SubBlockType, canonicalParamId: 'document', - placeholder: 'Document URL or reference from previous block', + placeholder: 'Document URL', mode: 'advanced', }, { diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts index a7c7a28c29..681c2aa207 100644 --- a/apps/sim/blocks/blocks/reducto.ts +++ b/apps/sim/blocks/blocks/reducto.ts @@ -28,7 +28,7 @@ export const ReductoBlock: BlockConfig = { title: 'PDF Document', type: 'short-input' as SubBlockType, canonicalParamId: 'document', - placeholder: 'Document URL or reference from previous block', + placeholder: 'Document URL', mode: 'advanced', }, { diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 1acbb71a61..2b83887089 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -15,20 +15,22 @@ export const TextractBlock: BlockConfig = { subBlocks: [ { id: 'processingMode', - title: 'Document Type', + title: 'Processing Mode', type: 'dropdown' as SubBlockType, options: [ - { id: 'sync', label: 'Single Page' }, - { id: 'async', label: 'Multi-Page' }, + { 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: 'application/pdf,image/jpeg,image/png,image/tiff', - placeholder: 'Upload a document', + acceptedTypes: 'image/jpeg,image/png,application/pdf', + placeholder: 'Upload JPEG, PNG, or single-page PDF (max 10MB)', condition: { field: 'processingMode', value: 'async', @@ -42,7 +44,7 @@ export const TextractBlock: BlockConfig = { title: 'Document', type: 'short-input' as SubBlockType, canonicalParamId: 'document', - placeholder: 'Document URL or reference from previous block', + placeholder: 'URL to JPEG, PNG, or single-page PDF', condition: { field: 'processingMode', value: 'async', diff --git a/apps/sim/tools/mistral/types.ts b/apps/sim/tools/mistral/types.ts index f8f9e62076..9e138b8ca4 100644 --- a/apps/sim/tools/mistral/types.ts +++ b/apps/sim/tools/mistral/types.ts @@ -1,176 +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 } -/** - * Image bounding box and data from Mistral OCR API - */ export interface MistralOcrImage { - /** Image identifier */ id: string - /** Top-left X coordinate */ top_left_x: number - /** Top-left Y coordinate */ top_left_y: number - /** Bottom-right X coordinate */ bottom_right_x: number - /** Bottom-right Y coordinate */ bottom_right_y: number - /** Base64-encoded image data (if includeImageBase64 was true) */ image_base64?: string } -/** - * Page dimensions from Mistral OCR API - */ export interface MistralOcrDimensions { - /** DPI of the page */ dpi: number - /** Page height in pixels */ height: number - /** Page width in pixels */ width: number } -/** - * Page data from Mistral OCR API - */ export interface MistralOcrPage { - /** Page index (zero-based) */ index: number - /** Markdown content extracted from this page */ markdown: string - /** Images extracted from this page */ images: MistralOcrImage[] - /** Page dimensions */ dimensions: MistralOcrDimensions - /** Tables extracted from this page */ tables: unknown[] - /** Hyperlinks found on this page */ hyperlinks: unknown[] - /** Header content if detected */ header: string | null - /** Footer content if detected */ footer: string | null } -/** - * Raw usage info from Mistral OCR API - */ export interface MistralOcrUsageInfoRaw { - /** Number of pages processed */ pages_processed: number - /** Document size in bytes */ doc_size_bytes: number | null } -/** - * V2 Output - Returns raw Mistral API response structure - */ export interface MistralParserV2Output extends ToolResponse { output: { - /** Array of page objects with full OCR data */ pages: MistralOcrPage[] - /** Model used for OCR processing */ model: string - /** Usage statistics from the API */ usage_info: MistralOcrUsageInfoRaw - /** Structured annotation data as JSON string (when applicable) */ document_annotation: string | null } } From 63d109de3a128df12568cea3b33c7489932a49ef Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 20 Jan 2026 17:52:22 -0800 Subject: [PATCH 12/15] added description for inputs to workflow --- README.md | 2 +- .../components/starter/input-format.tsx | 24 ++++++- apps/sim/providers/utils.ts | 63 ++++++++++++++++++- apps/sim/tools/params.ts | 5 +- 4 files changed, 86 insertions(+), 8 deletions(-) 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/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/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/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') From 601f58cec90e9fb21c8b97c2256973d96928c841 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 20 Jan 2026 18:11:38 -0800 Subject: [PATCH 13/15] use helper for internal route check --- apps/sim/app/api/tools/mistral/parse/route.ts | 10 +++-- apps/sim/app/api/tools/pulse/parse/route.ts | 10 +++-- apps/sim/app/api/tools/reducto/parse/route.ts | 10 +++-- .../sim/app/api/tools/textract/parse/route.ts | 39 +++++++++---------- .../components/file-upload/file-upload.tsx | 3 +- .../panel/components/editor/editor.tsx | 4 +- 6 files changed, 42 insertions(+), 34 deletions(-) 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/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index d3e0884b92..3fb73976dd 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -9,9 +9,12 @@ import { validateS3BucketName, } from '@/lib/core/security/input-validation' 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' @@ -423,10 +426,7 @@ export async function POST(request: NextRequest) { let fileUrl = validatedData.filePath - const isInternalFilePath = - validatedData.filePath?.startsWith('/api/files/serve/') || - (validatedData.filePath?.startsWith('/') && - validatedData.filePath?.includes('/api/files/serve/')) + const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath) if (isInternalFilePath) { try { @@ -463,21 +463,18 @@ export async function POST(request: NextRequest) { ) } } else if (validatedData.filePath?.startsWith('/')) { - if (!validatedData.filePath.startsWith('/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 } - ) - } - const baseUrl = getBaseUrl() - fileUrl = `${baseUrl}${validatedData.filePath}` + // 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) { 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 2848f39108..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 @@ -90,8 +90,9 @@ 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, accepted: string): boolean => { + 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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index e2db3de277..d9fa9a2062 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -129,9 +129,7 @@ export function Editor() { blockSubBlockValues, canonicalIndex ) - const displayAdvancedOptions = userPermissions.canEdit - ? advancedMode - : advancedMode || advancedValuesPresent + const displayAdvancedOptions = advancedMode || advancedValuesPresent const hasAdvancedOnlyFields = useMemo(() => { for (const subBlock of subBlocksForCanonical) { From cd1c5315d6e556bcde90875ce1309d0037a8d934 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 20 Jan 2026 18:19:55 -0800 Subject: [PATCH 14/15] fix tag dropdown merge conflict change --- .../sub-block/components/tag-dropdown/tag-dropdown.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 9d1b1e916a..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 @@ -241,12 +241,16 @@ const getOutputTypeForPath = ( const blockState = useWorkflowStore.getState().blocks[blockId] const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) return getBlockOutputType(block.type, outputPath, subBlocks) - } else if (blockConfig) { + } else if (blockConfig?.tools?.config?.tool) { const blockState = useWorkflowStore.getState().blocks[blockId] const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) return getToolOutputType(blockConfig, subBlocks, outputPath) } - return 'any' + + const subBlocks = + mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks + const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled + return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode) } /** From 78f818f7cd5694c6dc74e141b06228cbc7e6df27 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 20 Jan 2026 18:32:17 -0800 Subject: [PATCH 15/15] remove duplicate code --- .../hooks/use-block-output-fields.ts | 91 ++----------------- 1 file changed, 6 insertions(+), 85 deletions(-) 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) } }