mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-20 20:38:16 -05:00
Compare commits
16 Commits
feat/email
...
feat/tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9239b55ef | ||
|
|
233a3ee0b4 | ||
|
|
c3634c2e38 | ||
|
|
51ed4f506d | ||
|
|
59578dd140 | ||
|
|
dcaae1df7c | ||
|
|
c5d3405c7a | ||
|
|
0ac6fec0a5 | ||
|
|
75450afb11 | ||
|
|
dbee20e9e5 | ||
|
|
ecf39c5a54 | ||
|
|
a26a1a9737 | ||
|
|
689037a300 | ||
|
|
07f0c01dc4 | ||
|
|
e4ad31bb6b | ||
|
|
84691fc873 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Interact with Confluence
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="confluence"
|
||||
type="confluence_v2"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"supabase",
|
||||
"tavily",
|
||||
"telegram",
|
||||
"textract",
|
||||
"tinybird",
|
||||
"translate",
|
||||
"trello",
|
||||
|
||||
@@ -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\) |
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
120
apps/docs/content/docs/en/tools/textract.mdx
Normal file
120
apps/docs/content/docs/en/tools/textract.mdx
Normal 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 |
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
640
apps/sim/app/api/tools/textract/parse/route.ts
Normal file
640
apps/sim/app/api/tools/textract/parse/route.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
validateAwsRegion,
|
||||
validateExternalUrl,
|
||||
validateS3BucketName,
|
||||
} from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
|
||||
import { verifyFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
|
||||
|
||||
const logger = createLogger('TextractParseAPI')
|
||||
|
||||
const QuerySchema = z.object({
|
||||
Text: z.string().min(1),
|
||||
Alias: z.string().optional(),
|
||||
Pages: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const TextractParseSchema = z
|
||||
.object({
|
||||
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
processingMode: z.enum(['sync', 'async']).optional().default('sync'),
|
||||
filePath: z.string().optional(),
|
||||
s3Uri: z.string().optional(),
|
||||
featureTypes: z
|
||||
.array(z.enum(['TABLES', 'FORMS', 'QUERIES', 'SIGNATURES', 'LAYOUT']))
|
||||
.optional(),
|
||||
queries: z.array(QuerySchema).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const regionValidation = validateAwsRegion(data.region, 'AWS region')
|
||||
if (!regionValidation.isValid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: regionValidation.error,
|
||||
path: ['region'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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?.startsWith('/api/files/serve/') ||
|
||||
(validatedData.filePath?.startsWith('/') &&
|
||||
validatedData.filePath?.includes('/api/files/serve/'))
|
||||
|
||||
if (isInternalFilePath) {
|
||||
try {
|
||||
const storageKey = extractStorageKey(validatedData.filePath)
|
||||
const context = inferContextFromKey(storageKey)
|
||||
|
||||
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('/')) {
|
||||
if (!validatedData.filePath.startsWith('/api/files/serve/')) {
|
||||
logger.warn(`[${requestId}] Invalid internal path`, {
|
||||
userId,
|
||||
path: validatedData.filePath.substring(0, 50),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const baseUrl = getBaseUrl()
|
||||
fileUrl = `${baseUrl}${validatedData.filePath}`
|
||||
} else {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const BlockDataSchema = z.object({
|
||||
doWhileCondition: z.string().optional(),
|
||||
parallelType: z.enum(['collection', 'count']).optional(),
|
||||
type: z.string().optional(),
|
||||
canonicalModes: z.record(z.enum(['basic', 'advanced'])).optional(),
|
||||
})
|
||||
|
||||
const SubBlockStateSchema = z.object({
|
||||
|
||||
@@ -538,15 +538,11 @@ export function Document({
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (operation === 'delete') {
|
||||
if (operation === 'delete' || result.errorCount > 0) {
|
||||
refreshChunks()
|
||||
} else {
|
||||
result.results.forEach((opResult) => {
|
||||
if (opResult.operation === operation) {
|
||||
opResult.chunkIds.forEach((chunkId: string) => {
|
||||
updateChunk(chunkId, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
chunks.forEach((chunk) => {
|
||||
updateChunk(chunk.id, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
|
||||
|
||||
@@ -462,7 +462,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='space-y-[8px]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
||||
definition.
|
||||
@@ -470,7 +470,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{selectedTagUsage?.documentCount === 0 ? (
|
||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
This tag definition is not being used by any documents. You can safely delete it
|
||||
to free up the tag slot.
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronUp, X } from 'lucide-react'
|
||||
import { Badge, Button, Eye } from '@/components/emcn'
|
||||
import { Button, Eye } from '@/components/emcn'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import {
|
||||
@@ -257,9 +257,9 @@ export const LogDetails = memo(function LogDetails({
|
||||
Version
|
||||
</span>
|
||||
<div className='flex w-0 flex-1 justify-end'>
|
||||
<Badge variant='green' size='md' className='max-w-full truncate'>
|
||||
<span className='max-w-full truncate rounded-[6px] bg-[#14291B] px-[9px] py-[2px] font-medium text-[#86EFAC] text-[12px]'>
|
||||
{log.deploymentVersionName || `v${log.deploymentVersion}`}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 || {}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export function GeneralDeploy({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Promote to live</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to promote{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{versionToPromoteInfo?.name || `v${versionToPromote}`}
|
||||
|
||||
@@ -591,12 +591,11 @@ export function DeployModal({
|
||||
)}
|
||||
{activeTab === 'api' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
|
||||
Edit API Info
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => setIsCreateKeyModalOpen(true)}
|
||||
|
||||
@@ -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,46 @@ export function FileUpload({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file's MIME type matches the accepted types
|
||||
* Supports exact matches, wildcard patterns (e.g., 'image/*'), and '*' for all types
|
||||
*/
|
||||
const isFileTypeAccepted = (fileType: string, accepted: string): boolean => {
|
||||
if (accepted === '*') return true
|
||||
|
||||
const acceptedList = accepted.split(',').map((t) => t.trim().toLowerCase())
|
||||
const normalizedFileType = fileType.toLowerCase()
|
||||
|
||||
return acceptedList.some((acceptedType) => {
|
||||
if (acceptedType === normalizedFileType) return true
|
||||
|
||||
if (acceptedType.endsWith('/*')) {
|
||||
const typePrefix = acceptedType.slice(0, -1) // 'image/' from 'image/*'
|
||||
return normalizedFileType.startsWith(typePrefix)
|
||||
}
|
||||
|
||||
if (acceptedType.startsWith('.')) {
|
||||
const extension = acceptedType.slice(1).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 +454,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 +501,30 @@ export function FileUpload({
|
||||
const comboboxOptions = useMemo(
|
||||
() => [
|
||||
{ label: 'Upload New File', value: '__upload_new__' },
|
||||
...availableWorkspaceFiles.map((file) => ({
|
||||
label: file.name,
|
||||
value: file.id,
|
||||
})),
|
||||
...availableWorkspaceFiles.map((file) => {
|
||||
const isAccepted =
|
||||
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
|
||||
return {
|
||||
label: file.name,
|
||||
value: file.id,
|
||||
disabled: !isAccepted,
|
||||
}
|
||||
}),
|
||||
],
|
||||
[availableWorkspaceFiles]
|
||||
[availableWorkspaceFiles, acceptedTypes]
|
||||
)
|
||||
|
||||
const handleComboboxChange = (value: string) => {
|
||||
setInputValue(value)
|
||||
|
||||
const isValidOption =
|
||||
value === '__upload_new__' || availableWorkspaceFiles.some((file) => file.id === value)
|
||||
const selectedFile = availableWorkspaceFiles.find((file) => file.id === value)
|
||||
const isAcceptedType =
|
||||
selectedFile &&
|
||||
(!acceptedTypes ||
|
||||
acceptedTypes === '*' ||
|
||||
isFileTypeAccepted(selectedFile.type, acceptedTypes))
|
||||
|
||||
const isValidOption = value === '__upload_new__' || isAcceptedType
|
||||
|
||||
if (!isValidOption) {
|
||||
return
|
||||
|
||||
@@ -241,11 +241,10 @@ const getOutputTypeForPath = (
|
||||
const blockState = useWorkflowStore.getState().blocks[blockId]
|
||||
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
|
||||
return getBlockOutputType(block.type, outputPath, subBlocks)
|
||||
} else {
|
||||
const operationValue = getSubBlockValue(blockId, 'operation')
|
||||
if (blockConfig && operationValue) {
|
||||
return getToolOutputType(blockConfig, operationValue, outputPath)
|
||||
}
|
||||
} else if (blockConfig) {
|
||||
const blockState = useWorkflowStore.getState().blocks[blockId]
|
||||
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
|
||||
return getToolOutputType(blockConfig, subBlocks, outputPath)
|
||||
}
|
||||
return 'any'
|
||||
}
|
||||
@@ -1211,11 +1210,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}`)
|
||||
@@ -1535,7 +1530,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
|
||||
@@ -1543,11 +1537,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}`)
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CodeEditor({
|
||||
placeholder = '',
|
||||
className = '',
|
||||
gutterClassName = '',
|
||||
minHeight = '360px',
|
||||
minHeight,
|
||||
highlightVariables = true,
|
||||
onKeyDown,
|
||||
disabled = false,
|
||||
@@ -186,7 +186,7 @@ export function CodeEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<Code.Container className={className} style={{ minHeight }}>
|
||||
<Code.Container className={className} style={minHeight ? { minHeight } : undefined}>
|
||||
{showWandButton && onWandClick && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -220,7 +220,7 @@ export function CodeEditor({
|
||||
disabled={disabled}
|
||||
{...getCodeEditorProps({ disabled })}
|
||||
className={cn(getCodeEditorProps({ disabled }).className, 'h-full')}
|
||||
style={{ minHeight }}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
textareaClassName={cn(
|
||||
getCodeEditorProps({ disabled }).textareaClassName,
|
||||
'!block !h-full !min-h-full'
|
||||
|
||||
@@ -87,15 +87,16 @@ export function CustomToolModal({
|
||||
const [codeError, setCodeError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [toolId, setToolId] = useState<string | undefined>(undefined)
|
||||
const [initialJsonSchema, setInitialJsonSchema] = useState('')
|
||||
const [initialFunctionCode, setInitialFunctionCode] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showDiscardAlert, setShowDiscardAlert] = useState(false)
|
||||
const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false)
|
||||
const [schemaPromptInput, setSchemaPromptInput] = useState('')
|
||||
const [schemaPromptSummary, setSchemaPromptSummary] = useState<string | null>(null)
|
||||
const schemaPromptInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [isCodePromptActive, setIsCodePromptActive] = useState(false)
|
||||
const [codePromptInput, setCodePromptInput] = useState('')
|
||||
const [codePromptSummary, setCodePromptSummary] = useState<string | null>(null)
|
||||
const codePromptInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const schemaGeneration = useWand({
|
||||
@@ -174,6 +175,9 @@ Example 2:
|
||||
generationType: 'custom-tool-schema',
|
||||
},
|
||||
currentValue: jsonSchema,
|
||||
onStreamStart: () => {
|
||||
setJsonSchema('')
|
||||
},
|
||||
onGeneratedContent: (content) => {
|
||||
setJsonSchema(content)
|
||||
setSchemaError(null)
|
||||
@@ -237,6 +241,9 @@ try {
|
||||
generationType: 'javascript-function-body',
|
||||
},
|
||||
currentValue: functionCode,
|
||||
onStreamStart: () => {
|
||||
setFunctionCode('')
|
||||
},
|
||||
onGeneratedContent: (content) => {
|
||||
handleFunctionCodeChange(content)
|
||||
setCodeError(null)
|
||||
@@ -272,12 +279,15 @@ try {
|
||||
|
||||
if (initialValues) {
|
||||
try {
|
||||
setJsonSchema(
|
||||
const schemaValue =
|
||||
typeof initialValues.schema === 'string'
|
||||
? initialValues.schema
|
||||
: JSON.stringify(initialValues.schema, null, 2)
|
||||
)
|
||||
setFunctionCode(initialValues.code || '')
|
||||
const codeValue = initialValues.code || ''
|
||||
setJsonSchema(schemaValue)
|
||||
setFunctionCode(codeValue)
|
||||
setInitialJsonSchema(schemaValue)
|
||||
setInitialFunctionCode(codeValue)
|
||||
setIsEditing(true)
|
||||
setToolId(initialValues.id)
|
||||
} catch (error) {
|
||||
@@ -304,17 +314,18 @@ try {
|
||||
const resetForm = () => {
|
||||
setJsonSchema('')
|
||||
setFunctionCode('')
|
||||
setInitialJsonSchema('')
|
||||
setInitialFunctionCode('')
|
||||
setSchemaError(null)
|
||||
setCodeError(null)
|
||||
setActiveSection('schema')
|
||||
setIsEditing(false)
|
||||
setToolId(undefined)
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
setIsSchemaPromptActive(false)
|
||||
setIsCodePromptActive(false)
|
||||
setSchemaPromptInput('')
|
||||
setCodePromptInput('')
|
||||
setShowDiscardAlert(false)
|
||||
schemaGeneration.closePrompt()
|
||||
schemaGeneration.hidePromptInline()
|
||||
codeGeneration.closePrompt()
|
||||
@@ -328,31 +339,37 @@ try {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const validateJsonSchema = (schema: string): boolean => {
|
||||
if (!schema) return false
|
||||
const validateSchema = (schema: string): { isValid: boolean; error: string | null } => {
|
||||
if (!schema) return { isValid: false, error: null }
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(schema)
|
||||
|
||||
if (!parsed.type || parsed.type !== 'function') {
|
||||
return false
|
||||
return { isValid: false, error: 'Missing "type": "function"' }
|
||||
}
|
||||
|
||||
if (!parsed.function || !parsed.function.name) {
|
||||
return false
|
||||
return { isValid: false, error: 'Missing function.name field' }
|
||||
}
|
||||
|
||||
if (!parsed.function.parameters) {
|
||||
return false
|
||||
return { isValid: false, error: 'Missing function.parameters object' }
|
||||
}
|
||||
if (!parsed.function.parameters.type) {
|
||||
return { isValid: false, error: 'Missing parameters.type field' }
|
||||
}
|
||||
if (parsed.function.parameters.properties === undefined) {
|
||||
return { isValid: false, error: 'Missing parameters.properties field' }
|
||||
}
|
||||
if (
|
||||
typeof parsed.function.parameters.properties !== 'object' ||
|
||||
parsed.function.parameters.properties === null
|
||||
) {
|
||||
return { isValid: false, error: 'parameters.properties must be an object' }
|
||||
}
|
||||
|
||||
if (!parsed.function.parameters.type || parsed.function.parameters.properties === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (_error) {
|
||||
return false
|
||||
return { isValid: true, error: null }
|
||||
} catch {
|
||||
return { isValid: false, error: 'Invalid JSON format' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +391,32 @@ try {
|
||||
}
|
||||
}, [jsonSchema])
|
||||
|
||||
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
|
||||
const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [jsonSchema])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!isEditing) return true
|
||||
return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode
|
||||
}, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode])
|
||||
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
if (isEditing) {
|
||||
return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode
|
||||
}
|
||||
return jsonSchema.trim().length > 0 || functionCode.trim().length > 0
|
||||
}, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode])
|
||||
|
||||
const handleCloseAttempt = () => {
|
||||
if (hasUnsavedChanges && !schemaGeneration.isStreaming && !codeGeneration.isStreaming) {
|
||||
setShowDiscardAlert(true)
|
||||
} else {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDiscard = () => {
|
||||
setShowDiscardAlert(false)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@@ -384,43 +426,9 @@ try {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonSchema)
|
||||
|
||||
if (!parsed.type || parsed.type !== 'function') {
|
||||
setSchemaError('Schema must have a "type" field set to "function"')
|
||||
setActiveSection('schema')
|
||||
return
|
||||
}
|
||||
|
||||
if (!parsed.function || !parsed.function.name) {
|
||||
setSchemaError('Schema must have a "function" object with a "name" field')
|
||||
setActiveSection('schema')
|
||||
return
|
||||
}
|
||||
|
||||
if (!parsed.function.parameters) {
|
||||
setSchemaError('Missing function.parameters object')
|
||||
setActiveSection('schema')
|
||||
return
|
||||
}
|
||||
|
||||
if (!parsed.function.parameters.type) {
|
||||
setSchemaError('Missing parameters.type field')
|
||||
setActiveSection('schema')
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.function.parameters.properties === undefined) {
|
||||
setSchemaError('Missing parameters.properties field')
|
||||
setActiveSection('schema')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed.function.parameters.properties !== 'object' ||
|
||||
parsed.function.parameters.properties === null
|
||||
) {
|
||||
setSchemaError('parameters.properties must be an object')
|
||||
const { isValid, error } = validateSchema(jsonSchema)
|
||||
if (!isValid) {
|
||||
setSchemaError(error)
|
||||
setActiveSection('schema')
|
||||
return
|
||||
}
|
||||
@@ -483,17 +491,9 @@ try {
|
||||
}
|
||||
|
||||
onSave(customTool)
|
||||
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
logger.error('Error saving custom tool:', { error })
|
||||
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
|
||||
|
||||
if (errorMessage.includes('Cannot change function name')) {
|
||||
@@ -512,46 +512,8 @@ try {
|
||||
setJsonSchema(value)
|
||||
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
if (!parsed.type || parsed.type !== 'function') {
|
||||
setSchemaError('Missing "type": "function"')
|
||||
return
|
||||
}
|
||||
|
||||
if (!parsed.function || !parsed.function.name) {
|
||||
setSchemaError('Missing function.name field')
|
||||
return
|
||||
}
|
||||
|
||||
if (!parsed.function.parameters) {
|
||||
setSchemaError('Missing function.parameters object')
|
||||
return
|
||||
}
|
||||
|
||||
if (!parsed.function.parameters.type) {
|
||||
setSchemaError('Missing parameters.type field')
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.function.parameters.properties === undefined) {
|
||||
setSchemaError('Missing parameters.properties field')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed.function.parameters.properties !== 'object' ||
|
||||
parsed.function.parameters.properties === null
|
||||
) {
|
||||
setSchemaError('parameters.properties must be an object')
|
||||
return
|
||||
}
|
||||
|
||||
setSchemaError(null)
|
||||
} catch {
|
||||
setSchemaError('Invalid JSON format')
|
||||
}
|
||||
const { error } = validateSchema(value)
|
||||
setSchemaError(error)
|
||||
} else {
|
||||
setSchemaError(null)
|
||||
}
|
||||
@@ -709,12 +671,12 @@ try {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1))
|
||||
break
|
||||
return
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
return
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -722,14 +684,17 @@ try {
|
||||
const selectedParam = schemaParameters[schemaParamSelectedIndex]
|
||||
handleSchemaParamSelect(selectedParam.name)
|
||||
}
|
||||
break
|
||||
return
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setShowSchemaParams(false)
|
||||
break
|
||||
return
|
||||
case ' ':
|
||||
case 'Tab':
|
||||
setShowSchemaParams(false)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showEnvVars || showTags) {
|
||||
@@ -743,7 +708,7 @@ try {
|
||||
const handleSchemaWandClick = () => {
|
||||
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||
setIsSchemaPromptActive(true)
|
||||
setSchemaPromptInput(schemaPromptSummary ?? '')
|
||||
setSchemaPromptInput('')
|
||||
setTimeout(() => {
|
||||
schemaPromptInputRef.current?.focus()
|
||||
}, 0)
|
||||
@@ -762,7 +727,6 @@ try {
|
||||
const handleSchemaPromptSubmit = () => {
|
||||
const trimmedPrompt = schemaPromptInput.trim()
|
||||
if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||
setSchemaPromptSummary(trimmedPrompt)
|
||||
schemaGeneration.generateStream({ prompt: trimmedPrompt })
|
||||
setSchemaPromptInput('')
|
||||
setIsSchemaPromptActive(false)
|
||||
@@ -782,7 +746,7 @@ try {
|
||||
const handleCodeWandClick = () => {
|
||||
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||
setIsCodePromptActive(true)
|
||||
setCodePromptInput(codePromptSummary ?? '')
|
||||
setCodePromptInput('')
|
||||
setTimeout(() => {
|
||||
codePromptInputRef.current?.focus()
|
||||
}, 0)
|
||||
@@ -801,7 +765,6 @@ try {
|
||||
const handleCodePromptSubmit = () => {
|
||||
const trimmedPrompt = codePromptInput.trim()
|
||||
if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||
setCodePromptSummary(trimmedPrompt)
|
||||
codeGeneration.generateStream({ prompt: trimmedPrompt })
|
||||
setCodePromptInput('')
|
||||
setIsCodePromptActive(false)
|
||||
@@ -846,19 +809,8 @@ try {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent
|
||||
size='xl'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setShowEnvVars(false)
|
||||
setShowTags(false)
|
||||
setShowSchemaParams(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal open={open} onOpenChange={handleCloseAttempt}>
|
||||
<ModalContent size='xl'>
|
||||
<ModalHeader>{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}</ModalHeader>
|
||||
|
||||
<ModalTabs
|
||||
@@ -1211,7 +1163,7 @@ try {
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleSave}
|
||||
disabled={!isSchemaValid || !!schemaError}
|
||||
disabled={!isSchemaValid || !!schemaError || !hasChanges}
|
||||
>
|
||||
{isEditing ? 'Update Tool' : 'Save Tool'}
|
||||
</Button>
|
||||
@@ -1248,6 +1200,26 @@ try {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showDiscardAlert} onOpenChange={setShowDiscardAlert}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
You have unsaved changes to this tool. Are you sure you want to discard your changes
|
||||
and close the editor?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowDiscardAlert(false)}>
|
||||
Keep Editing
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleConfirmDiscard}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,9 @@ export function Editor() {
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
)
|
||||
const displayAdvancedOptions = advancedMode || advancedValuesPresent
|
||||
const displayAdvancedOptions = userPermissions.canEdit
|
||||
? advancedMode
|
||||
: advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
|
||||
@@ -1072,7 +1072,7 @@ export function AccessControl() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
You have unsaved changes. Do you want to save them before closing?
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function CreateApiKeyModal({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{keyType === 'workspace'
|
||||
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
|
||||
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
|
||||
@@ -218,7 +218,7 @@ export function CreateApiKeyModal({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
This is the only time you will see your API key.{' '}
|
||||
<span className='font-semibold text-[var(--text-primary)]'>
|
||||
Copy it now and store it securely.
|
||||
|
||||
@@ -222,7 +222,7 @@ export function BYOK() {
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
This key will be used for all {PROVIDERS.find((p) => p.id === editingProvider)?.name}{' '}
|
||||
requests in this workspace. Your key is encrypted and stored securely.
|
||||
</p>
|
||||
@@ -308,7 +308,7 @@ export function BYOK() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API Key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete the{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
|
||||
|
||||
@@ -214,7 +214,7 @@ export function Copilot() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
This key will allow access to Copilot features. Make sure to copy it after creation as
|
||||
you won't be able to see it again.
|
||||
</p>
|
||||
@@ -276,7 +276,7 @@ export function Copilot() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
This is the only time you will see your API key.{' '}
|
||||
<span className='font-semibold text-[var(--text-primary)]'>
|
||||
Copy it now and store it securely.
|
||||
|
||||
@@ -824,7 +824,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{hasConflicts || hasInvalidKeys
|
||||
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
|
||||
: 'You have unsaved changes. Do you want to save them before closing?'}
|
||||
|
||||
@@ -603,7 +603,7 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Reset Password</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
A password reset link will be sent to{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>.
|
||||
Click the link in the email to create a new password.
|
||||
|
||||
@@ -64,7 +64,7 @@ export function TeamSeats({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>{description}</p>
|
||||
|
||||
<div className='mt-[16px] flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='seats' className='text-[12px]'>
|
||||
|
||||
@@ -25,9 +25,11 @@ const GRID_COLUMNS = 6
|
||||
function ColorGrid({
|
||||
hexInput,
|
||||
setHexInput,
|
||||
onColorChange,
|
||||
}: {
|
||||
hexInput: string
|
||||
setHexInput: (color: string) => void
|
||||
onColorChange?: (color: string) => void
|
||||
}) {
|
||||
const { isInFolder } = usePopoverContext()
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
@@ -72,7 +74,9 @@ function ColorGrid({
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHexInput(WORKFLOW_COLORS[index].color)
|
||||
onColorChange?.(WORKFLOW_COLORS[index].color)
|
||||
return
|
||||
default:
|
||||
return
|
||||
@@ -83,7 +87,7 @@ function ColorGrid({
|
||||
buttonRefs.current[newIndex]?.focus()
|
||||
}
|
||||
},
|
||||
[setHexInput]
|
||||
[setHexInput, onColorChange]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -105,8 +109,10 @@ function ColorGrid({
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-[4px] focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1 focus:ring-offset-[#1b1b1b]',
|
||||
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
|
||||
'h-[20px] w-[20px] rounded-[4px] outline-none ring-white ring-offset-0',
|
||||
(focusedIndex === index ||
|
||||
(focusedIndex === -1 && hexInput.toLowerCase() === color.toLowerCase())) &&
|
||||
'ring-[1.5px]'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
@@ -450,7 +456,11 @@ export function ContextMenu({
|
||||
>
|
||||
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
|
||||
{/* Preset colors with keyboard navigation */}
|
||||
<ColorGrid hexInput={hexInput} setHexInput={setHexInput} />
|
||||
<ColorGrid
|
||||
hexInput={hexInput}
|
||||
setHexInput={setHexInput}
|
||||
onColorChange={onColorChange}
|
||||
/>
|
||||
|
||||
{/* Hex input */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
|
||||
@@ -459,6 +459,7 @@ export function WorkspaceHeader({
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
setIsListRenaming(true)
|
||||
|
||||
@@ -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',
|
||||
@@ -233,6 +245,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',
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -123,3 +125,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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
191
apps/sim/blocks/blocks/textract.ts
Normal file
191
apps/sim/blocks/blocks/textract.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -460,6 +460,13 @@ const PopoverContent = React.forwardRef<
|
||||
const content = contentRef.current
|
||||
if (!content) return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
const isInputFocused =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.getAttribute('contenteditable') === 'true'
|
||||
if (isInputFocused) return
|
||||
|
||||
const items = content.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([aria-disabled="true"])'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
<!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.svg" width="79" alt="Sim Logo" style="display:block;width:79px;" border="0">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Intro Text -->
|
||||
<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:25px;text-align:left;">
|
||||
<p style="margin:0;">We've been heads down building features to make Sim the best way to build, deploy, and observe AI workflows. Here's what's new: <strong>Copilot</strong> for context-aware assistance, <strong>MCP deployment</strong> to expose workflows as tools, a complete <strong>Logs & Dashboard</strong> for execution observability, and <strong>realtime collaboration</strong> so your team can build together.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:15px;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:6px 14px;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>
|
||||
|
||||
<!-- 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;">­</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Section Header: What's New -->
|
||||
<tr>
|
||||
<td align="left" style="color:#5c5c5c;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;word-break:break-word;line-height:1;padding-bottom:11px;padding-top:9px;text-align:left;">
|
||||
<p style="margin:0;"><em>Build smarter, ship faster</em></p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:16px;word-break:break-word;line-height:1;padding-bottom:15px;text-align:left;">
|
||||
<h2 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:32px;word-break:break-word;">What's new in Sim</h2>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- FEATURE 1: Copilot -->
|
||||
<tr>
|
||||
<td align="center" style="padding-top:15px;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.gif" 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:20px;padding-top:25px;text-align:left;">
|
||||
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>Copilot</strong></h4>
|
||||
<br>
|
||||
<p style="margin:0;font-size:16px;">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="center" style="padding-top:15px;">
|
||||
<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:24px;font-weight:bold;">MCP Deployment</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:8px;">
|
||||
<p style="margin:0;color:#a0a0a0;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:14px;">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:20px;padding-top:25px;text-align:left;">
|
||||
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>MCP Deployment</strong></h4>
|
||||
<br>
|
||||
<p style="margin:0;font-size:16px;">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="center" style="padding-top:15px;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.jpeg" 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:20px;padding-top:25px;text-align:left;">
|
||||
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>Logs & Dashboard</strong></h4>
|
||||
<br>
|
||||
<p style="margin:0;font-size:16px;">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="center" style="padding-top:15px;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/studio/multiplayer/cover.png" 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:20px;padding-top:25px;text-align:left;">
|
||||
<h4 style="margin:0;color:#2d2d2d;font-family:-apple-system,'SF Pro Display','SF Pro Text','Helvetica',sans-serif;font-size:18px;word-break:break-word;"><strong>Realtime Collaboration</strong></h4>
|
||||
<br>
|
||||
<p style="margin:0;font-size:16px;">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: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;">­</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:6px 14px;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>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation" style="background-color:#F7F9FC;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellspacing="0" cellpadding="0" border="0" role="presentation" style="max-width:600px;width:100%;">
|
||||
<tbody>
|
||||
<!-- Top spacer -->
|
||||
<tr>
|
||||
<td height="32" style="font-size:1px;line-height:1px;"> </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;"> </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;"> </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;"> </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>
|
||||
•
|
||||
<a href="https://sim.ai/terms" style="color:#737373;text-decoration:underline;font-weight:normal;" rel="noopener noreferrer" target="_blank">Terms of Service</a>
|
||||
•
|
||||
<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;"> </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;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -377,10 +377,7 @@ function buildManualTriggerOutput(
|
||||
return mergeFilesIntoOutput(output, workflowInput)
|
||||
}
|
||||
|
||||
function buildIntegrationTriggerOutput(
|
||||
_finalInput: unknown,
|
||||
workflowInput: unknown
|
||||
): NormalizedBlockOutput {
|
||||
function buildIntegrationTriggerOutput(workflowInput: unknown): NormalizedBlockOutput {
|
||||
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {}
|
||||
}
|
||||
|
||||
@@ -430,7 +427,7 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali
|
||||
return buildManualTriggerOutput(finalInput, workflowInput)
|
||||
|
||||
case StartBlockPath.EXTERNAL_TRIGGER:
|
||||
return buildIntegrationTriggerOutput(finalInput, workflowInput)
|
||||
return buildIntegrationTriggerOutput(workflowInput)
|
||||
|
||||
case StartBlockPath.LEGACY_STARTER:
|
||||
return buildLegacyStarterOutput(
|
||||
|
||||
@@ -755,12 +755,11 @@ export interface BulkChunkOperationParams {
|
||||
}
|
||||
|
||||
export interface BulkChunkOperationResult {
|
||||
operation: string
|
||||
successCount: number
|
||||
failedCount: number
|
||||
results: Array<{
|
||||
operation: string
|
||||
chunkIds: string[]
|
||||
}>
|
||||
errorCount: number
|
||||
processed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export async function bulkChunkOperation({
|
||||
|
||||
@@ -897,6 +897,17 @@ export function useCollaborativeWorkflow() {
|
||||
// Collect all edge IDs to remove
|
||||
const edgeIdsToRemove = updates.flatMap((u) => u.affectedEdges.map((e) => e.id))
|
||||
if (edgeIdsToRemove.length > 0) {
|
||||
const edgeOperationId = crypto.randomUUID()
|
||||
addToQueue({
|
||||
id: edgeOperationId,
|
||||
operation: {
|
||||
operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
|
||||
target: OPERATION_TARGETS.EDGES,
|
||||
payload: { ids: edgeIdsToRemove },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
useWorkflowStore.getState().batchRemoveEdges(edgeIdsToRemove)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createPinnedUrl,
|
||||
validateAirtableId,
|
||||
validateAlphanumericId,
|
||||
validateAwsRegion,
|
||||
validateEnum,
|
||||
validateExternalUrl,
|
||||
validateFileExtension,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
validateNumericId,
|
||||
validatePathSegment,
|
||||
validateProxyUrl,
|
||||
validateS3BucketName,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation'
|
||||
import { sanitizeForLogging } from '@/lib/core/security/redaction'
|
||||
@@ -1192,3 +1194,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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,12 @@ import { createLogger } from '@sim/logger'
|
||||
|
||||
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
|
||||
@@ -65,7 +59,6 @@ export function validatePathSegment(
|
||||
customPattern,
|
||||
} = options
|
||||
|
||||
// Check for null/undefined
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -73,7 +66,6 @@ export function validatePathSegment(
|
||||
}
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (value.length > maxLength) {
|
||||
logger.warn('Path segment exceeds maximum length', {
|
||||
paramName,
|
||||
@@ -86,7 +78,6 @@ export function validatePathSegment(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for null bytes (potential for bypass attacks)
|
||||
if (value.includes('\0') || value.includes('%00')) {
|
||||
logger.warn('Path segment contains null bytes', { paramName })
|
||||
return {
|
||||
@@ -95,7 +86,6 @@ export function validatePathSegment(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for path traversal patterns
|
||||
const pathTraversalPatterns = [
|
||||
'..',
|
||||
'./',
|
||||
@@ -124,7 +114,6 @@ export function validatePathSegment(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for directory separators
|
||||
if (value.includes('/') || value.includes('\\')) {
|
||||
logger.warn('Path segment contains directory separators', { paramName })
|
||||
return {
|
||||
@@ -133,7 +122,6 @@ export function validatePathSegment(
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom pattern if provided
|
||||
if (customPattern) {
|
||||
if (!customPattern.test(value)) {
|
||||
logger.warn('Path segment failed custom pattern validation', {
|
||||
@@ -148,7 +136,6 @@ export function validatePathSegment(
|
||||
return { isValid: true, sanitized: value }
|
||||
}
|
||||
|
||||
// Build allowed character pattern
|
||||
let pattern = '^[a-zA-Z0-9'
|
||||
if (allowHyphens) pattern += '\\-'
|
||||
if (allowUnderscores) pattern += '_'
|
||||
@@ -947,6 +934,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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,17 @@ export interface SimplifiedImapEmail {
|
||||
}
|
||||
|
||||
export interface ImapWebhookPayload {
|
||||
messageId: string
|
||||
subject: string
|
||||
from: string
|
||||
to: string
|
||||
cc: string
|
||||
date: string | null
|
||||
bodyText: string
|
||||
bodyHtml: string
|
||||
mailbox: string
|
||||
hasAttachments: boolean
|
||||
attachments: ImapAttachment[]
|
||||
email: SimplifiedImapEmail
|
||||
timestamp: string
|
||||
}
|
||||
@@ -613,6 +624,17 @@ async function processEmails(
|
||||
}
|
||||
|
||||
const payload: ImapWebhookPayload = {
|
||||
messageId: simplifiedEmail.messageId,
|
||||
subject: simplifiedEmail.subject,
|
||||
from: simplifiedEmail.from,
|
||||
to: simplifiedEmail.to,
|
||||
cc: simplifiedEmail.cc,
|
||||
date: simplifiedEmail.date,
|
||||
bodyText: simplifiedEmail.bodyText,
|
||||
bodyHtml: simplifiedEmail.bodyHtml,
|
||||
mailbox: simplifiedEmail.mailbox,
|
||||
hasAttachments: simplifiedEmail.hasAttachments,
|
||||
attachments: simplifiedEmail.attachments,
|
||||
email: simplifiedEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ interface RssFeed {
|
||||
}
|
||||
|
||||
export interface RssWebhookPayload {
|
||||
title?: string
|
||||
link?: string
|
||||
pubDate?: string
|
||||
item: RssItem
|
||||
feed: {
|
||||
title?: string
|
||||
@@ -349,6 +352,9 @@ async function processRssItems(
|
||||
`${webhookData.id}:${itemGuid}`,
|
||||
async () => {
|
||||
const payload: RssWebhookPayload = {
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
pubDate: item.pubDate,
|
||||
item: {
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
|
||||
@@ -686,6 +686,9 @@ export async function formatWebhookInput(
|
||||
if (foundWebhook.provider === 'rss') {
|
||||
if (body && typeof body === 'object' && 'item' in body) {
|
||||
return {
|
||||
title: body.title,
|
||||
link: body.link,
|
||||
pubDate: body.pubDate,
|
||||
item: body.item,
|
||||
feed: body.feed,
|
||||
timestamp: body.timestamp,
|
||||
@@ -697,6 +700,17 @@ export async function formatWebhookInput(
|
||||
if (foundWebhook.provider === 'imap') {
|
||||
if (body && typeof body === 'object' && 'email' in body) {
|
||||
return {
|
||||
messageId: body.messageId,
|
||||
subject: body.subject,
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
cc: body.cc,
|
||||
date: body.date,
|
||||
bodyText: body.bodyText,
|
||||
bodyHtml: body.bodyHtml,
|
||||
mailbox: body.mailbox,
|
||||
hasAttachments: body.hasAttachments,
|
||||
attachments: body.attachments,
|
||||
email: body.email,
|
||||
timestamp: body.timestamp,
|
||||
}
|
||||
|
||||
@@ -545,14 +545,26 @@ function generateOutputPathsWithTypes(
|
||||
* Gets the tool outputs for a block operation.
|
||||
*
|
||||
* @param blockConfig - The block configuration containing tools config
|
||||
* @param operation - The selected operation for the tool
|
||||
* @param subBlocks - SubBlock values to pass to the tool selector
|
||||
* @returns Outputs schema for the tool, or empty object on error
|
||||
*/
|
||||
export function getToolOutputs(blockConfig: BlockConfig, operation: string): Record<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)
|
||||
@@ -560,7 +572,7 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec
|
||||
|
||||
return toolConfig.outputs
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get tool outputs for operation', { operation, error })
|
||||
logger.warn('Failed to get tool outputs', { error })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -569,16 +581,14 @@ export function getToolOutputs(blockConfig: BlockConfig, operation: string): Rec
|
||||
* Generates output paths for a tool-based block.
|
||||
*
|
||||
* @param blockConfig - The block configuration containing tools config
|
||||
* @param operation - The selected operation for the tool
|
||||
* @param subBlocks - Optional subBlock values for condition evaluation
|
||||
* @param subBlocks - SubBlock values for tool selection and condition evaluation
|
||||
* @returns Array of output paths for the tool, or empty array on error
|
||||
*/
|
||||
export function getToolOutputPaths(
|
||||
blockConfig: BlockConfig,
|
||||
operation: string,
|
||||
subBlocks?: Record<string, SubBlockWithValue>
|
||||
): string[] {
|
||||
const outputs = getToolOutputs(blockConfig, operation)
|
||||
const outputs = getToolOutputs(blockConfig, subBlocks)
|
||||
|
||||
if (!outputs || Object.keys(outputs).length === 0) return []
|
||||
|
||||
@@ -613,16 +623,16 @@ export function getOutputPathsFromSchema(outputs: Record<string, 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)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
convertToGeminiFormat,
|
||||
convertUsageMetadata,
|
||||
createReadableStreamFromGeminiStream,
|
||||
ensureStructResponse,
|
||||
extractFunctionCallPart,
|
||||
extractTextContent,
|
||||
mapToThinkingLevel,
|
||||
@@ -104,7 +105,7 @@ async function executeToolCall(
|
||||
const duration = toolCallEndTime - toolCallStartTime
|
||||
|
||||
const resultContent: Record<string, unknown> = result.success
|
||||
? (result.output as Record<string, unknown>)
|
||||
? ensureStructResponse(result.output)
|
||||
: { error: true, message: result.error || 'Tool execution failed', tool: toolName }
|
||||
|
||||
const toolCall: FunctionCallResponse = {
|
||||
|
||||
453
apps/sim/providers/google/utils.test.ts
Normal file
453
apps/sim/providers/google/utils.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { convertToGeminiFormat, ensureStructResponse } from '@/providers/google/utils'
|
||||
import type { ProviderRequest } from '@/providers/types'
|
||||
|
||||
describe('ensureStructResponse', () => {
|
||||
describe('should return objects unchanged', () => {
|
||||
it('should return plain object unchanged', () => {
|
||||
const input = { key: 'value', nested: { a: 1 } }
|
||||
const result = ensureStructResponse(input)
|
||||
expect(result).toBe(input) // Same reference
|
||||
expect(result).toEqual({ key: 'value', nested: { a: 1 } })
|
||||
})
|
||||
|
||||
it('should return empty object unchanged', () => {
|
||||
const input = {}
|
||||
const result = ensureStructResponse(input)
|
||||
expect(result).toBe(input)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('should wrap primitive values in { value: ... }', () => {
|
||||
it('should wrap boolean true', () => {
|
||||
const result = ensureStructResponse(true)
|
||||
expect(result).toEqual({ value: true })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap boolean false', () => {
|
||||
const result = ensureStructResponse(false)
|
||||
expect(result).toEqual({ value: false })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap string', () => {
|
||||
const result = ensureStructResponse('success')
|
||||
expect(result).toEqual({ value: 'success' })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap empty string', () => {
|
||||
const result = ensureStructResponse('')
|
||||
expect(result).toEqual({ value: '' })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap number', () => {
|
||||
const result = ensureStructResponse(42)
|
||||
expect(result).toEqual({ value: 42 })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap zero', () => {
|
||||
const result = ensureStructResponse(0)
|
||||
expect(result).toEqual({ value: 0 })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap null', () => {
|
||||
const result = ensureStructResponse(null)
|
||||
expect(result).toEqual({ value: null })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap undefined', () => {
|
||||
const result = ensureStructResponse(undefined)
|
||||
expect(result).toEqual({ value: undefined })
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('should wrap arrays in { value: ... }', () => {
|
||||
it('should wrap array of strings', () => {
|
||||
const result = ensureStructResponse(['a', 'b', 'c'])
|
||||
expect(result).toEqual({ value: ['a', 'b', 'c'] })
|
||||
expect(typeof result).toBe('object')
|
||||
expect(Array.isArray(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('should wrap array of objects', () => {
|
||||
const result = ensureStructResponse([{ id: 1 }, { id: 2 }])
|
||||
expect(result).toEqual({ value: [{ id: 1 }, { id: 2 }] })
|
||||
expect(typeof result).toBe('object')
|
||||
expect(Array.isArray(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('should wrap empty array', () => {
|
||||
const result = ensureStructResponse([])
|
||||
expect(result).toEqual({ value: [] })
|
||||
expect(typeof result).toBe('object')
|
||||
expect(Array.isArray(result)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle nested objects correctly', () => {
|
||||
const input = { a: { b: { c: 1 } }, d: [1, 2, 3] }
|
||||
const result = ensureStructResponse(input)
|
||||
expect(result).toBe(input) // Same reference, unchanged
|
||||
})
|
||||
|
||||
it('should handle object with array property correctly', () => {
|
||||
const input = { items: ['a', 'b'], count: 2 }
|
||||
const result = ensureStructResponse(input)
|
||||
expect(result).toBe(input) // Same reference, unchanged
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertToGeminiFormat', () => {
|
||||
describe('tool message handling', () => {
|
||||
it('should convert tool message with object response correctly', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city": "London"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'get_weather',
|
||||
tool_call_id: 'call_123',
|
||||
content: '{"temperature": 20, "condition": "sunny"}',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
expect(toolResponseContent).toBeDefined()
|
||||
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
expect(functionResponse?.response).toEqual({ temperature: 20, condition: 'sunny' })
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
})
|
||||
|
||||
it('should wrap boolean true response in an object for Gemini compatibility', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Check if user exists' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_456',
|
||||
type: 'function',
|
||||
function: { name: 'user_exists', arguments: '{"userId": "123"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'user_exists',
|
||||
tool_call_id: 'call_456',
|
||||
content: 'true', // Boolean true as JSON string
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
expect(toolResponseContent).toBeDefined()
|
||||
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).not.toBe(true)
|
||||
expect(functionResponse?.response).toEqual({ value: true })
|
||||
})
|
||||
|
||||
it('should wrap boolean false response in an object for Gemini compatibility', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Check if user exists' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_789',
|
||||
type: 'function',
|
||||
function: { name: 'user_exists', arguments: '{"userId": "999"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'user_exists',
|
||||
tool_call_id: 'call_789',
|
||||
content: 'false', // Boolean false as JSON string
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).toEqual({ value: false })
|
||||
})
|
||||
|
||||
it('should wrap string response in an object for Gemini compatibility', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Get status' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_str',
|
||||
type: 'function',
|
||||
function: { name: 'get_status', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'get_status',
|
||||
tool_call_id: 'call_str',
|
||||
content: '"success"', // String as JSON
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).toEqual({ value: 'success' })
|
||||
})
|
||||
|
||||
it('should wrap number response in an object for Gemini compatibility', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Get count' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_num',
|
||||
type: 'function',
|
||||
function: { name: 'get_count', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'get_count',
|
||||
tool_call_id: 'call_num',
|
||||
content: '42', // Number as JSON
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).toEqual({ value: 42 })
|
||||
})
|
||||
|
||||
it('should wrap null response in an object for Gemini compatibility', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Get data' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_null',
|
||||
type: 'function',
|
||||
function: { name: 'get_data', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'get_data',
|
||||
tool_call_id: 'call_null',
|
||||
content: 'null', // null as JSON
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).toEqual({ value: null })
|
||||
})
|
||||
|
||||
it('should keep array response as-is since arrays are valid Struct values', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Get items' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_arr',
|
||||
type: 'function',
|
||||
function: { name: 'get_items', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'get_items',
|
||||
tool_call_id: 'call_arr',
|
||||
content: '["item1", "item2"]', // Array as JSON
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).toEqual({ value: ['item1', 'item2'] })
|
||||
})
|
||||
|
||||
it('should handle invalid JSON by wrapping in output object', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Get data' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_invalid',
|
||||
type: 'function',
|
||||
function: { name: 'get_data', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'get_data',
|
||||
tool_call_id: 'call_invalid',
|
||||
content: 'not valid json {',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
expect(functionResponse?.response).toEqual({ output: 'not valid json {' })
|
||||
})
|
||||
|
||||
it('should handle empty content by wrapping in output object', () => {
|
||||
const request: ProviderRequest = {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_empty',
|
||||
type: 'function',
|
||||
function: { name: 'do_action', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'do_action',
|
||||
tool_call_id: 'call_empty',
|
||||
content: '', // Empty content - falls back to default '{}'
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = convertToGeminiFormat(request)
|
||||
|
||||
const toolResponseContent = result.contents.find(
|
||||
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
|
||||
)
|
||||
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
|
||||
?.functionResponse as { response?: unknown }
|
||||
|
||||
expect(typeof functionResponse?.response).toBe('object')
|
||||
// Empty string is not valid JSON, so it falls back to { output: "" }
|
||||
expect(functionResponse?.response).toEqual({ output: '' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,22 @@ import { trackForcedToolUsage } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('GoogleUtils')
|
||||
|
||||
/**
|
||||
* Ensures a value is a valid object for Gemini's functionResponse.response field.
|
||||
* Gemini's API requires functionResponse.response to be a google.protobuf.Struct,
|
||||
* which must be an object with string keys. Primitive values (boolean, string,
|
||||
* number, null) and arrays are wrapped in { value: ... }.
|
||||
*
|
||||
* @param value - The value to ensure is a Struct-compatible object
|
||||
* @returns A Record<string, unknown> suitable for functionResponse.response
|
||||
*/
|
||||
export function ensureStructResponse(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return { value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage metadata for Google Gemini responses
|
||||
*/
|
||||
@@ -180,7 +196,8 @@ export function convertToGeminiFormat(request: ProviderRequest): {
|
||||
}
|
||||
let responseData: Record<string, unknown>
|
||||
try {
|
||||
responseData = JSON.parse(message.content ?? '{}')
|
||||
const parsed = JSON.parse(message.content ?? '{}')
|
||||
responseData = ensureStructResponse(parsed)
|
||||
} catch {
|
||||
responseData = { output: message.content }
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 382 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg width="79" height="79" viewBox="0 0 79 79" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_3011_11314)">
|
||||
<path d="M63 2.5H16C9.37258 2.5 4 7.87258 4 14.5V61.5C4 68.1274 9.37258 73.5 16 73.5H63C69.6274 73.5 75 68.1274 75 61.5V14.5C75 7.87258 69.6274 2.5 63 2.5Z" fill="#FEFEFE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.0264 35.5016C39.0264 36.0181 38.822 36.514 38.4593 36.8792L38.4073 36.9316C38.0449 37.298 37.5523 37.5028 37.0394 37.5028H25.4362C24.3675 37.5028 23.5 38.3754 23.5 39.4515V51.9998C23.5 53.0761 24.3675 53.9487 25.4362 53.9487H37.8946C38.9632 53.9487 39.8296 53.0761 39.8296 51.9998V40.2513C39.8296 39.7723 40.0182 39.3128 40.3548 38.9739C40.6903 38.6359 41.1466 38.4458 41.6223 38.4458H53.4199C54.4885 38.4458 55.3548 37.5733 55.3548 36.4971V23.9488C55.3548 22.8726 54.4885 22 53.4199 22H40.9614C39.8928 22 39.0264 22.8726 39.0264 23.9488V35.5016ZM42.8072 24.6712H51.5731C52.1976 24.6712 52.7026 25.1809 52.7026 25.8088V34.6371C52.7026 35.265 52.1976 35.7747 51.5731 35.7747H42.8072C42.1837 35.7747 41.6776 35.265 41.6776 34.6371V25.8088C41.6776 25.1809 42.1837 24.6712 42.8072 24.6712Z" fill="#33C482"/>
|
||||
<path d="M53.4366 40.6562H44.1739C43.051 40.6562 42.1406 41.5731 42.1406 42.7041V51.9191C42.1406 53.05 43.051 53.9669 44.1739 53.9669H53.4366C54.5596 53.9669 55.47 53.05 55.47 51.9191V42.7041C55.47 41.5731 54.5596 40.6562 53.4366 40.6562Z" fill="#33C482"/>
|
||||
<path d="M53.4366 40.6133H44.1739C43.051 40.6133 42.1406 41.53 42.1406 42.6611V51.876C42.1406 53.0069 43.051 53.9236 44.1739 53.9236H53.4366C54.5596 53.9236 55.47 53.0069 55.47 51.876V42.6611C55.47 41.53 54.5596 40.6133 53.4366 40.6133Z" fill="url(#paint0_linear_3011_11314)" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_3011_11314" x="0" y="0" width="79" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1.5"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3011_11314"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3011_11314" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_3011_11314" x1="42.1406" y1="40.6133" x2="50.2327" y2="48.6865" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -337,10 +337,11 @@ async function handleBlockOperationTx(
|
||||
const currentData = currentBlock?.data || {}
|
||||
|
||||
// Update data with parentId and extent
|
||||
const { parentId: _removedParentId, extent: _removedExtent, ...restData } = currentData
|
||||
const updatedData = isRemovingFromParent
|
||||
? {} // Clear data entirely when removing from parent
|
||||
? restData
|
||||
: {
|
||||
...currentData,
|
||||
...restData,
|
||||
...(payload.parentId ? { parentId: payload.parentId } : {}),
|
||||
...(payload.extent ? { extent: payload.extent } : {}),
|
||||
}
|
||||
@@ -828,10 +829,11 @@ async function handleBlocksOperationTx(
|
||||
|
||||
const currentData = currentBlock?.data || {}
|
||||
|
||||
const { parentId: _removedParentId, extent: _removedExtent, ...restData } = currentData
|
||||
const updatedData = isRemovingFromParent
|
||||
? {}
|
||||
? restData
|
||||
: {
|
||||
...currentData,
|
||||
...restData,
|
||||
...(parentId ? { parentId, extent: 'parent' } : {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileParserTool } from '@/tools/file/parser'
|
||||
import { fileParserTool, fileParserV2Tool } from '@/tools/file/parser'
|
||||
|
||||
export const fileParseTool = fileParserTool
|
||||
export { fileParserV2Tool }
|
||||
|
||||
@@ -146,3 +146,25 @@ export const fileParserTool: ToolConfig<FileParserInput, FileParserOutput> = {
|
||||
combinedContent: { type: 'string', description: 'Combined content of all parsed files' },
|
||||
},
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { mistralParserTool } from '@/tools/mistral/parser'
|
||||
import { mistralParserTool, mistralParserV2Tool } from '@/tools/mistral/parser'
|
||||
|
||||
export { mistralParserTool }
|
||||
export { mistralParserTool, mistralParserV2Tool }
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
2
apps/sim/tools/textract/index.ts
Normal file
2
apps/sim/tools/textract/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { textractParserTool } from '@/tools/textract/parser'
|
||||
export * from '@/tools/textract/types'
|
||||
288
apps/sim/tools/textract/parser.ts
Normal file
288
apps/sim/tools/textract/parser.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
110
apps/sim/tools/textract/types.ts
Normal file
110
apps/sim/tools/textract/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user