Compare commits

...

11 Commits

Author SHA1 Message Date
Vikhyath Mondreti
7411095e43 rename correctly 2026-01-21 02:38:30 -08:00
Vikhyath Mondreti
d900267cc3 fix(ring): duplicate should clear original block 2026-01-21 02:24:22 -08:00
Vikhyath Mondreti
145db9d8c3 fix(http): options not parsed accurately (#2914)
* fix(http): options not parsed accurately

* fix lint

* remove boilerplate code'
2026-01-21 01:36:29 -08:00
Emir Karabeg
294b168ed9 feat(broadcast): email v0.5 (#2905) 2026-01-20 23:42:48 -08:00
Waleed
0dc2c1fe0d improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)
* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows
2026-01-20 23:13:05 -08:00
Vikhyath Mondreti
fb90c4e9b1 fix(change-detection): copilot diffs have extra field (#2913) 2026-01-20 22:04:08 -08:00
Vikhyath Mondreti
0af96d06c6 fix(a2a): canonical merge (#2912)
* fix canonical merge

* fix empty array case
2026-01-20 21:58:13 -08:00
Vikhyath Mondreti
1d450578c8 fix(copilot): legacy tool display names (#2911) 2026-01-20 21:16:48 -08:00
Waleed
c6d408c65b fix(canvas): removed invite to workspace from canvas popover (#2908)
* fix(canvas): removed invite to workspace

* removed unused props
2026-01-20 20:29:53 -08:00
Waleed
16716ea26a fix(ui): change add inputs button to match output selector (#2907) 2026-01-20 19:24:59 -08:00
Waleed
563098ca0a feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)
* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-20 18:41:26 -08:00
89 changed files with 3875 additions and 530 deletions

View File

@@ -14,7 +14,7 @@
</p>
<p align="center">
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
</p>
### Build Workflows with Ease

View File

@@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='10 14 60 52'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<path
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
fill='currentColor'
/>
</svg>
)
}
export function McpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -110,6 +110,7 @@ import {
SupabaseIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
@@ -143,7 +144,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
calendly: CalendlyIcon,
circleback: CirclebackIcon,
clay: ClayIcon,
confluence: ConfluenceIcon,
confluence_v2: ConfluenceIcon,
cursor_v2: CursorIcon,
datadog: DatadogIcon,
discord: DiscordIcon,
@@ -153,7 +154,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
exa: ExaAIIcon,
file: DocumentIcon,
file_v2: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies: FirefliesIcon,
github_v2: GithubIcon,
@@ -195,7 +196,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
microsoft_excel_v2: MicrosoftExcelIcon,
microsoft_planner: MicrosoftPlannerIcon,
microsoft_teams: MicrosoftTeamsIcon,
mistral_parse: MistralIcon,
mistral_parse_v2: MistralIcon,
mongodb: MongoDBIcon,
mysql: MySQLIcon,
neo4j: Neo4jIcon,
@@ -237,6 +238,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
supabase: SupabaseIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract: TextractIcon,
tinybird: TinybirdIcon,
translate: TranslateIcon,
trello: TrelloIcon,
@@ -244,7 +246,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
video_generator: VideoIcon,
video_generator_v2: VideoIcon,
vision: EyeIcon,
wealthbox: WealthboxIcon,
webflow: WebflowIcon,

View File

@@ -6,7 +6,7 @@ description: Interact with Confluence
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="confluence"
type="confluence_v2"
color="#E0E0E0"
/>

View File

@@ -6,7 +6,7 @@ description: Read and parse multiple files
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="file"
type="file_v2"
color="#40916C"
/>
@@ -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 |

View File

@@ -106,6 +106,7 @@
"supabase",
"tavily",
"telegram",
"textract",
"tinybird",
"translate",
"trello",

View File

@@ -6,7 +6,7 @@ description: Extract text from PDF documents
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="mistral_parse"
type="mistral_parse_v2"
color="#000000"
/>
@@ -54,18 +54,37 @@ Parse PDF documents using Mistral OCR API
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the PDF was parsed successfully |
| `content` | string | Extracted content in the requested format \(markdown, text, or JSON\) |
| `metadata` | object | Processing metadata including jobId, fileType, pageCount, and usage info |
| ↳ `jobId` | string | Unique job identifier |
| ↳ `fileType` | string | File type \(e.g., pdf\) |
| ↳ `fileName` | string | Original file name |
| ↳ `source` | string | Source type \(url\) |
| ↳ `pageCount` | number | Number of pages processed |
| ↳ `model` | string | Mistral model used |
| ↳ `resultType` | string | Output format \(markdown, text, json\) |
| ↳ `processedAt` | string | Processing timestamp |
| ↳ `sourceUrl` | string | Source URL if applicable |
| ↳ `usageInfo` | object | Usage statistics from OCR processing |
| `pages` | array | Array of page objects from Mistral OCR |
| ↳ `index` | number | Page index \(zero-based\) |
| ↳ `markdown` | string | Extracted markdown content |
| ↳ `images` | array | Images extracted from this page with bounding boxes |
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
| ↳ `id` | string | Image identifier \(e.g., img-0.jpeg\) |
| ↳ `top_left_x` | number | Top-left X coordinate in pixels |
| ↳ `top_left_y` | number | Top-left Y coordinate in pixels |
| ↳ `bottom_right_x` | number | Bottom-right X coordinate in pixels |
| ↳ `bottom_right_y` | number | Bottom-right Y coordinate in pixels |
| ↳ `image_base64` | string | Base64-encoded image data \(when include_image_base64=true\) |
| ↳ `dimensions` | object | Page dimensions |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `dpi` | number | Dots per inch |
| ↳ `height` | number | Page height in pixels |
| ↳ `width` | number | Page width in pixels |
| ↳ `tables` | array | Extracted tables as HTML/markdown \(when table_format is set\). Referenced via placeholders like \[tbl-0.html\] |
| ↳ `hyperlinks` | array | Array of URL strings detected in the page \(e.g., \[ |
| ↳ `header` | string | Page header content \(when extract_header=true\) |
| ↳ `footer` | string | Page footer content \(when extract_footer=true\) |
| `model` | string | Mistral OCR model identifier \(e.g., mistral-ocr-latest\) |
| `usage_info` | object | Usage and processing statistics |
| ↳ `pages_processed` | number | Total number of pages processed |
| ↳ `doc_size_bytes` | number | Document file size in bytes |
| `document_annotation` | string | Structured annotation data as JSON string \(when applicable\) |

View File

@@ -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 |

View File

@@ -0,0 +1,120 @@
---
title: AWS Textract
description: Extract text, tables, and forms from documents
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="textract"
color="linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)"
/>
{/* MANUAL-CONTENT-START:intro */}
[AWS Textract](https://aws.amazon.com/textract/) is a powerful AI service from Amazon Web Services designed to automatically extract printed text, handwriting, tables, forms, key-value pairs, and other structured data from scanned documents and images. Textract leverages advanced optical character recognition (OCR) and document analysis to transform documents into actionable data, enabling automation, analytics, compliance, and more.
With AWS Textract, you can:
- **Extract text from images and documents**: Recognize printed text and handwriting in formats such as PDF, JPEG, PNG, or TIFF
- **Detect and extract tables**: Automatically find tables and output their structured content
- **Parse forms and key-value pairs**: Pull structured data from forms, including fields and their corresponding values
- **Identify signatures and layout features**: Detect signatures, geometric layout, and relationships between document elements
- **Customize extraction with queries**: Extract specific fields and answers using query-based extraction (e.g., "What is the invoice number?")
In Sim, the AWS Textract integration empowers your agents to intelligently process documents as part of their workflows. This unlocks automation scenarios such as data entry from invoices, onboarding documents, contracts, receipts, and more. Your agents can extract relevant data, analyze structured forms, and generate summaries or reports directly from document uploads or URLs. By connecting Sim with AWS Textract, you can reduce manual effort, improve data accuracy, and streamline your business processes with robust document understanding.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.
## Tools
### `textract_parser`
Parse documents using AWS Textract OCR and document analysis
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessKeyId` | string | Yes | AWS Access Key ID |
| `secretAccessKey` | string | Yes | AWS Secret Access Key |
| `region` | string | Yes | AWS region for Textract service \(e.g., us-east-1\) |
| `processingMode` | string | No | Document type: single-page or multi-page. Defaults to single-page. |
| `filePath` | string | No | URL to a document to be processed \(JPEG, PNG, or single-page PDF\). |
| `s3Uri` | string | No | S3 URI for multi-page processing \(s3://bucket/key\). |
| `fileUpload` | object | No | File upload data from file-upload component |
| `featureTypes` | array | No | Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed. |
| `items` | string | No | Feature type |
| `queries` | array | No | Custom queries to extract specific information. Only used when featureTypes includes QUERIES. |
| `items` | object | No | Query configuration |
| `properties` | string | No | The query text |
| `Text` | string | No | No description |
| `Alias` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `blocks` | array | Array of Block objects containing detected text, tables, forms, and other elements |
| ↳ `BlockType` | string | Type of block \(PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.\) |
| ↳ `Id` | string | Unique identifier for the block |
| ↳ `Text` | string | Query text |
| ↳ `TextType` | string | Type of text \(PRINTED or HANDWRITING\) |
| ↳ `Confidence` | number | Confidence score \(0-100\) |
| ↳ `Page` | number | Page number |
| ↳ `Geometry` | object | Location and bounding box information |
| ↳ `BoundingBox` | object | Height as ratio of document height |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Polygon` | array | Polygon coordinates |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `BoundingBox` | object | Height as ratio of document height |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Height` | number | Height as ratio of document height |
| ↳ `Left` | number | Left position as ratio of document width |
| ↳ `Top` | number | Top position as ratio of document height |
| ↳ `Width` | number | Width as ratio of document width |
| ↳ `Polygon` | array | Polygon coordinates |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `X` | number | X coordinate |
| ↳ `Y` | number | Y coordinate |
| ↳ `Relationships` | array | Relationships to other blocks |
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
| ↳ `Ids` | array | IDs of related blocks |
| ↳ `Type` | string | Relationship type \(CHILD, VALUE, ANSWER, etc.\) |
| ↳ `Ids` | array | IDs of related blocks |
| ↳ `EntityTypes` | array | Entity types for KEY_VALUE_SET \(KEY or VALUE\) |
| ↳ `SelectionStatus` | string | For checkboxes: SELECTED or NOT_SELECTED |
| ↳ `RowIndex` | number | Row index for table cells |
| ↳ `ColumnIndex` | number | Column index for table cells |
| ↳ `RowSpan` | number | Row span for merged cells |
| ↳ `ColumnSpan` | number | Column span for merged cells |
| ↳ `Query` | object | Query information for QUERY blocks |
| ↳ `Text` | string | Query text |
| ↳ `Alias` | string | Query alias |
| ↳ `Pages` | array | Pages to search |
| ↳ `Alias` | string | Query alias |
| ↳ `Pages` | array | Pages to search |
| `documentMetadata` | object | Metadata about the analyzed document |
| ↳ `pages` | number | Number of pages in the document |
| `modelVersion` | string | Version of the Textract model used for processing |

View File

@@ -6,7 +6,7 @@ description: Generate videos from text using AI
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="video_generator"
type="video_generator_v2"
color="#181C1E"
/>

View File

@@ -11,7 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -36,7 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
if (!startBlock) {
return []

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,637 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
validateAwsRegion,
validateExternalUrl,
validateS3BucketName,
} from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { StorageService } from '@/lib/uploads'
import {
extractStorageKey,
inferContextFromKey,
isInternalFileUrl,
} from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
const logger = createLogger('TextractParseAPI')
const QuerySchema = z.object({
Text: z.string().min(1),
Alias: z.string().optional(),
Pages: z.array(z.string()).optional(),
})
const TextractParseSchema = z
.object({
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
region: z.string().min(1, 'AWS region is required'),
processingMode: z.enum(['sync', 'async']).optional().default('sync'),
filePath: z.string().optional(),
s3Uri: z.string().optional(),
featureTypes: z
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
.optional(),
queries: z.array(QuerySchema).optional(),
})
.superRefine((data, ctx) => {
const regionValidation = validateAwsRegion(data.region, 'AWS region')
if (!regionValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: regionValidation.error,
path: ['region'],
})
}
})
function getSignatureKey(
key: string,
dateStamp: string,
regionName: string,
serviceName: string
): Buffer {
const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest()
const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest()
const kService = crypto.createHmac('sha256', kRegion).update(serviceName).digest()
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
return kSigning
}
function signAwsRequest(
method: string,
host: string,
uri: string,
body: string,
accessKeyId: string,
secretAccessKey: string,
region: string,
service: string,
amzTarget: string
): Record<string, string> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function callTextractAsync(
host: string,
amzTarget: string,
body: Record<string, unknown>,
accessKeyId: string,
secretAccessKey: string,
region: string
): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
const pollIntervalMs = 5000 // 5 seconds between polls
const maxPollTimeMs = 180000 // 3 minutes maximum polling time
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
const getTarget = useAnalyzeDocument
? 'Textract.GetDocumentAnalysis'
: 'Textract.GetDocumentTextDetection'
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await callTextractAsync(
host,
getTarget,
{ JobId: jobId },
accessKeyId,
secretAccessKey,
region
)
const jobStatus = result.JobStatus as string
if (jobStatus === 'SUCCEEDED') {
logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`)
let allBlocks = (result.Blocks as unknown[]) || []
let nextToken = result.NextToken as string | undefined
while (nextToken) {
const nextResult = await callTextractAsync(
host,
getTarget,
{ JobId: jobId, NextToken: nextToken },
accessKeyId,
secretAccessKey,
region
)
allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || [])
nextToken = nextResult.NextToken as string | undefined
}
return {
...result,
Blocks: allBlocks,
}
}
if (jobStatus === 'FAILED') {
throw new Error(`Textract job failed: ${result.StatusMessage || 'Unknown error'}`)
}
if (jobStatus === 'PARTIAL_SUCCESS') {
logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`)
let allBlocks = (result.Blocks as unknown[]) || []
let nextToken = result.NextToken as string | undefined
while (nextToken) {
const nextResult = await callTextractAsync(
host,
getTarget,
{ JobId: jobId, NextToken: nextToken },
accessKeyId,
secretAccessKey,
region
)
allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || [])
nextToken = nextResult.NextToken as string | undefined
}
return {
...result,
Blocks: allBlocks,
}
}
logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`)
await sleep(pollIntervalMs)
}
throw new Error(
`Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`
)
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, {
error: authResult.error || 'Missing userId',
})
return NextResponse.json(
{
success: false,
error: authResult.error || 'Unauthorized',
},
{ status: 401 }
)
}
const userId = authResult.userId
const body = await request.json()
const validatedData = TextractParseSchema.parse(body)
const processingMode = validatedData.processingMode || 'sync'
const featureTypes = validatedData.featureTypes ?? []
const useAnalyzeDocument = featureTypes.length > 0
const host = `textract.${validatedData.region}.amazonaws.com`
logger.info(`[${requestId}] Textract parse request`, {
processingMode,
filePath: validatedData.filePath?.substring(0, 50),
s3Uri: validatedData.s3Uri?.substring(0, 50),
featureTypes,
userId,
})
if (processingMode === 'async') {
if (!validatedData.s3Uri) {
return NextResponse.json(
{
success: false,
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
},
{ status: 400 }
)
}
const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri)
logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key })
const startTarget = useAnalyzeDocument
? 'Textract.StartDocumentAnalysis'
: 'Textract.StartDocumentTextDetection'
const startBody: Record<string, unknown> = {
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Name: s3Key,
},
},
}
if (useAnalyzeDocument) {
startBody.FeatureTypes = featureTypes
if (
validatedData.queries &&
validatedData.queries.length > 0 &&
featureTypes.includes('QUERIES')
) {
startBody.QueriesConfig = {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
}
}
const startResult = await callTextractAsync(
host,
startTarget,
startBody,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region
)
const jobId = startResult.JobId as string
if (!jobId) {
throw new Error('Failed to start Textract job: No JobId returned')
}
logger.info(`[${requestId}] Async job started`, { jobId })
const textractData = await pollForJobCompletion(
host,
jobId,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region,
useAnalyzeDocument,
requestId
)
logger.info(`[${requestId}] Textract async parse successful`, {
pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
blockCount: (textractData.Blocks as unknown[])?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: textractData.Blocks ?? [],
documentMetadata: {
pages: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0,
},
modelVersion: (textractData.AnalyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion) as string | undefined,
},
})
}
if (!validatedData.filePath) {
return NextResponse.json(
{
success: false,
error: 'File path is required for single-page processing',
},
{ status: 400 }
)
}
let fileUrl = validatedData.filePath
const isInternalFilePath = validatedData.filePath && isInternalFileUrl(validatedData.filePath)
if (isInternalFilePath) {
try {
const storageKey = extractStorageKey(validatedData.filePath)
const context = inferContextFromKey(storageKey)
const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false)
if (!hasAccess) {
logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, {
userId,
key: storageKey,
context,
})
return NextResponse.json(
{
success: false,
error: 'File not found',
},
{ status: 404 }
)
}
fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60)
logger.info(`[${requestId}] Generated presigned URL for ${context} file`)
} catch (error) {
logger.error(`[${requestId}] Failed to generate presigned URL:`, error)
return NextResponse.json(
{
success: false,
error: 'Failed to generate file access URL',
},
{ status: 500 }
)
}
} else if (validatedData.filePath?.startsWith('/')) {
// Reject arbitrary absolute paths that don't contain /api/files/serve/
logger.warn(`[${requestId}] Invalid internal path`, {
userId,
path: validatedData.filePath.substring(0, 50),
})
return NextResponse.json(
{
success: false,
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
},
{ status: 400 }
)
} else {
const urlValidation = validateExternalUrl(fileUrl, 'Document URL')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] SSRF attempt blocked`, {
userId,
url: fileUrl.substring(0, 100),
error: urlValidation.error,
})
return NextResponse.json(
{
success: false,
error: urlValidation.error,
},
{ status: 400 }
)
}
}
const { bytes, contentType } = await fetchDocumentBytes(fileUrl)
// Track if this is a PDF for better error messaging
const isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf')
const uri = '/'
let textractBody: Record<string, unknown>
let amzTarget: string
if (useAnalyzeDocument) {
amzTarget = 'Textract.AnalyzeDocument'
textractBody = {
Document: {
Bytes: bytes,
},
FeatureTypes: featureTypes,
}
if (
validatedData.queries &&
validatedData.queries.length > 0 &&
featureTypes.includes('QUERIES')
) {
textractBody.QueriesConfig = {
Queries: validatedData.queries.map((q) => ({
Text: q.Text,
Alias: q.Alias,
Pages: q.Pages,
})),
}
}
} else {
amzTarget = 'Textract.DetectDocumentText'
textractBody = {
Document: {
Bytes: bytes,
},
}
}
const bodyString = JSON.stringify(textractBody)
const headers = signAwsRequest(
'POST',
host,
uri,
bodyString,
validatedData.accessKeyId,
validatedData.secretAccessKey,
validatedData.region,
'textract',
amzTarget
)
const textractResponse = await fetch(`https://${host}${uri}`, {
method: 'POST',
headers,
body: bodyString,
})
if (!textractResponse.ok) {
const errorText = await textractResponse.text()
logger.error(`[${requestId}] Textract API error:`, errorText)
let errorMessage = `Textract API error: ${textractResponse.statusText}`
let isUnsupportedFormat = false
try {
const errorJson = JSON.parse(errorText)
if (errorJson.Message) {
errorMessage = errorJson.Message
} else if (errorJson.__type) {
errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}`
}
// Check for unsupported document format error
isUnsupportedFormat =
errorJson.__type === 'UnsupportedDocumentException' ||
errorJson.Message?.toLowerCase().includes('unsupported document') ||
errorText.toLowerCase().includes('unsupported document')
} catch {
isUnsupportedFormat = errorText.toLowerCase().includes('unsupported document')
}
// Provide helpful message for unsupported format (likely multi-page PDF)
if (isUnsupportedFormat && isPdf) {
errorMessage =
'This document format is not supported in Single Page mode. If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.'
}
return NextResponse.json(
{
success: false,
error: errorMessage,
},
{ status: textractResponse.status }
)
}
const textractData = await textractResponse.json()
logger.info(`[${requestId}] Textract parse successful`, {
pageCount: textractData.DocumentMetadata?.Pages ?? 0,
blockCount: textractData.Blocks?.length ?? 0,
})
return NextResponse.json({
success: true,
output: {
blocks: textractData.Blocks ?? [],
documentMetadata: {
pages: textractData.DocumentMetadata?.Pages ?? 0,
},
modelVersion:
textractData.AnalyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion ??
undefined,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
logger.error(`[${requestId}] Error in Textract parse:`, error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -234,7 +234,7 @@ function ProgressBar({
{segments.map((segment, index) => (
<div
key={index}
className='absolute h-full'
className='absolute h-full opacity-70'
style={{
left: `${segment.startPercent}%`,
width: `${segment.widthPercent}%`,

View File

@@ -257,7 +257,7 @@ export const LogDetails = memo(function LogDetails({
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-[6px] bg-[#14291B] px-[9px] py-[2px] font-medium text-[#86EFAC] text-[12px]'>
<span className='max-w-full truncate rounded-[6px] bg-[#bbf7d0] px-[9px] py-[2px] font-medium text-[#15803d] text-[12px] dark:bg-[#14291B] dark:text-[#86EFAC]'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
</span>
</div>

View File

@@ -19,6 +19,7 @@ import { DatePicker } from '@/components/emcn/components/date-picker/date-picker
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
@@ -211,12 +212,12 @@ export function LogsToolbar({
}, [level])
const statusOptions: ComboboxOption[] = useMemo(
() => [
{ value: 'error', label: 'Error', icon: getColorIcon('var(--text-error)') },
{ value: 'info', label: 'Info', icon: getColorIcon('var(--terminal-status-info-color)') },
{ value: 'running', label: 'Running', icon: getColorIcon('#22c55e') },
{ value: 'pending', label: 'Pending', icon: getColorIcon('#f59e0b') },
],
() =>
(Object.keys(STATUS_CONFIG) as LogStatus[]).map((status) => ({
value: status,
label: STATUS_CONFIG[status].label,
icon: getColorIcon(STATUS_CONFIG[status].color),
})),
[]
)
@@ -242,12 +243,8 @@ export function LogsToolbar({
const selectedStatusColor = useMemo(() => {
if (selectedStatuses.length !== 1) return null
const status = selectedStatuses[0]
if (status === 'error') return 'var(--text-error)'
if (status === 'info') return 'var(--terminal-status-info-color)'
if (status === 'running') return '#22c55e'
if (status === 'pending') return '#f59e0b'
return null
const status = selectedStatuses[0] as LogStatus
return STATUS_CONFIG[status]?.color ?? null
}, [selectedStatuses])
const workflowOptions: ComboboxOption[] = useMemo(

View File

@@ -5,7 +5,6 @@ import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
/** Column configuration for logs table - shared between header and rows */
export const LOG_COLUMNS = {
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
@@ -16,10 +15,8 @@ export const LOG_COLUMNS = {
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
/** Type-safe column key derived from LOG_COLUMNS */
export type LogColumnKey = keyof typeof LOG_COLUMNS
/** Ordered list of column keys for rendering table headers */
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'date',
'time',
@@ -30,7 +27,6 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'duration',
] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
/**
@@ -53,30 +49,28 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus {
}
}
/** Configuration mapping log status to Badge variant and display label */
const STATUS_VARIANT_MAP: Record<
export const STATUS_CONFIG: Record<
LogStatus,
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string }
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string; color: string }
> = {
error: { variant: 'red', label: 'Error' },
pending: { variant: 'amber', label: 'Pending' },
running: { variant: 'green', label: 'Running' },
cancelled: { variant: 'gray', label: 'Cancelled' },
info: { variant: 'gray', label: 'Info' },
error: { variant: 'red', label: 'Error', color: 'var(--text-error)' },
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' },
running: { variant: 'green', label: 'Running', color: '#22c55e' },
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' },
info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' },
}
/** Configuration mapping core trigger types to Badge color variants */
const TRIGGER_VARIANT_MAP: Record<string, React.ComponentProps<typeof Badge>['variant']> = {
manual: 'gray-secondary',
api: 'blue',
schedule: 'green',
chat: 'purple',
webhook: 'orange',
mcp: 'cyan',
a2a: 'teal',
}
interface StatusBadgeProps {
/** The execution status to display */
status: LogStatus
}
@@ -86,14 +80,13 @@ interface StatusBadgeProps {
* @returns A Badge with dot indicator and status label
*/
export const StatusBadge = React.memo(({ status }: StatusBadgeProps) => {
const config = STATUS_VARIANT_MAP[status]
const config = STATUS_CONFIG[status]
return React.createElement(Badge, { variant: config.variant, dot: true }, config.label)
})
StatusBadge.displayName = 'StatusBadge'
interface TriggerBadgeProps {
/** The trigger type identifier (e.g., 'manual', 'api', or integration block type) */
trigger: string
}

View File

@@ -2,7 +2,7 @@ import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -90,7 +90,7 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
const isStartBlock = isValidStartBlockType(blockType)
const isStartBlock = isInputDefinitionTrigger(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
@@ -142,7 +142,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && !isSubflowBlock && (
{!isStartBlock && !isResponseBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -213,6 +213,29 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{isSubflowBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -8,7 +8,7 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
/**
* Block information for context menu actions
@@ -74,12 +74,16 @@ export function BlockMenu({
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
const hasSingletonBlock = selectedBlocks.some(
(b) =>
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
)
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b))
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
@@ -127,7 +131,7 @@ export function BlockMenu({
<span>Paste</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>V</span>
</PopoverItem>
{!hasStarterBlock && (
{!hasSingletonBlock && (
<PopoverItem
disabled={disableEdit}
onClick={() => {

View File

@@ -26,7 +26,6 @@ export interface CanvasMenuProps {
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
@@ -55,15 +54,12 @@ export function CanvasMenu({
onOpenLogs,
onToggleVariables,
onToggleChat,
onInvite,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
disableEdit = false,
disableAdmin = false,
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -179,22 +175,6 @@ export function CanvasMenu({
>
{isChatOpen ? 'Close Chat' : 'Open Chat'}
</PopoverItem>
{/* Admin action - hidden when invitations are disabled */}
{!isInvitationsDisabled && (
<>
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>
)

View File

@@ -886,17 +886,16 @@ export function Chat() {
onMouseDown={(e) => e.stopPropagation()}
>
{shouldShowConfigureStartInputsButton && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-[2px] font-medium font-sans text-[12px] text-[var(--text-primary)] hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()
handleConfigureStartInputs()
}}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
<span className='whitespace-nowrap'>Add inputs</span>
</div>
)}
<OutputSelect

View File

@@ -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<string, unknown> = {}
@@ -146,10 +142,20 @@ export function OutputSelect({
outputsToProcess = blockConfig?.outputs || {}
}
} else {
const toolOutputs =
blockConfig && typeof operationValue === 'string'
? getToolOutputs(blockConfig, operationValue)
: {}
// Build subBlocks object for tool selector
const rawSubBlockValues =
shouldUseBaseline && baselineWorkflow
? baselineWorkflow.blocks?.[block.id]?.subBlocks
: subBlockValues?.[block.id]
const subBlocks: Record<string, { value: unknown }> = {}
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 || {}
}

View File

@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
import { Check, Clipboard } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
@@ -883,14 +882,13 @@ console.log(data);`
<code className='text-[10px]'>&lt;start.files&gt;</code>.
</p>
{missingFields.any && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-[2px] font-medium font-sans text-[12px] text-[var(--text-primary)] hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
<span className='whitespace-nowrap'>Add inputs</span>
</div>
)}
</div>
</div>

View File

@@ -17,7 +17,7 @@ import { Skeleton } from '@/components/ui'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import {
type FieldConfig,
useCreateForm,
@@ -147,7 +147,7 @@ export function FormDeploy({
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
const startBlock = blocks.find((b) => isInputDefinitionTrigger(b.type))
if (startBlock) {
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')

View File

@@ -14,7 +14,7 @@ import {
Textarea,
} from '@/components/emcn'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -52,7 +52,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
if (blockType && isInputDefinitionTrigger(blockType)) {
return blockId
}
}

View File

@@ -15,7 +15,7 @@ import {
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import {
useAddWorkflowMcpTool,
@@ -107,7 +107,7 @@ export function McpDeploy({
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
if (blockType && isInputDefinitionTrigger(blockType)) {
return blockId
}
}

View File

@@ -8,9 +8,10 @@ import { Button, Combobox } from '@/components/emcn/components'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
const logger = createLogger('FileUpload')
@@ -85,14 +86,47 @@ export function FileUpload({
}
}
/**
* Checks if a file's MIME type matches the accepted types
* Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types
*/
const isFileTypeAccepted = (fileType: string | undefined, accepted: string): boolean => {
if (accepted === '*') return true
if (!fileType) return false
const acceptedList = accepted.split(',').map((t) => t.trim().toLowerCase())
const normalizedFileType = fileType.toLowerCase()
return acceptedList.some((acceptedType) => {
if (acceptedType === normalizedFileType) return true
if (acceptedType.endsWith('/*')) {
const typePrefix = acceptedType.slice(0, -1) // 'image/' from 'image/*'
return normalizedFileType.startsWith(typePrefix)
}
if (acceptedType.startsWith('.')) {
const extension = acceptedType.slice(1).toLowerCase()
const fileExtension = getExtensionFromMimeType(normalizedFileType)
if (fileExtension === extension) return true
return normalizedFileType.endsWith(`/${extension}`)
}
return false
})
}
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
return !existingFiles.some(
const isAlreadySelected = existingFiles.some(
(existing) =>
existing.name === workspaceFile.name ||
existing.path?.includes(workspaceFile.key) ||
existing.key === workspaceFile.key
)
return !isAlreadySelected
})
useEffect(() => {
@@ -421,23 +455,23 @@ export function FileUpload({
return (
<div
key={fileKey}
className='flex items-center justify-between rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
className='relative rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:bg-[var(--surface-5)] dark:hover:bg-[var(--border-1)]'
>
<div className='flex-1 truncate pr-2 text-sm' title={file.name}>
<div className='truncate pr-[24px] text-sm' title={file.name}>
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
<span className='ml-2 text-[var(--text-muted)]'>({formatFileSize(file.size)})</span>
</div>
<Button
type='button'
variant='ghost'
className='h-5 w-5 shrink-0 p-0'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-6 w-6 p-0'
onClick={(e) => handleRemoveFile(file, e)}
disabled={isDeleting}
>
{isDeleting ? (
<div className='h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<X className='h-3.5 w-3.5' />
<X className='h-4 w-4 opacity-50' />
)}
</Button>
</div>
@@ -468,19 +502,30 @@ export function FileUpload({
const comboboxOptions = useMemo(
() => [
{ label: 'Upload New File', value: '__upload_new__' },
...availableWorkspaceFiles.map((file) => ({
label: file.name,
value: file.id,
})),
...availableWorkspaceFiles.map((file) => {
const isAccepted =
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
return {
label: file.name,
value: file.id,
disabled: !isAccepted,
}
}),
],
[availableWorkspaceFiles]
[availableWorkspaceFiles, acceptedTypes]
)
const handleComboboxChange = (value: string) => {
setInputValue(value)
const isValidOption =
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
const selectedFile = availableWorkspaceFiles.find((file) => file.id === value)
const isAcceptedType =
selectedFile &&
(!acceptedTypes ||
acceptedTypes === '*' ||
isFileTypeAccepted(selectedFile.type, acceptedTypes))
const isValidOption = value === '__upload_new__' || isAcceptedType
if (!isValidOption) {
return

View File

@@ -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<Field[]>(blockId, subBlockId)
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
@@ -554,6 +560,18 @@ export function FieldFormat({
</div>
)}
{showDescription && (
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input
value={field.description ?? ''}
onChange={(e) => updateField(field.id, 'description', e.target.value)}
placeholder={descriptionPlaceholder}
disabled={isReadOnly}
/>
</div>
)}
{showValue && (
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
@@ -568,8 +586,10 @@ export function FieldFormat({
)
}
export function InputFormat(props: Omit<FieldFormatProps, 'title' | 'placeholder'>) {
return <FieldFormat {...props} title='Input' placeholder='firstName' />
export function InputFormat(
props: Omit<FieldFormatProps, 'title' | 'placeholder' | 'showDescription'>
) {
return <FieldFormat {...props} title='Input' placeholder='firstName' showDescription={true} />
}
export function ResponseFormat(

View File

@@ -214,17 +214,42 @@ const getOutputTypeForPath = (
outputPath: string,
mergedSubBlocksOverride?: Record<string, any>
): string => {
if (block?.triggerMode && blockConfig?.triggers?.enabled) {
return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true)
}
if (block?.type === 'starter') {
const startWorkflowValue =
mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow')
if (startWorkflowValue === 'chat') {
const chatModeTypes: Record<string, string> = {
input: 'string',
conversationId: 'string',
files: 'files',
}
return chatModeTypes[outputPath] || 'any'
}
const inputFormatValue =
mergedSubBlocksOverride?.inputFormat?.value ?? getSubBlockValue(blockId, 'inputFormat')
if (inputFormatValue && Array.isArray(inputFormatValue)) {
const field = inputFormatValue.find(
(f: { name?: string; type?: string }) => f.name === outputPath
)
if (field?.type) return field.type
}
} else if (blockConfig?.category === 'triggers') {
const blockState = useWorkflowStore.getState().blocks[blockId]
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
return getBlockOutputType(block.type, outputPath, subBlocks)
} else if (blockConfig?.tools?.config?.tool) {
const blockState = useWorkflowStore.getState().blocks[blockId]
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
return getToolOutputType(blockConfig, subBlocks, outputPath)
}
const subBlocks =
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled
if (blockConfig?.tools?.config?.tool) {
const operationValue = getSubBlockValue(blockId, 'operation')
if (operationValue) {
return getToolOutputType(blockConfig, operationValue, outputPath)
}
}
return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode)
}
@@ -1189,11 +1214,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
: allTags
}
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
const toolOutputPaths = operationValue
? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks)
: []
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
if (toolOutputPaths.length > 0) {
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
@@ -1513,7 +1534,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
// For self-reference, only show url and resumeEndpoint (not response format fields)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
@@ -1521,11 +1541,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
}
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
const toolOutputPaths = operationValue
? getToolOutputPaths(blockConfig, operationValue, mergedSubBlocks)
: []
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
if (toolOutputPaths.length > 0) {
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)

View File

@@ -1,6 +1,7 @@
import { memo, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Badge } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
@@ -78,6 +79,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
? currentBlock.is_diff
: undefined
const isEnabled = currentBlock?.enabled ?? true
const isPreview = data?.isPreview || false
// Focus state
@@ -184,14 +186,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
<span
className={cn(
'truncate font-medium text-[16px]',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName}
</span>
</div>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
</div>
{!isPreview && (

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { normalizeName } from '@/executor/constants'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
@@ -27,7 +27,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find((block) => isValidStartBlockType(block.type))
const starterBlock = Object.values(blocks).find((block) => isInputDefinitionTrigger(block.type))
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}

View File

@@ -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<string, any>, 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<string, any> => {
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)
}
}

View File

@@ -180,6 +180,21 @@ function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]
return result
}
/**
* Syncs the panel editor with the current selection state.
* Shows block details when exactly one block is selected, clears otherwise.
*/
function syncPanelWithSelection(selectedIds: string[]) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } = usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
} else if (selectedIds.length > 1 && currentBlockId) {
clearCurrentBlock()
}
}
/** Custom node types for ReactFlow. */
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
@@ -2075,7 +2090,10 @@ const WorkflowContent = React.memo(() => {
...node,
selected: pendingSet.has(node.id),
}))
setDisplayNodes(resolveParentChildSelectionConflicts(withSelection, blocks))
const resolved = resolveParentChildSelectionConflicts(withSelection, blocks)
setDisplayNodes(resolved)
const selectedIds = resolved.filter((node) => node.selected).map((node) => node.id)
syncPanelWithSelection(selectedIds)
return
}
@@ -2175,13 +2193,7 @@ const WorkflowContent = React.memo(() => {
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } =
usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
}
syncPanelWithSelection(selectedIds)
}
},
[blocks]
@@ -3323,15 +3335,12 @@ const WorkflowContent = React.memo(() => {
onOpenLogs={handleContextOpenLogs}
onToggleVariables={handleContextToggleVariables}
onToggleChat={handleContextToggleChat}
onInvite={handleContextInvite}
isVariablesOpen={isVariablesOpen}
isChatOpen={isChatOpen}
hasClipboard={hasClipboard()}
disableEdit={!effectivePermissions.canEdit}
disableAdmin={!effectivePermissions.canAdmin}
canUndo={canUndo}
canRedo={canRedo}
isInvitationsDisabled={isInvitationsDisabled}
/>
</>
)}

View File

@@ -107,14 +107,26 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
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',
@@ -202,6 +214,15 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
],
config: {
tool: (params) => params.operation as string,
params: (params) => {
const { fileUpload, fileReference, ...rest } = params
const hasFileUpload = Array.isArray(fileUpload) ? fileUpload.length > 0 : !!fileUpload
const files = hasFileUpload ? fileUpload : fileReference
return {
...rest,
...(files ? { files } : {}),
}
},
},
},
inputs: {
@@ -233,6 +254,14 @@ export const A2ABlock: BlockConfig<A2AResponse> = {
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',

View File

@@ -5,8 +5,9 @@ import type { ConfluenceResponse } from '@/tools/confluence/types'
export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
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<ConfluenceResponse> = {
status: { type: 'string', description: 'Space status' },
},
}
export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
...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' },
},
}

View File

@@ -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<FileParserOutput> = {
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<FileParserOutput> = {
category: 'tools',
bgColor: '#40916C',
icon: DocumentIcon,
hideFromToolbar: true,
subBlocks: [
{
id: 'inputMethod',
@@ -127,3 +129,92 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
},
},
}
export const FileV2Block: BlockConfig<FileParserOutput> = {
...FileBlock,
type: 'file_v2',
name: 'File',
description: 'Read and parse multiple files',
hideFromToolbar: false,
subBlocks: [
{
id: 'file',
title: 'Files',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'fileInput',
acceptedTypes:
'.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.xml,.rtf',
placeholder: 'Upload files to process',
multiple: true,
mode: 'basic',
maxSize: 100,
},
{
id: 'filePath',
title: 'Files',
type: 'short-input' as SubBlockType,
canonicalParamId: 'fileInput',
placeholder: 'File URL',
mode: 'advanced',
},
],
tools: {
access: ['file_parser_v2'],
config: {
tool: createVersionedToolSelector({
baseToolSelector: () => 'file_parser',
suffix: '_v2',
fallbackToolId: 'file_parser_v2',
}),
params: (params) => {
const fileInput = params.file || params.filePath || params.fileInput
if (!fileInput) {
logger.error('No file input provided')
throw new Error('File is required')
}
if (typeof fileInput === 'string') {
return {
filePath: fileInput.trim(),
fileType: params.fileType || 'auto',
workspaceId: params._context?.workspaceId,
}
}
if (Array.isArray(fileInput) && fileInput.length > 0) {
const filePaths = fileInput.map((file) => file.path)
return {
filePath: filePaths.length === 1 ? filePaths[0] : filePaths,
fileType: params.fileType || 'auto',
}
}
if (fileInput?.path) {
return {
filePath: fileInput.path,
fileType: params.fileType || 'auto',
}
}
logger.error('Invalid file input format')
throw new Error('Invalid file input')
},
},
},
inputs: {
fileInput: { type: 'json', description: 'File input (upload or URL reference)' },
filePath: { type: 'string', description: 'File URL (advanced mode)' },
file: { type: 'json', description: 'Uploaded file data (basic mode)' },
fileType: { type: 'string', description: 'File type' },
},
outputs: {
files: {
type: 'json',
description: 'Array of parsed file objects with content, metadata, and file properties',
},
combinedContent: {
type: 'string',
description: 'All file contents merged into a single text string',
},
},
}

View File

@@ -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<MistralParserOutput> = {
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<MistralParserOutput> = {
bgColor: '#000000',
icon: MistralIcon,
subBlocks: [
// Show input method selection
{
id: 'inputMethod',
title: 'Select Input Method',
@@ -23,8 +24,6 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
{ 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<MistralParserOutput> = {
value: 'url',
},
},
// File upload option
{
id: 'fileUpload',
title: 'Upload PDF',
@@ -46,9 +43,8 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
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<MistralParserOutput> = {
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<MistralParserOutput> = {
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<string, unknown> = {
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<MistralParserOutput> = {
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<MistralParserOutput> = {
if (pagesArray && pagesArray.length === 0) {
pagesArray = undefined
}
} catch (error: any) {
throw new Error(`Page number format error: ${error.message}`)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Page number format error: ${errorMessage}`)
}
}
// Add optional parameters
if (pagesArray && pagesArray.length > 0) {
parameters.pages = pagesArray
}
@@ -173,3 +142,129 @@ export const MistralParseBlock: BlockConfig<MistralParserOutput> = {
metadata: { type: 'json', description: 'Processing metadata' },
},
}
export const MistralParseV2Block: BlockConfig<MistralParserOutput> = {
...MistralParseBlock,
type: 'mistral_parse_v2',
name: 'Mistral Parser',
description: 'Extract text from PDF documents',
hideFromToolbar: false,
subBlocks: [
{
id: 'fileUpload',
title: 'PDF Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'application/pdf',
placeholder: 'Upload a PDF document',
mode: 'basic',
maxSize: 50,
},
{
id: 'filePath',
title: 'PDF Document',
type: 'short-input' as SubBlockType,
canonicalParamId: 'document',
placeholder: 'Document URL',
mode: 'advanced',
},
{
id: 'resultType',
title: 'Output Format',
type: 'dropdown',
options: [
{ id: 'markdown', label: 'Markdown' },
{ id: 'text', label: 'Plain Text' },
{ id: 'json', label: 'JSON' },
],
},
{
id: 'pages',
title: 'Specific Pages',
type: 'short-input',
placeholder: 'e.g. 0,1,2 (leave empty for all pages)',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input' as SubBlockType,
placeholder: 'Enter your Mistral API key',
password: true,
required: true,
},
],
tools: {
access: ['mistral_parser_v2'],
config: {
tool: createVersionedToolSelector({
baseToolSelector: () => 'mistral_parser',
suffix: '_v2',
fallbackToolId: 'mistral_parser_v2',
}),
params: (params) => {
if (!params || !params.apiKey || params.apiKey.trim() === '') {
throw new Error('Mistral API key is required')
}
const parameters: Record<string, unknown> = {
apiKey: params.apiKey.trim(),
resultType: params.resultType || 'markdown',
}
const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) {
throw new Error('PDF document is required')
}
if (typeof documentInput === 'object') {
parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim()
}
let pagesArray: number[] | undefined
if (params.pages && params.pages.trim() !== '') {
try {
pagesArray = params.pages
.split(',')
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0)
.map((p: string) => {
const num = Number.parseInt(p, 10)
if (Number.isNaN(num) || num < 0) {
throw new Error(`Invalid page number: ${p}`)
}
return num
})
if (pagesArray && pagesArray.length === 0) {
pagesArray = undefined
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Page number format error: ${errorMessage}`)
}
}
if (pagesArray && pagesArray.length > 0) {
parameters.pages = pagesArray
}
return parameters
},
},
},
inputs: {
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
filePath: { type: 'string', description: 'PDF document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded PDF file (basic mode)' },
apiKey: { type: 'string', description: 'Mistral API key' },
resultType: { type: 'string', description: 'Output format type' },
pages: { type: 'string', description: 'Page selection' },
},
outputs: {
pages: { type: 'array', description: 'Array of page objects from Mistral OCR' },
model: { type: 'string', description: 'Mistral OCR model identifier' },
usage_info: { type: 'json', description: 'Usage statistics from the API' },
document_annotation: { type: 'string', description: 'Structured annotation data' },
},
}

View File

@@ -15,34 +15,22 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
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: 'fileUpload',
title: 'Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx',
placeholder: 'Upload a document',
mode: 'basic',
maxSize: 50,
},
{
id: 'filePath',
title: 'Document URL',
title: 'Document',
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',
type: 'file-upload' as SubBlockType,
acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx',
condition: {
field: 'inputMethod',
value: 'upload',
},
maxSize: 50,
canonicalParamId: 'document',
placeholder: 'Document URL',
mode: 'advanced',
},
{
id: 'pages',
@@ -84,17 +72,14 @@ export const PulseBlock: BlockConfig<PulseParserOutput> = {
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<PulseParserOutput> = {
},
},
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: {

View File

@@ -14,34 +14,22 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
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: '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 URL',
title: 'PDF Document',
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',
type: 'file-upload' as SubBlockType,
acceptedTypes: 'application/pdf',
condition: {
field: 'inputMethod',
value: 'upload',
},
maxSize: 50,
canonicalParamId: 'document',
placeholder: 'Document URL',
mode: 'advanced',
},
{
id: 'pages',
@@ -80,17 +68,15 @@ export const ReductoBlock: BlockConfig<ReductoParserOutput> = {
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<ReductoParserOutput> = {
},
},
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' },

View File

@@ -414,6 +414,10 @@ export const S3Block: BlockConfig<S3Response> = {
},
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' },

View File

@@ -0,0 +1,191 @@
import { TextractIcon } from '@/components/icons'
import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types'
import type { TextractParserOutput } from '@/tools/textract/types'
export const TextractBlock: BlockConfig<TextractParserOutput> = {
type: 'textract',
name: 'AWS Textract',
description: 'Extract text, tables, and forms from documents',
authMode: AuthMode.ApiKey,
longDescription: `Integrate AWS Textract into your workflow to extract text, tables, forms, and key-value pairs from documents. Single-page mode supports JPEG, PNG, and single-page PDF. Multi-page mode supports multi-page PDF and TIFF.`,
docsLink: 'https://docs.sim.ai/tools/textract',
category: 'tools',
bgColor: 'linear-gradient(135deg, #055F4E 0%, #56C0A7 100%)',
icon: TextractIcon,
subBlocks: [
{
id: 'processingMode',
title: 'Processing Mode',
type: 'dropdown' as SubBlockType,
options: [
{ id: 'sync', label: 'Single Page (JPEG, PNG, 1-page PDF)' },
{ id: 'async', label: 'Multi-Page (PDF, TIFF via S3)' },
],
tooltip:
'Single Page uses synchronous API for JPEG, PNG, or single-page PDF. Multi-Page uses async API for multi-page PDF/TIFF stored in S3.',
},
{
id: 'fileUpload',
title: 'Document',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'document',
acceptedTypes: 'image/jpeg,image/png,application/pdf',
placeholder: 'Upload JPEG, PNG, or single-page PDF (max 10MB)',
condition: {
field: 'processingMode',
value: 'async',
not: true,
},
mode: 'basic',
maxSize: 10,
},
{
id: 'filePath',
title: 'Document',
type: 'short-input' as SubBlockType,
canonicalParamId: 'document',
placeholder: 'URL to JPEG, PNG, or single-page PDF',
condition: {
field: 'processingMode',
value: 'async',
not: true,
},
mode: 'advanced',
},
{
id: 's3Uri',
title: 'S3 URI',
type: 'short-input' as SubBlockType,
placeholder: 's3://bucket-name/path/to/document.pdf',
condition: {
field: 'processingMode',
value: 'async',
},
},
{
id: 'region',
title: 'AWS Region',
type: 'short-input' as SubBlockType,
placeholder: 'e.g., us-east-1',
required: true,
},
{
id: 'accessKeyId',
title: 'AWS Access Key ID',
type: 'short-input' as SubBlockType,
placeholder: 'Enter your AWS Access Key ID',
password: true,
required: true,
},
{
id: 'secretAccessKey',
title: 'AWS Secret Access Key',
type: 'short-input' as SubBlockType,
placeholder: 'Enter your AWS Secret Access Key',
password: true,
required: true,
},
{
id: 'extractTables',
title: 'Extract Tables',
type: 'switch' as SubBlockType,
},
{
id: 'extractForms',
title: 'Extract Forms (Key-Value Pairs)',
type: 'switch' as SubBlockType,
},
{
id: 'detectSignatures',
title: 'Detect Signatures',
type: 'switch' as SubBlockType,
},
{
id: 'analyzeLayout',
title: 'Analyze Document Layout',
type: 'switch' as SubBlockType,
},
],
tools: {
access: ['textract_parser'],
config: {
tool: () => 'textract_parser',
params: (params) => {
if (!params.accessKeyId || params.accessKeyId.trim() === '') {
throw new Error('AWS Access Key ID is required')
}
if (!params.secretAccessKey || params.secretAccessKey.trim() === '') {
throw new Error('AWS Secret Access Key is required')
}
if (!params.region || params.region.trim() === '') {
throw new Error('AWS Region is required')
}
const processingMode = params.processingMode || 'sync'
const parameters: Record<string, unknown> = {
accessKeyId: params.accessKeyId.trim(),
secretAccessKey: params.secretAccessKey.trim(),
region: params.region.trim(),
processingMode,
}
if (processingMode === 'async') {
if (!params.s3Uri || params.s3Uri.trim() === '') {
throw new Error('S3 URI is required for multi-page processing')
}
parameters.s3Uri = params.s3Uri.trim()
} else {
const documentInput = params.fileUpload || params.filePath || params.document
if (!documentInput) {
throw new Error('Document is required')
}
if (typeof documentInput === 'object') {
parameters.fileUpload = documentInput
} else if (typeof documentInput === 'string') {
parameters.filePath = documentInput.trim()
}
}
const featureTypes: string[] = []
if (params.extractTables) featureTypes.push('TABLES')
if (params.extractForms) featureTypes.push('FORMS')
if (params.detectSignatures) featureTypes.push('SIGNATURES')
if (params.analyzeLayout) featureTypes.push('LAYOUT')
if (featureTypes.length > 0) {
parameters.featureTypes = featureTypes
}
return parameters
},
},
},
inputs: {
processingMode: { type: 'string', description: 'Document type: single-page or multi-page' },
document: { type: 'json', description: 'Document input (file upload or URL reference)' },
filePath: { type: 'string', description: 'Document URL (advanced mode)' },
fileUpload: { type: 'json', description: 'Uploaded document file (basic mode)' },
s3Uri: { type: 'string', description: 'S3 URI for multi-page processing (s3://bucket/key)' },
extractTables: { type: 'boolean', description: 'Extract tables from document' },
extractForms: { type: 'boolean', description: 'Extract form key-value pairs' },
detectSignatures: { type: 'boolean', description: 'Detect signatures' },
analyzeLayout: { type: 'boolean', description: 'Analyze document layout' },
region: { type: 'string', description: 'AWS region' },
accessKeyId: { type: 'string', description: 'AWS Access Key ID' },
secretAccessKey: { type: 'string', description: 'AWS Secret Access Key' },
},
outputs: {
blocks: {
type: 'json',
description: 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)',
},
documentMetadata: {
type: 'json',
description: 'Document metadata containing pages count',
},
modelVersion: {
type: 'string',
description: 'Version of the Textract model used for processing',
},
},
}

View File

@@ -4,8 +4,9 @@ import type { VideoBlockResponse } from '@/tools/video/types'
export const VideoGeneratorBlock: BlockConfig<VideoBlockResponse> = {
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<VideoBlockResponse> = {
model: { type: 'string', description: 'Model used' },
},
}
export const VideoGeneratorV2Block: BlockConfig<VideoBlockResponse> = {
...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)',
},
},
}

View File

@@ -14,7 +14,7 @@ import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger'
import { CirclebackBlock } from '@/blocks/blocks/circleback'
import { ClayBlock } from '@/blocks/blocks/clay'
import { ConditionBlock } from '@/blocks/blocks/condition'
import { ConfluenceBlock } from '@/blocks/blocks/confluence'
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor'
import { DatadogBlock } from '@/blocks/blocks/datadog'
import { DiscordBlock } from '@/blocks/blocks/discord'
@@ -25,7 +25,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { ExaBlock } from '@/blocks/blocks/exa'
import { FileBlock } from '@/blocks/blocks/file'
import { FileBlock, FileV2Block } from '@/blocks/blocks/file'
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
import { FirefliesBlock } from '@/blocks/blocks/fireflies'
import { FunctionBlock } from '@/blocks/blocks/function'
@@ -74,7 +74,7 @@ import { MemoryBlock } from '@/blocks/blocks/memory'
import { MicrosoftExcelBlock, MicrosoftExcelV2Block } from '@/blocks/blocks/microsoft_excel'
import { MicrosoftPlannerBlock } from '@/blocks/blocks/microsoft_planner'
import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams'
import { MistralParseBlock } from '@/blocks/blocks/mistral_parse'
import { MistralParseBlock, MistralParseV2Block } from '@/blocks/blocks/mistral_parse'
import { MongoDBBlock } from '@/blocks/blocks/mongodb'
import { MySQLBlock } from '@/blocks/blocks/mysql'
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
@@ -123,6 +123,7 @@ import { SttBlock } from '@/blocks/blocks/stt'
import { SupabaseBlock } from '@/blocks/blocks/supabase'
import { TavilyBlock } from '@/blocks/blocks/tavily'
import { TelegramBlock } from '@/blocks/blocks/telegram'
import { TextractBlock } from '@/blocks/blocks/textract'
import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
import { TranslateBlock } from '@/blocks/blocks/translate'
@@ -132,7 +133,7 @@ import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
import { TypeformBlock } from '@/blocks/blocks/typeform'
import { VariablesBlock } from '@/blocks/blocks/variables'
import { VideoGeneratorBlock } from '@/blocks/blocks/video_generator'
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
import { VisionBlock } from '@/blocks/blocks/vision'
import { WaitBlock } from '@/blocks/blocks/wait'
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
@@ -169,6 +170,7 @@ export const registry: Record<string, BlockConfig> = {
clay: ClayBlock,
condition: ConditionBlock,
confluence: ConfluenceBlock,
confluence_v2: ConfluenceV2Block,
cursor: CursorBlock,
cursor_v2: CursorV2Block,
datadog: DatadogBlock,
@@ -181,6 +183,7 @@ export const registry: Record<string, BlockConfig> = {
evaluator: EvaluatorBlock,
exa: ExaBlock,
file: FileBlock,
file_v2: FileV2Block,
firecrawl: FirecrawlBlock,
fireflies: FirefliesBlock,
function: FunctionBlock,
@@ -236,6 +239,7 @@ export const registry: Record<string, BlockConfig> = {
microsoft_planner: MicrosoftPlannerBlock,
microsoft_teams: MicrosoftTeamsBlock,
mistral_parse: MistralParseBlock,
mistral_parse_v2: MistralParseV2Block,
mongodb: MongoDBBlock,
mysql: MySQLBlock,
neo4j: Neo4jBlock,
@@ -286,6 +290,7 @@ export const registry: Record<string, BlockConfig> = {
supabase: SupabaseBlock,
tavily: TavilyBlock,
telegram: TelegramBlock,
textract: TextractBlock,
thinking: ThinkingBlock,
tinybird: TinybirdBlock,
translate: TranslateBlock,
@@ -296,6 +301,7 @@ export const registry: Record<string, BlockConfig> = {
typeform: TypeformBlock,
variables: VariablesBlock,
video_generator: VideoGeneratorBlock,
video_generator_v2: VideoGeneratorV2Block,
vision: VisionBlock,
wait: WaitBlock,
wealthbox: WealthboxBlock,

View File

@@ -25,7 +25,7 @@ const badgeVariants = cva(
orange: `${STATUS_BASE} bg-[#fed7aa] text-[#c2410c] dark:bg-[rgba(249,115,22,0.2)] dark:text-[#fdba74]`,
amber: `${STATUS_BASE} bg-[#fde68a] text-[#a16207] dark:bg-[rgba(245,158,11,0.2)] dark:text-[#fcd34d]`,
teal: `${STATUS_BASE} bg-[#99f6e4] text-[#0f766e] dark:bg-[rgba(20,184,166,0.2)] dark:text-[#5eead4]`,
cyan: `${STATUS_BASE} bg-[#a5f3fc] text-[#0e7490] dark:bg-[rgba(14,165,233,0.2)] dark:text-[#7dd3fc]`,
cyan: `${STATUS_BASE} bg-[var(--surface-4)] text-[#0891b2] dark:bg-[rgba(14,165,233,0.2)] dark:text-[#7dd3fc]`,
'gray-secondary': `${STATUS_BASE} bg-[var(--surface-4)] text-[var(--text-secondary)]`,
},
size: {

View File

@@ -4093,6 +4093,23 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='10 14 60 52'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
>
<path
d='M22.0624102,50 C24.3763895,53.603 28.4103535,56 33.0003125,56 C40.1672485,56 45.9991964,50.168 45.9991964,43 C45.9991964,35.832 40.1672485,30 33.0003125,30 C27.6033607,30 22.9664021,33.307 21.0024196,38 L23.2143999,38 C25.0393836,34.444 28.7363506,32 33.0003125,32 C39.0652583,32 43.9992143,36.935 43.9992143,43 C43.9992143,49.065 39.0652583,54 33.0003125,54 C29.5913429,54 26.5413702,52.441 24.5213882,50 L22.0624102,50 Z M37.0002768,45 L37.0002768,43 L41.9992321,43 C41.9992321,38.038 37.9622682,34 33.0003125,34 C28.0373568,34 23.9993929,38.038 23.9993929,43 L28.9993482,43 L28.9993482,45 L24.2313908,45 C25.1443826,49.002 28.7253507,52 33.0003125,52 C35.1362934,52 37.0992759,51.249 38.6442621,50 L34.0003036,50 L34.0003036,48 L40.4782457,48 C41.0812403,47.102 41.5202364,46.087 41.7682342,45 L37.0002768,45 Z M21.0024196,48 L23.2143999,48 C22.4434068,46.498 22.0004107,44.801 22.0004107,43 C22.0004107,41.959 22.1554093,40.955 22.4264069,40 L20.3634253,40 C20.1344274,40.965 19.9994286,41.966 19.9994286,43 C19.9994286,44.771 20.3584254,46.46 21.0024196,48 L21.0024196,48 Z M19.7434309,50 L17.0004554,50 L17.0004554,48 L18.8744386,48 C18.5344417,47.04 18.2894438,46.038 18.1494451,45 L15.4144695,45 L16.707458,46.293 L15.2924706,47.707 L12.2924974,44.707 C11.9025009,44.316 11.9025009,43.684 12.2924974,43.293 L15.2924706,40.293 L16.707458,41.707 L15.4144695,43 L18.0004464,43 C18.0004464,41.973 18.1044455,40.97 18.3024437,40 L17.0004554,40 L17.0004554,38 L18.8744386,38 C20.9404202,32.184 26.4833707,28 33.0003125,28 C37.427273,28 41.4002375,29.939 44.148213,33 L59.0000804,33 L59.0000804,35 L45.6661994,35 C47.1351863,37.318 47.9991786,40.058 47.9991786,43 L59.0000804,43 L59.0000804,45 L47.8501799,45 C46.8681887,52.327 40.5912447,58 33.0003125,58 C27.2563638,58 22.2624084,54.752 19.7434309,50 L19.7434309,50 Z M37.0002768,39 C37.0002768,38.448 36.5522808,38 36.0002857,38 L29.9993482,38 C29.4473442,38 28.9993482,38.448 28.9993482,39 L28.9993482,41 L31.0003304,41 L31.0003304,40 L32.0003214,40 L32.0003214,43 L31.0003304,43 L31.0003304,45 L35.0002946,45 L35.0002946,43 L34.0003036,43 L34.0003036,40 L35.0002946,40 L35.0002946,41 L37.0002768,41 L37.0002768,39 Z M49.0001696,40 L59.0000804,40 L59.0000804,38 L49.0001696,38 L49.0001696,40 Z M49.0001696,50 L59.0000804,50 L59.0000804,48 L49.0001696,48 L49.0001696,50 Z M57.0000982,27 L60.5850662,27 L57.0000982,23.414 L57.0000982,27 Z M63.7070383,27.293 C63.8940367,27.48 64.0000357,27.735 64.0000357,28 L64.0000357,63 C64.0000357,63.552 63.5520397,64 63.0000446,64 L32.0003304,64 C31.4473264,64 31.0003304,63.552 31.0003304,63 L31.0003304,59 L33.0003125,59 L33.0003125,62 L62.0000536,62 L62.0000536,29 L56.0001071,29 C55.4471121,29 55.0001161,28.552 55.0001161,28 L55.0001161,22 L33.0003125,22 L33.0003125,27 L31.0003304,27 L31.0003304,21 C31.0003304,20.448 31.4473264,20 32.0003304,20 L56.0001071,20 C56.2651048,20 56.5191025,20.105 56.7071008,20.293 L63.7070383,27.293 Z M68,24.166 L68,61 C68,61.552 67.552004,62 67.0000089,62 L65.0000268,62 L65.0000268,60 L66.0000179,60 L66.0000179,24.612 L58.6170838,18 L36.0002857,18 L36.0002857,19 L34.0003036,19 L34.0003036,17 C34.0003036,16.448 34.4472996,16 35.0003036,16 L59.0000804,16 C59.2460782,16 59.483076,16.091 59.6660744,16.255 L67.666003,23.42 C67.8780011,23.61 68,23.881 68,24.166 L68,24.166 Z'
fill='currentColor'
/>
</svg>
)
}
export function McpIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What's New at Sim</title>
</head>
<body style="margin:0;padding:0;background-color:#ffffff;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" style="background-color:#ffffff;">
<tr>
<td align="center" style="padding:0 16px;">
<table width="600" cellspacing="0" cellpadding="0" border="0" role="presentation" style="max-width:600px;width:100%;">
<!-- Logo -->
<tr>
<td align="center" style="padding-top:32px;padding-bottom:16px;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/logo.png" width="79" alt="Sim Logo" style="display:block;width:79px;" border="0">
</a>
</td>
</tr>
<!-- Intro Paragraph -->
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:20px;padding-top:8px;text-align:left;">
<p style="margin:0;">We're excited to introduce <strong>Sim v0.5</strong>, the next evolution of our agent workflow platform—built for teams who need <strong>context-aware AI assistance</strong>, <strong>seamless tool deployment</strong>, and <strong>full execution observability</strong> in production.</p>
</td>
</tr>
<!-- CTA Button -->
<tr>
<td align="center" style="padding-bottom:25px;padding-top:5px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="background-color:#32bd7e;border-radius:5px;">
<a href="https://sim.ai" style="display:inline-block;font-weight:500;font-size:14px;padding:7px 16px;text-decoration:none;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;" target="_blank">Try Sim</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Header Text -->
<tr>
<td align="left" valign="top" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;word-break:break-word;padding-top:10px;padding-bottom:8px;text-align:left;">
<h2 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:28px;font-weight:600;word-break:break-word;">Get things done <em>faster</em></h2>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:30px;text-align:left;">
<p style="margin:0;">You're focused on the most critical tasks, let agents handle the rest.</p>
</td>
</tr>
<!-- FEATURE 1: Copilot -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:10px;padding-bottom:12px;text-align:left;">
<strong>Copilot</strong>
</td>
</tr>
<tr>
<td align="center" style="border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/copilot.jpg" width="570" alt="Sim Copilot" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Copilot is a context-aware assistant embedded directly into the Sim editor. It has first-class access to your workflows, blocks, execution logs, and documentation—helping you build, debug, and optimize without leaving the canvas. Ask it to explain a workflow, propose changes, or execute actions. All writes are gated behind explicit approval, so you stay in control.</p>
</td>
</tr>
<!-- FEATURE 2: MCP Deployment -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:20px;padding-bottom:12px;text-align:left;">
<strong>MCP Deployment</strong>
</td>
</tr>
<tr>
<td align="center">
<a href="https://sim.ai" style="text-decoration:none;display:block;" target="_blank">
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" style="background-color:#181C1E;border-radius:8px;">
<tr>
<td align="center" style="padding:40px 30px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="padding-bottom:16px;">
<img src="https://sim.ai/email/broadcast/v0.5/mcp.png" width="48" height="48" alt="MCP" style="display:block;" border="0">
</td>
</tr>
<tr>
<td align="center">
<p style="margin:0;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:20px;font-weight:500;">Expose workflows as tools for any agent</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Deploy any workflow as an MCP tool and expose it to any MCP-compatible agent—Claude Desktop, Cursor, or your own applications. Define custom tool names, descriptions, and parameter schemas. Your workflows become reusable building blocks that other agents can invoke, turning Sim into the backend for your agentic systems.</p>
</td>
</tr>
<!-- FEATURE 3: Logs & Dashboard -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:20px;padding-bottom:12px;text-align:left;">
<strong>Logs & Dashboard</strong>
</td>
</tr>
<tr>
<td align="center" style="border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/dashboard.jpg" width="570" alt="Logs & Dashboard" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Full observability for every execution. Trace spans show you exactly what happened at each step—durations, token usage, cost breakdowns by model, and error details when things go wrong. Filter by time range, trigger type, or workflow. Live mode auto-refreshes every 5 seconds. Restore any previous workflow state with execution snapshots.</p>
</td>
</tr>
<!-- FEATURE 4: Collaboration -->
<tr>
<td align="left" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;padding-top:20px;padding-bottom:12px;text-align:left;">
<strong>Realtime Collaboration</strong>
</td>
</tr>
<tr>
<td align="center" style="border-radius:8px;font-size:0;line-height:0;">
<a href="https://sim.ai" style="color:#000;text-decoration:none;" target="_blank">
<img src="https://sim.ai/email/broadcast/v0.5/collaboration.jpg" width="570" alt="Realtime Collaboration" style="display:block;width:100%;border-radius:8px;" border="0">
</a>
</td>
</tr>
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:25px;padding-top:12px;text-align:left;">
<p style="margin:0;">Invite teammates to your workspace and build workflows together in realtime—like Figma for AI agents. See cursors move, blocks added, and configurations updated as they happen. The operation queue ensures reliability even under poor network conditions. Undo/redo works per-user, so your changes stay separate from your collaborators'.</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td align="center" style="padding-bottom:10px;padding-top:10px;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" height="1" style="border-top:1px solid #ededed;">
<tr>
<td height="0" style="font-size:0;line-height:0;">&#173;</td>
</tr>
</table>
</td>
</tr>
<!-- Final CTA -->
<tr>
<td align="left" style="color:#404040;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;line-height:24px;word-break:break-word;padding-bottom:15px;padding-top:15px;text-align:left;">
<p style="margin:0;">Ready to build? These features are available now in Sim.</p>
</td>
</tr>
<tr>
<td align="center" style="padding-bottom:30px;padding-top:15px;">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tr>
<td align="center" style="background-color:#32bd7e;border-radius:5px;">
<a href="https://sim.ai" style="display:inline-block;font-weight:500;font-size:14px;padding:7px 16px;text-decoration:none;color:#ffffff;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;" target="_blank">Get Started</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer Divider -->
<tr>
<td align="center" style="padding-top:20px;padding-bottom:20px;">
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" height="1" style="border-top:1px solid #ededed;">
<tr>
<td height="0" style="font-size:0;line-height:0;">&#173;</td>
</tr>
</table>
</td>
</tr>
<!-- Social links row -->
<tr>
<td align="center">
<table cellspacing="0" cellpadding="0" border="0" role="presentation">
<tbody>
<tr>
<td align="center" style="padding:0 8px 0 0;">
<a href="https://sim.ai/x" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/x-icon.png" width="20" height="20" alt="X" style="display:block;" border="0">
</a>
</td>
<td align="center" style="padding:0 8px;">
<a href="https://sim.ai/discord" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/discord-icon.png" width="20" height="20" alt="Discord" style="display:block;" border="0">
</a>
</td>
<td align="center" style="padding:0 8px;">
<a href="https://sim.ai/github" rel="noopener noreferrer" target="_blank">
<img src="https://sim.ai/static/github-icon.png" width="20" height="20" alt="GitHub" style="display:block;" border="0">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="16" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Address row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
Sim, 80 Langton St, San Francisco, CA 94103, USA
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="8" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Contact row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
Questions? <a href="mailto:support@sim.ai" style="color:#737373;text-decoration:underline;font-weight:normal;">support@sim.ai</a>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="8" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Links row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
<a href="https://sim.ai/privacy" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Privacy Policy</a>
&nbsp;&nbsp;
<a href="https://sim.ai/terms" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Terms of Service</a>
&nbsp;&nbsp;
<a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Unsubscribe</a>
</td>
</tr>
<!-- Spacer -->
<tr>
<td height="16" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Copyright row -->
<tr>
<td align="center" style="font-size:12px;line-height:20px;color:#737373;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;margin:0;">
© 2026 Sim, All Rights Reserved
</td>
</tr>
<!-- Bottom spacer -->
<tr>
<td height="32" style="font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -10,7 +10,7 @@ import {
GetBlockConfigInput,
GetBlockConfigResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockConfigArgs {
blockType: string
@@ -40,8 +40,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
},
getDynamicText: (params, state) => {
if (params?.blockType && typeof params.blockType === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(params.blockType)
const blockConfig = getLatestBlock(params.blockType)
const blockName = (blockConfig?.name ?? params.blockType.replace(/_/g, ' ')).toLowerCase()
const opSuffix = params.operation ? ` (${params.operation})` : ''

View File

@@ -10,7 +10,7 @@ import {
GetBlockOptionsInput,
GetBlockOptionsResult,
} from '@/lib/copilot/tools/shared/schemas'
import { getBlock } from '@/blocks/registry'
import { getLatestBlock } from '@/blocks/registry'
interface GetBlockOptionsArgs {
blockId: string
@@ -43,8 +43,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
(params as any)?.block_id ||
(params as any)?.block_type
if (typeof blockId === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(blockId)
const blockConfig = getLatestBlock(blockId)
const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase()
switch (state) {

View File

@@ -17,7 +17,7 @@ import {
type GetBlockUpstreamReferencesResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
@@ -141,7 +141,7 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find((b) => isValidStartBlockType(b.type))
const starterBlock = Object.values(blocks).find((b) => isInputDefinitionTrigger(b.type))
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}

View File

@@ -5,7 +5,7 @@ import {
GetBlockConfigResult,
type GetBlockConfigResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
@@ -452,9 +452,12 @@ export const getBlockConfigServerTool: BaseServerTool<
const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
const outputs = extractOutputs(blockConfig, operation, trigger)
const latestBlock = getLatestBlock(blockType)
const displayName = latestBlock?.name ?? blockConfig.name
const result = {
blockType,
blockName: blockConfig.name,
blockName: displayName,
operation,
trigger,
inputs,

View File

@@ -5,7 +5,7 @@ import {
GetBlockOptionsResult,
type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry'
@@ -113,9 +113,12 @@ export const getBlockOptionsServerTool: BaseServerTool<
}
}
const latestBlock = getLatestBlock(blockId)
const displayName = latestBlock?.name ?? blockConfig.name
const result = {
blockId,
blockName: blockConfig.name,
blockName: displayName,
operations,
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
validateAirtableId,
validateAlphanumericId,
validateAwsRegion,
validateEnum,
validateExternalUrl,
validateFileExtension,
@@ -16,6 +17,7 @@ import {
validateNumericId,
validatePathSegment,
validateProxyUrl,
validateS3BucketName,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation'
import { sanitizeForLogging } from '@/lib/core/security/redaction'
@@ -1169,3 +1171,282 @@ describe('validateAirtableId', () => {
})
})
})
describe('validateAwsRegion', () => {
describe('valid standard regions', () => {
it.concurrent('should accept us-east-1', () => {
const result = validateAwsRegion('us-east-1')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('us-east-1')
})
it.concurrent('should accept us-west-2', () => {
const result = validateAwsRegion('us-west-2')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept eu-west-1', () => {
const result = validateAwsRegion('eu-west-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept eu-central-1', () => {
const result = validateAwsRegion('eu-central-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept ap-southeast-1', () => {
const result = validateAwsRegion('ap-southeast-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept ap-northeast-1', () => {
const result = validateAwsRegion('ap-northeast-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept sa-east-1', () => {
const result = validateAwsRegion('sa-east-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept me-south-1', () => {
const result = validateAwsRegion('me-south-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept af-south-1', () => {
const result = validateAwsRegion('af-south-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept ca-central-1', () => {
const result = validateAwsRegion('ca-central-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept il-central-1', () => {
const result = validateAwsRegion('il-central-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept regions with double-digit numbers', () => {
const result = validateAwsRegion('ap-northeast-12')
expect(result.isValid).toBe(true)
})
})
describe('valid GovCloud regions', () => {
it.concurrent('should accept us-gov-west-1', () => {
const result = validateAwsRegion('us-gov-west-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept us-gov-east-1', () => {
const result = validateAwsRegion('us-gov-east-1')
expect(result.isValid).toBe(true)
})
})
describe('valid China regions', () => {
it.concurrent('should accept cn-north-1', () => {
const result = validateAwsRegion('cn-north-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept cn-northwest-1', () => {
const result = validateAwsRegion('cn-northwest-1')
expect(result.isValid).toBe(true)
})
})
describe('valid ISO regions', () => {
it.concurrent('should accept us-iso-east-1', () => {
const result = validateAwsRegion('us-iso-east-1')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept us-isob-east-1', () => {
const result = validateAwsRegion('us-isob-east-1')
expect(result.isValid).toBe(true)
})
})
describe('invalid regions', () => {
it.concurrent('should reject null', () => {
const result = validateAwsRegion(null)
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
it.concurrent('should reject empty string', () => {
const result = validateAwsRegion('')
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
it.concurrent('should reject uppercase regions', () => {
const result = validateAwsRegion('US-EAST-1')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid format - missing number', () => {
const result = validateAwsRegion('us-east')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid format - wrong separators', () => {
const result = validateAwsRegion('us_east_1')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid format - too many parts', () => {
const result = validateAwsRegion('us-east-1-extra')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject path traversal attempts', () => {
const result = validateAwsRegion('../etc/passwd')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject arbitrary strings', () => {
const result = validateAwsRegion('not-a-region')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid prefix', () => {
const result = validateAwsRegion('xx-east-1')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject invalid direction', () => {
const result = validateAwsRegion('us-middle-1')
expect(result.isValid).toBe(false)
})
it.concurrent('should use custom param name in errors', () => {
const result = validateAwsRegion('', 'awsRegion')
expect(result.error).toContain('awsRegion')
})
})
})
describe('validateS3BucketName', () => {
describe('valid bucket names', () => {
it.concurrent('should accept simple bucket name', () => {
const result = validateS3BucketName('my-bucket')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('my-bucket')
})
it.concurrent('should accept bucket name with numbers', () => {
const result = validateS3BucketName('bucket123')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept bucket name with periods', () => {
const result = validateS3BucketName('my.bucket.name')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept 3 character bucket name', () => {
const result = validateS3BucketName('abc')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept 63 character bucket name', () => {
const result = validateS3BucketName('a'.repeat(63))
expect(result.isValid).toBe(true)
})
it.concurrent('should accept minimum valid bucket name (3 chars)', () => {
const result = validateS3BucketName('a1b')
expect(result.isValid).toBe(true)
})
})
describe('invalid bucket names - null/empty', () => {
it.concurrent('should reject null', () => {
const result = validateS3BucketName(null)
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
it.concurrent('should reject empty string', () => {
const result = validateS3BucketName('')
expect(result.isValid).toBe(false)
expect(result.error).toContain('required')
})
})
describe('invalid bucket names - length', () => {
it.concurrent('should reject 2 character bucket name', () => {
const result = validateS3BucketName('ab')
expect(result.isValid).toBe(false)
expect(result.error).toContain('between 3 and 63')
})
it.concurrent('should reject 64 character bucket name', () => {
const result = validateS3BucketName('a'.repeat(64))
expect(result.isValid).toBe(false)
expect(result.error).toContain('between 3 and 63')
})
})
describe('invalid bucket names - format', () => {
it.concurrent('should reject uppercase letters', () => {
const result = validateS3BucketName('MyBucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject underscores', () => {
const result = validateS3BucketName('my_bucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject starting with hyphen', () => {
const result = validateS3BucketName('-mybucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject ending with hyphen', () => {
const result = validateS3BucketName('mybucket-')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject starting with period', () => {
const result = validateS3BucketName('.mybucket')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject ending with period', () => {
const result = validateS3BucketName('mybucket.')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject consecutive periods', () => {
const result = validateS3BucketName('my..bucket')
expect(result.isValid).toBe(false)
expect(result.error).toContain('consecutive periods')
})
it.concurrent('should reject IP address format', () => {
const result = validateS3BucketName('192.168.1.1')
expect(result.isValid).toBe(false)
expect(result.error).toContain('IP address')
})
it.concurrent('should reject special characters', () => {
const result = validateS3BucketName('my@bucket')
expect(result.isValid).toBe(false)
})
})
describe('error messages', () => {
it.concurrent('should use custom param name in errors', () => {
const result = validateS3BucketName('', 's3Bucket')
expect(result.error).toContain('s3Bucket')
})
})
})

View File

@@ -1,23 +1,18 @@
import dns from 'dns/promises'
import http from 'http'
import https from 'https'
import type { LookupFunction } from 'net'
import { createLogger } from '@sim/logger'
import * as ipaddr from 'ipaddr.js'
const logger = createLogger('InputValidation')
/**
* Result type for validation functions
*/
export interface ValidationResult {
isValid: boolean
error?: string
sanitized?: string
}
/**
* Options for path segment validation
*/
export interface PathSegmentOptions {
/** Name of the parameter for error messages */
paramName?: string
@@ -68,7 +63,6 @@ export function validatePathSegment(
customPattern,
} = options
// Check for null/undefined
if (value === null || value === undefined || value === '') {
return {
isValid: false,
@@ -76,7 +70,6 @@ export function validatePathSegment(
}
}
// Check length
if (value.length > maxLength) {
logger.warn('Path segment exceeds maximum length', {
paramName,
@@ -89,7 +82,6 @@ export function validatePathSegment(
}
}
// Check for null bytes (potential for bypass attacks)
if (value.includes('\0') || value.includes('%00')) {
logger.warn('Path segment contains null bytes', { paramName })
return {
@@ -98,7 +90,6 @@ export function validatePathSegment(
}
}
// Check for path traversal patterns
const pathTraversalPatterns = [
'..',
'./',
@@ -127,7 +118,6 @@ export function validatePathSegment(
}
}
// Check for directory separators
if (value.includes('/') || value.includes('\\')) {
logger.warn('Path segment contains directory separators', { paramName })
return {
@@ -136,7 +126,6 @@ export function validatePathSegment(
}
}
// Use custom pattern if provided
if (customPattern) {
if (!customPattern.test(value)) {
logger.warn('Path segment failed custom pattern validation', {
@@ -151,7 +140,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 += '_'
@@ -920,26 +908,28 @@ export async function secureFetchWithPinnedIP(
const isIPv6 = resolvedIP.includes(':')
const family = isIPv6 ? 6 : 4
const agentOptions = {
lookup: (
_hostname: string,
_options: unknown,
callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void
) => {
const lookup: LookupFunction = (_hostname, options, callback) => {
if (options.all) {
callback(null, [{ address: resolvedIP, family }])
} else {
callback(null, resolvedIP, family)
},
}
}
const agent = isHttps
? new https.Agent(agentOptions as https.AgentOptions)
: new http.Agent(agentOptions as http.AgentOptions)
const agentOptions: http.AgentOptions = { lookup }
const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions)
// Remove accept-encoding since Node.js http/https doesn't auto-decompress
// Headers are lowercase due to Web Headers API normalization in executeToolRequest
const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {}
const requestOptions: http.RequestOptions = {
hostname: parsed.hostname,
port,
path: parsed.pathname + parsed.search,
method: options.method || 'GET',
headers: options.headers || {},
headers: sanitizedHeaders,
agent,
timeout: options.timeout || 30000,
}
@@ -1083,6 +1073,138 @@ export function validateAirtableId(
return { isValid: true, sanitized: value }
}
/**
* Validates an AWS region identifier
*
* Supported region formats:
* - Standard: us-east-1, eu-west-2, ap-southeast-1, sa-east-1, af-south-1
* - GovCloud: us-gov-east-1, us-gov-west-1
* - China: cn-north-1, cn-northwest-1
* - Israel: il-central-1
* - ISO partitions: us-iso-east-1, us-isob-east-1
*
* @param value - The AWS region to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateAwsRegion(region, 'region')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* ```
*/
export function validateAwsRegion(
value: string | null | undefined,
paramName = 'region'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
// AWS region patterns:
// - Standard: af|ap|ca|eu|me|sa|us|il followed by direction and number
// - GovCloud: us-gov-east-1, us-gov-west-1
// - China: cn-north-1, cn-northwest-1
// - ISO: us-iso-east-1, us-iso-west-1, us-isob-east-1
const awsRegionPattern =
/^(af|ap|ca|cn|eu|il|me|sa|us|us-gov|us-iso|us-isob)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/
if (!awsRegionPattern.test(value)) {
logger.warn('Invalid AWS region format', {
paramName,
value: value.substring(0, 50),
})
return {
isValid: false,
error: `${paramName} must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)`,
}
}
return { isValid: true, sanitized: value }
}
/**
* Validates an S3 bucket name according to AWS naming rules
*
* S3 bucket names must:
* - Be 3-63 characters long
* - Start and end with a letter or number
* - Contain only lowercase letters, numbers, and hyphens
* - Not contain consecutive periods
* - Not be formatted as an IP address
*
* @param value - The S3 bucket name to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateS3BucketName(bucket, 'bucket')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* ```
*/
export function validateS3BucketName(
value: string | null | undefined,
paramName = 'bucket'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
if (value.length < 3 || value.length > 63) {
logger.warn('S3 bucket name length invalid', {
paramName,
length: value.length,
})
return {
isValid: false,
error: `${paramName} must be between 3 and 63 characters`,
}
}
const bucketNamePattern = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$|^[a-z0-9]$/
if (!bucketNamePattern.test(value)) {
logger.warn('Invalid S3 bucket name format', {
paramName,
value: value.substring(0, 63),
})
return {
isValid: false,
error: `${paramName} must start and end with a letter or number, and contain only lowercase letters, numbers, hyphens, and periods`,
}
}
if (value.includes('..')) {
logger.warn('S3 bucket name contains consecutive periods', { paramName })
return {
isValid: false,
error: `${paramName} cannot contain consecutive periods`,
}
}
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/
if (ipPattern.test(value)) {
logger.warn('S3 bucket name formatted as IP address', { paramName })
return {
isValid: false,
error: `${paramName} cannot be formatted as an IP address`,
}
}
return { isValid: true, sanitized: value }
}
/**
* Validates a Google Calendar ID
*

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { McpToolSchema } from './types'
@@ -217,7 +217,7 @@ export function extractInputFormatFromBlocks(
const blockObj = block as Record<string, unknown>
const blockType = blockObj.type as string
if (isValidStartBlockType(blockType)) {
if (isInputDefinitionTrigger(blockType)) {
// Try to get inputFormat from subBlocks.inputFormat.value
const subBlocks = blockObj.subBlocks as Record<string, { value?: unknown }> | undefined
const subBlockValue = subBlocks?.inputFormat?.value

View File

@@ -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,
}
}

View File

@@ -562,14 +562,26 @@ function generateOutputPathsWithTypes(
* Gets the tool outputs for a block operation.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param subBlocks - SubBlock values to pass to the tool selector
* @returns Outputs schema for the tool, or empty object on error
*/
export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record<string, any> {
export function getToolOutputs(
blockConfig: BlockConfig,
subBlocks?: Record<string, SubBlockWithValue>
): Record<string, any> {
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<string, any> = {}
if (subBlocks) {
for (const [key, subBlock] of Object.entries(subBlocks)) {
params[key] = subBlock.value
}
}
const toolId = blockConfig.tools.config.tool(params)
if (!toolId) return {}
const toolConfig = getTool(toolId)
@@ -577,7 +589,7 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec
return toolConfig.outputs
} catch (error) {
logger.warn('Failed to get tool outputs for operation', { operation, error })
logger.warn('Failed to get tool outputs', { error })
return {}
}
}
@@ -586,16 +598,14 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec
* Generates output paths for a tool-based block.
*
* @param blockConfig - The block configuration containing tools config
* @param operation - The selected operation for the tool
* @param subBlocks - Optional subBlock values for condition evaluation
* @param subBlocks - SubBlock values for tool selection and condition evaluation
* @returns Array of output paths for the tool, or empty array on error
*/
export function getToolOutputPaths(
blockConfig: BlockConfig,
operation: string,
subBlocks?: Record<string, SubBlockWithValue>
): string[] {
const outputs = getToolOutputs(blockConfig, operation)
const outputs = getToolOutputs(blockConfig, subBlocks)
if (!outputs || Object.keys(outputs).length === 0) return []
@@ -630,16 +640,16 @@ export function getOutputPathsFromSchema(outputs: Record<string, any>): 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<string, SubBlockWithValue> | 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)

View File

@@ -1,4 +1,4 @@
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
import {
normalizedStringify,
@@ -13,6 +13,20 @@ import {
sortEdges,
} from './normalize'
/** Block with optional diff markers added by copilot */
type BlockWithDiffMarkers = BlockState & {
is_diff?: string
field_diffs?: Record<string, unknown>
}
/** SubBlock with optional diff marker */
type SubBlockWithDiffMarker = {
id: string
type: string
value: unknown
is_diff?: string
}
/**
* Compare the current workflow state with the deployed state to detect meaningful changes
* @param currentState - The current workflow state
@@ -63,21 +77,32 @@ export function hasWorkflowChanged(
// - subBlocks: handled separately below
// - layout: contains measuredWidth/measuredHeight from autolayout
// - height: block height measurement from autolayout
// - outputs: derived from subBlocks (e.g., inputFormat), already compared via subBlocks
// - is_diff, field_diffs: diff markers from copilot edits
const currentBlockWithDiff = currentBlock as BlockWithDiffMarkers
const deployedBlockWithDiff = deployedBlock as BlockWithDiffMarkers
const {
position: _currentPos,
subBlocks: currentSubBlocks = {},
layout: _currentLayout,
height: _currentHeight,
outputs: _currentOutputs,
is_diff: _currentIsDiff,
field_diffs: _currentFieldDiffs,
...currentRest
} = currentBlock
} = currentBlockWithDiff
const {
position: _deployedPos,
subBlocks: deployedSubBlocks = {},
layout: _deployedLayout,
height: _deployedHeight,
outputs: _deployedOutputs,
is_diff: _deployedIsDiff,
field_diffs: _deployedFieldDiffs,
...deployedRest
} = deployedBlock
} = deployedBlockWithDiff
// Also exclude width/height from data object (container dimensions from autolayout)
const {
@@ -156,14 +181,13 @@ export function hasWorkflowChanged(
}
}
// Compare type and other properties
const currentSubBlockWithoutValue = { ...currentSubBlocks[subBlockId], value: undefined }
const deployedSubBlockWithoutValue = { ...deployedSubBlocks[subBlockId], value: undefined }
// Compare type and other properties (excluding diff markers and value)
const currentSubBlockWithDiff = currentSubBlocks[subBlockId] as SubBlockWithDiffMarker
const deployedSubBlockWithDiff = deployedSubBlocks[subBlockId] as SubBlockWithDiffMarker
const { value: _cv, is_diff: _cd, ...currentSubBlockRest } = currentSubBlockWithDiff
const { value: _dv, is_diff: _dd, ...deployedSubBlockRest } = deployedSubBlockWithDiff
if (
normalizedStringify(currentSubBlockWithoutValue) !==
normalizedStringify(deployedSubBlockWithoutValue)
) {
if (normalizedStringify(currentSubBlockRest) !== normalizedStringify(deployedSubBlockRest)) {
return true
}
}

View File

@@ -1,4 +1,4 @@
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
/**
@@ -25,7 +25,7 @@ export function extractInputFieldsFromBlocks(
// Find trigger block
const triggerEntry = Object.entries(blocks).find(([, block]) => {
const b = block as Record<string, unknown>
return typeof b.type === 'string' && isValidStartBlockType(b.type)
return typeof b.type === 'string' && isInputDefinitionTrigger(b.type)
})
if (!triggerEntry) return []

View File

@@ -376,6 +376,7 @@ describe('Database Helpers', () => {
forEachItems: '',
doWhileCondition: '',
whileCondition: '',
enabled: true,
})
expect(result?.parallels['parallel-1']).toEqual({
@@ -384,6 +385,7 @@ describe('Database Helpers', () => {
count: 5,
distribution: ['item1', 'item2'],
parallelType: 'count',
enabled: true,
})
})

View File

@@ -273,6 +273,7 @@ export async function loadWorkflowFromNormalizedTables(
forEachItems: (config as Loop).forEachItems ?? '',
whileCondition: (config as Loop).whileCondition ?? '',
doWhileCondition: (config as Loop).doWhileCondition ?? '',
enabled: migratedBlocks[subflow.id]?.enabled ?? true,
}
loops[subflow.id] = loop
@@ -301,6 +302,7 @@ export async function loadWorkflowFromNormalizedTables(
(config as Parallel).parallelType === 'collection'
? (config as Parallel).parallelType
: 'count',
enabled: migratedBlocks[subflow.id]?.enabled ?? true,
}
parallels[subflow.id] = parallel
} else {

View File

@@ -0,0 +1,28 @@
/**
* Trigger types that define workflow input parameters (inputFormat).
* These are triggers where users can configure input schema for the workflow.
*
* This module is kept lightweight with no dependencies to avoid circular imports.
*
* Note: External triggers like webhook/schedule are NOT included here because
* they receive input from external event payloads, not user-defined inputFormat.
*/
export const INPUT_DEFINITION_TRIGGER_TYPES = [
'starter',
'start',
'start_trigger',
'api_trigger',
'input_trigger',
] as const
export type InputDefinitionTriggerType = (typeof INPUT_DEFINITION_TRIGGER_TYPES)[number]
/**
* Check if a block type is a trigger that defines workflow input parameters.
* Used to find blocks that have inputFormat subblock for workflow input schema.
*/
export function isInputDefinitionTrigger(
blockType: string
): blockType is InputDefinitionTriggerType {
return INPUT_DEFINITION_TRIGGER_TYPES.includes(blockType as InputDefinitionTriggerType)
}

View File

@@ -1,21 +0,0 @@
/**
* Valid start block types that can trigger a workflow
* This module is kept lightweight with no dependencies to avoid circular imports
*/
export const VALID_START_BLOCK_TYPES = [
'starter',
'start',
'start_trigger',
'api',
'api_trigger',
'input_trigger',
] as const
export type ValidStartBlockType = (typeof VALID_START_BLOCK_TYPES)[number]
/**
* Check if a block type is a valid start block type
*/
export function isValidStartBlockType(blockType: string): blockType is ValidStartBlockType {
return VALID_START_BLOCK_TYPES.includes(blockType as ValidStartBlockType)
}

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import {
type StartBlockCandidate,
StartBlockPath,
@@ -22,7 +22,7 @@ export function hasValidStartBlockInState(state: WorkflowState | null | undefine
const startBlock = Object.values(state.blocks).find((block: BlockState) => {
const blockType = block?.type
return isValidStartBlockType(blockType)
return isInputDefinitionTrigger(blockType)
})
return !!startBlock

View File

@@ -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,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -172,7 +172,14 @@ export type TimeRange =
| 'All time'
| 'Custom range'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
export type LogLevel =
| 'error'
| 'info'
| 'running'
| 'pending'
| 'cancelled'
| 'all'
| (string & {})
/** Core trigger types for workflow execution */
export const CORE_TRIGGER_TYPES = [
'manual',

View File

@@ -130,6 +130,7 @@ export interface Loop {
forEachItems?: any[] | Record<string, any> | string // Items or expression
whileCondition?: string // JS expression that evaluates to boolean (for while loops)
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
enabled: boolean
}
export interface Parallel {
@@ -138,6 +139,7 @@ export interface Parallel {
distribution?: any[] | Record<string, any> | string // Items or expression
count?: number // Number of parallel executions for count-based parallel
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
enabled: boolean
}
export interface Variable {

View File

@@ -72,6 +72,7 @@ export function convertLoopBlockToLoop(
nodes: findChildNodes(loopBlockId, blocks),
iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType,
enabled: loopBlock.enabled,
}
loop.forEachItems = loopBlock.data?.collection || ''
@@ -113,6 +114,7 @@ export function convertParallelBlockToParallel(
distribution,
count,
parallelType: validatedParallelType,
enabled: parallelBlock.enabled,
}
}

View File

@@ -1,3 +1,4 @@
import { fileParserTool } from '@/tools/file/parser'
import { fileParserTool, fileParserV2Tool } from '@/tools/file/parser'
export const fileParseTool = fileParserTool
export { fileParserV2Tool }

View File

@@ -177,3 +177,25 @@ export const fileParserTool: ToolConfig<FileParserInput, FileParserOutput> = {
processedFiles: { type: 'file[]', description: 'Array of UserFile objects for downstream use' },
},
}
export const fileParserV2Tool: ToolConfig<FileParserInput, FileParserOutput> = {
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',
},
},
}

View File

@@ -1,3 +1,3 @@
import { mistralParserTool } from '@/tools/mistral/parser'
import { mistralParserTool, mistralParserV2Tool } from '@/tools/mistral/parser'
export { mistralParserTool }
export { mistralParserTool, mistralParserV2Tool }

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { MistralParserInput, MistralParserOutput } from '@/tools/mistral/types'
import type {
MistralParserInput,
MistralParserOutput,
MistralParserV2Output,
} from '@/tools/mistral/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('MistralParserTool')
@@ -415,3 +419,138 @@ export const mistralParserTool: ToolConfig<MistralParserInput, MistralParserOutp
},
},
}
export const mistralParserV2Tool: ToolConfig<MistralParserInput, MistralParserV2Output> = {
id: 'mistral_parser_v2',
name: 'Mistral PDF Parser',
description: 'Parse PDF documents using Mistral OCR API',
version: '2.0.0',
params: mistralParserTool.params,
request: mistralParserTool.request,
transformResponse: async (response: Response) => {
let ocrResult
try {
ocrResult = await response.json()
} catch (jsonError) {
throw new Error(
`Failed to parse Mistral OCR response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`
)
}
if (!ocrResult || typeof ocrResult !== 'object') {
throw new Error('Invalid response format from Mistral OCR API')
}
// Extract the actual Mistral data (may be nested in output from our API route)
const mistralData =
ocrResult.output && typeof ocrResult.output === 'object' && !ocrResult.pages
? ocrResult.output
: ocrResult
// Return raw Mistral API structure - no transformation
return {
success: true,
output: {
pages: mistralData.pages ?? [],
model: mistralData.model ?? 'mistral-ocr-latest',
usage_info: mistralData.usage_info ?? { pages_processed: 0, doc_size_bytes: null },
document_annotation: mistralData.document_annotation ?? null,
},
}
},
outputs: {
pages: {
type: 'array',
description: 'Array of page objects from Mistral OCR',
items: {
type: 'object',
properties: {
index: { type: 'number', description: 'Page index (zero-based)' },
markdown: { type: 'string', description: 'Extracted markdown content' },
images: {
type: 'array',
description: 'Images extracted from this page with bounding boxes',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Image identifier (e.g., img-0.jpeg)' },
top_left_x: { type: 'number', description: 'Top-left X coordinate in pixels' },
top_left_y: { type: 'number', description: 'Top-left Y coordinate in pixels' },
bottom_right_x: {
type: 'number',
description: 'Bottom-right X coordinate in pixels',
},
bottom_right_y: {
type: 'number',
description: 'Bottom-right Y coordinate in pixels',
},
image_base64: {
type: 'string',
description: 'Base64-encoded image data (when include_image_base64=true)',
optional: true,
},
},
},
},
dimensions: {
type: 'object',
description: 'Page dimensions',
properties: {
dpi: { type: 'number', description: 'Dots per inch' },
height: { type: 'number', description: 'Page height in pixels' },
width: { type: 'number', description: 'Page width in pixels' },
},
},
tables: {
type: 'array',
description:
'Extracted tables as HTML/markdown (when table_format is set). Referenced via placeholders like [tbl-0.html]',
},
hyperlinks: {
type: 'array',
description:
'Array of URL strings detected in the page (e.g., ["https://...", "mailto:..."])',
items: {
type: 'string',
description: 'URL or mailto link',
},
},
header: {
type: 'string',
description: 'Page header content (when extract_header=true)',
optional: true,
},
footer: {
type: 'string',
description: 'Page footer content (when extract_footer=true)',
optional: true,
},
},
},
},
model: {
type: 'string',
description: 'Mistral OCR model identifier (e.g., mistral-ocr-latest)',
},
usage_info: {
type: 'object',
description: 'Usage and processing statistics',
properties: {
pages_processed: { type: 'number', description: 'Total number of pages processed' },
doc_size_bytes: {
type: 'number',
description: 'Document file size in bytes',
optional: true,
},
},
},
document_annotation: {
type: 'string',
description: 'Structured annotation data as JSON string (when applicable)',
optional: true,
},
},
}

View File

@@ -1,98 +1,80 @@
import type { ToolResponse } from '@/tools/types'
/**
* Input parameters for the Mistral OCR parser tool
*/
export interface MistralParserInput {
/** URL to a PDF document to be processed */
filePath: string
/** File upload data (from file-upload component) */
fileUpload?: any
/** Internal file path flag (for presigned URL conversion) */
_internalFilePath?: string
/** Mistral API key for authentication */
apiKey: string
/** Output format for the extracted content (default: 'markdown') */
resultType?: 'markdown' | 'text' | 'json'
/** Whether to include base64-encoded images in the response */
includeImageBase64?: boolean
/** Specific pages to process (zero-indexed) */
pages?: number[]
/** Maximum number of images to extract from the PDF */
imageLimit?: number
/** Minimum height and width (in pixels) for images to extract */
imageMinSize?: number
}
/**
* Usage information returned by the Mistral OCR API
*/
export interface MistralOcrUsageInfo {
/** Number of pages processed in the document */
pagesProcessed: number
/** Size of the document in bytes */
docSizeBytes: number | null
}
/**
* Metadata about the processed document
*/
export interface MistralParserMetadata {
/** Unique identifier for this OCR job */
jobId: string
/** File type of the document (typically 'pdf') */
fileType: string
/** Filename extracted from the document URL */
fileName: string
/** Source type (always 'url' for now) */
source: 'url'
/** Original URL to the document (only included for user-provided URLs) */
sourceUrl?: string
/** Total number of pages in the document */
pageCount: number
/** Usage statistics from the OCR processing */
usageInfo?: MistralOcrUsageInfo
/** The Mistral OCR model used for processing */
model: string
/** The output format that was requested */
resultType?: 'markdown' | 'text' | 'json'
/** ISO timestamp when the document was processed */
processedAt: string
}
/**
* Output data structure from the Mistral OCR parser
*/
export interface MistralParserOutputData {
/** Extracted content in the requested format */
content: string
/** Metadata about the parsed document and processing */
metadata: MistralParserMetadata
}
/**
* Complete response from the Mistral OCR parser tool
*/
export interface MistralParserOutput extends ToolResponse {
/** The output data containing content and metadata */
output: MistralParserOutputData
}
export interface MistralOcrImage {
id: string
top_left_x: number
top_left_y: number
bottom_right_x: number
bottom_right_y: number
image_base64?: string
}
export interface MistralOcrDimensions {
dpi: number
height: number
width: number
}
export interface MistralOcrPage {
index: number
markdown: string
images: MistralOcrImage[]
dimensions: MistralOcrDimensions
tables: unknown[]
hyperlinks: unknown[]
header: string | null
footer: string | null
}
export interface MistralOcrUsageInfoRaw {
pages_processed: number
doc_size_bytes: number | null
}
export interface MistralParserV2Output extends ToolResponse {
output: {
pages: MistralOcrPage[]
model: string
usage_info: MistralOcrUsageInfoRaw
document_annotation: string | null
}
}

View File

@@ -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<Array<{ name: string; type: string }>> {
): Promise<Array<{ name: string; type: string; description?: string }>> {
try {
const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http')

View File

@@ -204,7 +204,7 @@ import {
exaResearchTool,
exaSearchTool,
} from '@/tools/exa'
import { fileParseTool } from '@/tools/file'
import { fileParserV2Tool, fileParseTool } from '@/tools/file'
import {
firecrawlAgentTool,
firecrawlCrawlTool,
@@ -979,7 +979,7 @@ import {
microsoftTeamsWriteChannelTool,
microsoftTeamsWriteChatTool,
} from '@/tools/microsoft_teams'
import { mistralParserTool } from '@/tools/mistral'
import { mistralParserTool, mistralParserV2Tool } from '@/tools/mistral'
import {
mongodbDeleteTool,
mongodbExecuteTool,
@@ -1500,6 +1500,7 @@ import {
telegramSendPhotoTool,
telegramSendVideoTool,
} from '@/tools/telegram'
import { textractParserTool } from '@/tools/textract'
import { thinkingTool } from '@/tools/thinking'
import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird'
import {
@@ -1682,6 +1683,7 @@ export const tools: Record<string, ToolConfig> = {
function_execute: functionExecuteTool,
vision_tool: visionTool,
file_parser: fileParseTool,
file_parser_v2: fileParserV2Tool,
firecrawl_scrape: firecrawlScrapeTool,
firecrawl_search: firecrawlSearchTool,
firecrawl_crawl: firecrawlCrawlTool,
@@ -2455,7 +2457,9 @@ export const tools: Record<string, ToolConfig> = {
apollo_task_search: apolloTaskSearchTool,
apollo_email_accounts: apolloEmailAccountsTool,
mistral_parser: mistralParserTool,
mistral_parser_v2: mistralParserV2Tool,
reducto_parser: reductoParserTool,
textract_parser: textractParserTool,
thinking_tool: thinkingTool,
tinybird_events: tinybirdEventsTool,
tinybird_query: tinybirdQueryTool,

View File

@@ -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',

View File

@@ -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',

View File

@@ -0,0 +1,2 @@
export { textractParserTool } from '@/tools/textract/parser'
export * from '@/tools/textract/types'

View File

@@ -0,0 +1,288 @@
import { createLogger } from '@sim/logger'
import type { TextractParserInput, TextractParserOutput } from '@/tools/textract/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('TextractParserTool')
export const textractParserTool: ToolConfig<TextractParserInput, TextractParserOutput> = {
id: 'textract_parser',
name: 'AWS Textract Parser',
description: 'Parse documents using AWS Textract OCR and document analysis',
version: '1.0.0',
params: {
accessKeyId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS Access Key ID',
},
secretAccessKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS Secret Access Key',
},
region: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'AWS region for Textract service (e.g., us-east-1)',
},
processingMode: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Document type: single-page or multi-page. Defaults to single-page.',
},
filePath: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'URL to a document to be processed (JPEG, PNG, or single-page PDF).',
},
s3Uri: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'S3 URI for multi-page processing (s3://bucket/key).',
},
fileUpload: {
type: 'object',
required: false,
visibility: 'hidden',
description: 'File upload data from file-upload component',
},
featureTypes: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description:
'Feature types to detect: TABLES, FORMS, QUERIES, SIGNATURES, LAYOUT. If not specified, only text detection is performed.',
items: {
type: 'string',
description: 'Feature type',
},
},
queries: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description:
'Custom queries to extract specific information. Only used when featureTypes includes QUERIES.',
items: {
type: 'object',
description: 'Query configuration',
properties: {
Text: { type: 'string', description: 'The query text' },
Alias: { type: 'string', description: 'Optional alias for the result' },
},
},
},
},
request: {
url: '/api/tools/textract/parse',
method: 'POST',
headers: () => {
return {
'Content-Type': 'application/json',
Accept: 'application/json',
}
},
body: (params) => {
const processingMode = params.processingMode || 'sync'
const requestBody: Record<string, unknown> = {
accessKeyId: params.accessKeyId?.trim(),
secretAccessKey: params.secretAccessKey?.trim(),
region: params.region?.trim(),
processingMode,
}
if (processingMode === 'async') {
requestBody.s3Uri = params.s3Uri?.trim()
} else {
// Handle file upload by extracting the path
if (params.fileUpload && !params.filePath) {
const uploadPath = params.fileUpload.path || params.fileUpload.url
if (uploadPath) {
requestBody.filePath = uploadPath
}
} else {
requestBody.filePath = params.filePath?.trim()
}
}
if (params.featureTypes && Array.isArray(params.featureTypes)) {
requestBody.featureTypes = params.featureTypes
}
if (params.queries && Array.isArray(params.queries)) {
requestBody.queries = params.queries
}
return requestBody
},
},
transformResponse: async (response) => {
try {
let apiResult
try {
apiResult = await response.json()
} catch (jsonError) {
throw new Error(
`Failed to parse Textract response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`
)
}
if (!apiResult || typeof apiResult !== 'object') {
throw new Error('Invalid response format from Textract API')
}
if (!apiResult.success) {
throw new Error(apiResult.error || 'Request failed')
}
const textractData = apiResult.output ?? apiResult
return {
success: true,
output: {
blocks: textractData.Blocks ?? textractData.blocks ?? [],
documentMetadata: {
pages:
textractData.DocumentMetadata?.Pages ?? textractData.documentMetadata?.pages ?? 0,
},
modelVersion:
textractData.modelVersion ??
textractData.AnalyzeDocumentModelVersion ??
textractData.analyzeDocumentModelVersion ??
textractData.DetectDocumentTextModelVersion ??
textractData.detectDocumentTextModelVersion ??
undefined,
},
}
} catch (error) {
logger.error('Error processing Textract result:', error)
throw error
}
},
outputs: {
blocks: {
type: 'array',
description:
'Array of Block objects containing detected text, tables, forms, and other elements',
items: {
type: 'object',
properties: {
BlockType: {
type: 'string',
description: 'Type of block (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)',
},
Id: { type: 'string', description: 'Unique identifier for the block' },
Text: {
type: 'string',
description: 'The text content (for LINE and WORD blocks)',
optional: true,
},
TextType: {
type: 'string',
description: 'Type of text (PRINTED or HANDWRITING)',
optional: true,
},
Confidence: { type: 'number', description: 'Confidence score (0-100)', optional: true },
Page: { type: 'number', description: 'Page number', optional: true },
Geometry: {
type: 'object',
description: 'Location and bounding box information',
optional: true,
properties: {
BoundingBox: {
type: 'object',
properties: {
Height: { type: 'number', description: 'Height as ratio of document height' },
Left: { type: 'number', description: 'Left position as ratio of document width' },
Top: { type: 'number', description: 'Top position as ratio of document height' },
Width: { type: 'number', description: 'Width as ratio of document width' },
},
},
Polygon: {
type: 'array',
description: 'Polygon coordinates',
items: {
type: 'object',
properties: {
X: { type: 'number', description: 'X coordinate' },
Y: { type: 'number', description: 'Y coordinate' },
},
},
},
},
},
Relationships: {
type: 'array',
description: 'Relationships to other blocks',
optional: true,
items: {
type: 'object',
properties: {
Type: {
type: 'string',
description: 'Relationship type (CHILD, VALUE, ANSWER, etc.)',
},
Ids: { type: 'array', description: 'IDs of related blocks' },
},
},
},
EntityTypes: {
type: 'array',
description: 'Entity types for KEY_VALUE_SET (KEY or VALUE)',
optional: true,
},
SelectionStatus: {
type: 'string',
description: 'For checkboxes: SELECTED or NOT_SELECTED',
optional: true,
},
RowIndex: { type: 'number', description: 'Row index for table cells', optional: true },
ColumnIndex: {
type: 'number',
description: 'Column index for table cells',
optional: true,
},
RowSpan: { type: 'number', description: 'Row span for merged cells', optional: true },
ColumnSpan: {
type: 'number',
description: 'Column span for merged cells',
optional: true,
},
Query: {
type: 'object',
description: 'Query information for QUERY blocks',
optional: true,
properties: {
Text: { type: 'string', description: 'Query text' },
Alias: { type: 'string', description: 'Query alias', optional: true },
Pages: { type: 'array', description: 'Pages to search', optional: true },
},
},
},
},
},
documentMetadata: {
type: 'object',
description: 'Metadata about the analyzed document',
properties: {
pages: { type: 'number', description: 'Number of pages in the document' },
},
},
modelVersion: {
type: 'string',
description: 'Version of the Textract model used for processing',
optional: true,
},
},
}

View File

@@ -0,0 +1,110 @@
import type { ToolResponse } from '@/tools/types'
export type TextractProcessingMode = 'sync' | 'async'
export interface TextractParserInput {
accessKeyId: string
secretAccessKey: string
region: string
processingMode?: TextractProcessingMode
filePath?: string
s3Uri?: string
fileUpload?: {
url?: string
path?: string
}
featureTypes?: TextractFeatureType[]
queries?: TextractQuery[]
}
export type TextractFeatureType = 'TABLES' | 'FORMS' | 'QUERIES' | 'SIGNATURES' | 'LAYOUT'
export interface TextractQuery {
Text: string
Alias?: string
Pages?: string[]
}
export interface TextractBoundingBox {
Height: number
Left: number
Top: number
Width: number
}
export interface TextractPolygonPoint {
X: number
Y: number
}
export interface TextractGeometry {
BoundingBox: TextractBoundingBox
Polygon: TextractPolygonPoint[]
RotationAngle?: number
}
export interface TextractRelationship {
Type: string
Ids: string[]
}
export interface TextractBlock {
BlockType: string
Id: string
Text?: string
TextType?: string
Confidence?: number
Geometry?: TextractGeometry
Relationships?: TextractRelationship[]
Page?: number
EntityTypes?: string[]
SelectionStatus?: string
RowIndex?: number
ColumnIndex?: number
RowSpan?: number
ColumnSpan?: number
Query?: {
Text: string
Alias?: string
Pages?: string[]
}
}
export interface TextractDocumentMetadataRaw {
Pages: number
}
export interface TextractDocumentMetadata {
pages: number
}
export interface TextractApiResponse {
Blocks: TextractBlock[]
DocumentMetadata: TextractDocumentMetadataRaw
AnalyzeDocumentModelVersion?: string
DetectDocumentTextModelVersion?: string
}
export interface TextractNormalizedOutput {
blocks: TextractBlock[]
documentMetadata: TextractDocumentMetadata
modelVersion?: string
}
export interface TextractAsyncJobResponse {
JobStatus: 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'PARTIAL_SUCCESS'
StatusMessage?: string
Blocks?: TextractBlock[]
DocumentMetadata?: TextractDocumentMetadataRaw
NextToken?: string
AnalyzeDocumentModelVersion?: string
DetectDocumentTextModelVersion?: string
}
export interface TextractStartJobResponse {
JobId: string
}
export interface TextractParserOutput extends ToolResponse {
output: TextractNormalizedOutput
}

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",