diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index d8ebc1641e..2c1bcb62bf 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4678,3 +4678,349 @@ export function BedrockIcon(props: SVGProps) { ) } + +export function ReductoIcon(props: SVGProps) { + return ( + + + + ) +} + +export function PulseIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index d3a284093f..7927a555e9 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -84,9 +84,11 @@ import { PolymarketIcon, PostgresIcon, PosthogIcon, + PulseIcon, QdrantIcon, RDSIcon, RedditIcon, + ReductoIcon, ResendIcon, S3Icon, SalesforceIcon, @@ -208,9 +210,11 @@ export const blockTypeToIconMap: Record = { polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, + pulse: PulseIcon, qdrant: QdrantIcon, rds: RDSIcon, reddit: RedditIcon, + reducto: ReductoIcon, resend: ResendIcon, s3: S3Icon, salesforce: SalesforceIcon, diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index d1d88a5116..ec3178013b 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -79,9 +79,11 @@ "polymarket", "postgresql", "posthog", + "pulse", "qdrant", "rds", "reddit", + "reducto", "resend", "s3", "salesforce", diff --git a/apps/docs/content/docs/en/tools/pulse.mdx b/apps/docs/content/docs/en/tools/pulse.mdx new file mode 100644 index 0000000000..92d2319e00 --- /dev/null +++ b/apps/docs/content/docs/en/tools/pulse.mdx @@ -0,0 +1,72 @@ +--- +title: Pulse +description: Extract text from documents using Pulse OCR +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow. + +With Pulse, you can: + +- **Extract text from documents**: Quickly convert scanned PDFs, images, and Office documents to usable text, markdown, or JSON. +- **Process documents by URL or upload**: Simply provide a file URL or use upload to extract text from local documents or remote resources. +- **Flexible output formats**: Choose between markdown, plain text, or JSON representations of the extracted content for downstream processing. +- **Selective page processing**: Specify a range of pages to process, reducing processing time and cost when you only need part of a document. +- **Figure and table extraction**: Optionally extract figures and tables, with automatic caption and description generation for populated context. +- **Get processing insights**: Receive detailed metadata on each job, including file type, page count, processing time, and more. +- **Integration-ready responses**: Incorporate extracted content into research, workflow automation, or data analysis pipelines. + +Ideal for automating tedious document review, enabling content summarization, research, and more, Pulse Parser brings real-world documents into the digital workflow era. + +If you need accurate, scalable, and developer-friendly document parsing capabilities—across formats, languages, and layouts—Pulse empowers your agents to read the world. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload. + + + +## Tools + +### `pulse_parser` + +Parse documents (PDF, images, Office docs) using Pulse OCR API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filePath` | string | Yes | URL to a document to be processed | +| `fileUpload` | object | No | File upload data from file-upload component | +| `pages` | string | No | Page range to process \(1-indexed, e.g., "1-2,5"\) | +| `extractFigure` | boolean | No | Enable figure extraction from the document | +| `figureDescription` | boolean | No | Generate descriptions/captions for extracted figures | +| `returnHtml` | boolean | No | Include HTML in the response | +| `chunking` | string | No | Chunking strategies \(comma-separated: semantic, header, page, recursive\) | +| `chunkSize` | number | No | Maximum characters per chunk when chunking is enabled | +| `apiKey` | string | Yes | Pulse API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `markdown` | string | Extracted content in markdown format | +| `page_count` | number | Number of pages in the document | +| `job_id` | string | Unique job identifier | +| `bounding_boxes` | json | Bounding box layout information | +| `extraction_url` | string | URL for extraction results \(for large documents\) | +| `html` | string | HTML content if requested | +| `structured_output` | json | Structured output if schema was provided | +| `chunks` | json | Chunked content if chunking was enabled | +| `figures` | json | Extracted figures if figure extraction was enabled | + + diff --git a/apps/docs/content/docs/en/tools/reducto.mdx b/apps/docs/content/docs/en/tools/reducto.mdx new file mode 100644 index 0000000000..ef004cf898 --- /dev/null +++ b/apps/docs/content/docs/en/tools/reducto.mdx @@ -0,0 +1,63 @@ +--- +title: Reducto +description: Extract text from PDF documents +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +The [Reducto](https://reducto.ai/) tool enables fast and accurate extraction of text and data from PDF documents via OCR (Optical Character Recognition). Reducto is designed for agent workflows, making it easy to process uploaded or linked PDFs and transform their contents into ready-to-use information. + +With the Reducto tool, you can: + +- **Extract text and tables from PDFs**: Quickly convert scanned or digital PDFs to text, markdown, or structured JSON. +- **Parse PDFs from uploads or URLs**: Process documents either by uploading a PDF or specifying a direct URL. +- **Customize output formatting**: Choose your preferred output format—markdown, plain text, or JSON—and specify table formats as markdown or HTML. +- **Select specific pages**: Optionally extract content from particular pages to optimize processing and focus on what matters. +- **Receive detailed processing metadata**: Alongside extracted content, get job details, processing times, source file info, page counts, and OCR usage stats for audit and automation. + +Whether you’re automating workflow steps, extracting business-critical information, or unlocking archival documents for search and analysis, Reducto’s OCR parser gives you structured, actionable data from even the most complex PDFs. + +Looking for reliable and scalable PDF parsing? Reducto is optimized for developer and agent use—providing accuracy, speed, and flexibility for modern document understanding. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL. + + + +## Tools + +### `reducto_parser` + +Parse PDF documents using Reducto OCR API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `filePath` | string | Yes | URL to a PDF document to be processed | +| `fileUpload` | object | No | File upload data from file-upload component | +| `pages` | array | No | Specific pages to process \(1-indexed page numbers\) | +| `tableOutputFormat` | string | No | Table output format \(html or markdown\). Defaults to markdown. | +| `apiKey` | string | Yes | Reducto API key \(REDUCTO_API_KEY\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `job_id` | string | Unique identifier for the processing job | +| `duration` | number | Processing time in seconds | +| `usage` | json | Resource consumption data | +| `result` | json | Parsed document content with chunks and blocks | +| `pdf_url` | string | Storage URL of converted PDF | +| `studio_link` | string | Link to Reducto studio interface | + + diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts new file mode 100644 index 0000000000..7c2f340b1b --- /dev/null +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { StorageService } from '@/lib/uploads' +import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PulseParseAPI') + +const PulseParseSchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + filePath: z.string().min(1, 'File path is required'), + pages: z.string().optional(), + extractFigure: z.boolean().optional(), + figureDescription: z.boolean().optional(), + returnHtml: z.boolean().optional(), + chunking: z.string().optional(), + chunkSize: z.number().optional(), +}) + +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 Pulse 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 = PulseParseSchema.parse(body) + + logger.info(`[${requestId}] Pulse parse request`, { + filePath: validatedData.filePath, + isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'), + userId, + }) + + let fileUrl = validatedData.filePath + + if (validatedData.filePath?.includes('/api/files/serve/')) { + try { + const storageKey = extractStorageKey(validatedData.filePath) + const context = inferContextFromKey(storageKey) + + const hasAccess = await verifyFileAccess(storageKey, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: storageKey, + context, + }) + return NextResponse.json( + { + success: false, + error: 'File not found', + }, + { status: 404 } + ) + } + + fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + return NextResponse.json( + { + success: false, + error: 'Failed to generate file access URL', + }, + { status: 500 } + ) + } + } else if (validatedData.filePath?.startsWith('/')) { + const baseUrl = getBaseUrl() + fileUrl = `${baseUrl}${validatedData.filePath}` + } + + const formData = new FormData() + formData.append('file_url', fileUrl) + + if (validatedData.pages) { + formData.append('pages', validatedData.pages) + } + if (validatedData.extractFigure !== undefined) { + formData.append('extract_figure', String(validatedData.extractFigure)) + } + if (validatedData.figureDescription !== undefined) { + formData.append('figure_description', String(validatedData.figureDescription)) + } + if (validatedData.returnHtml !== undefined) { + formData.append('return_html', String(validatedData.returnHtml)) + } + if (validatedData.chunking) { + formData.append('chunking', validatedData.chunking) + } + if (validatedData.chunkSize !== undefined) { + formData.append('chunk_size', String(validatedData.chunkSize)) + } + + const pulseResponse = await fetch('https://api.runpulse.com/extract', { + method: 'POST', + headers: { + 'x-api-key': validatedData.apiKey, + }, + body: formData, + }) + + if (!pulseResponse.ok) { + const errorText = await pulseResponse.text() + logger.error(`[${requestId}] Pulse API error:`, errorText) + return NextResponse.json( + { + success: false, + error: `Pulse API error: ${pulseResponse.statusText}`, + }, + { status: pulseResponse.status } + ) + } + + const pulseData = await pulseResponse.json() + + logger.info(`[${requestId}] Pulse parse successful`) + + return NextResponse.json({ + success: true, + output: pulseData, + }) + } 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 Pulse parse:`, error) + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts new file mode 100644 index 0000000000..fa96ac46b0 --- /dev/null +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { StorageService } from '@/lib/uploads' +import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ReductoParseAPI') + +const ReductoParseSchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + filePath: z.string().min(1, 'File path is required'), + pages: z.array(z.number()).optional(), + tableOutputFormat: z.enum(['html', 'md']).optional(), +}) + +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 Reducto 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 = ReductoParseSchema.parse(body) + + logger.info(`[${requestId}] Reducto parse request`, { + filePath: validatedData.filePath, + isWorkspaceFile: validatedData.filePath.includes('/api/files/serve/'), + userId, + }) + + let fileUrl = validatedData.filePath + + if (validatedData.filePath?.includes('/api/files/serve/')) { + try { + const storageKey = extractStorageKey(validatedData.filePath) + const context = inferContextFromKey(storageKey) + + const hasAccess = await verifyFileAccess( + storageKey, + userId, + undefined, // customConfig + context, // context + false // isLocal + ) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key: storageKey, + context, + }) + return NextResponse.json( + { + success: false, + error: 'File not found', + }, + { status: 404 } + ) + } + + fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + return NextResponse.json( + { + success: false, + error: 'Failed to generate file access URL', + }, + { status: 500 } + ) + } + } else if (validatedData.filePath?.startsWith('/')) { + const baseUrl = getBaseUrl() + fileUrl = `${baseUrl}${validatedData.filePath}` + } + + const reductoBody: Record = { + input: fileUrl, + } + + if (validatedData.pages && validatedData.pages.length > 0) { + reductoBody.settings = { + page_range: validatedData.pages, + } + } + + if (validatedData.tableOutputFormat) { + reductoBody.formatting = { + table_output_format: validatedData.tableOutputFormat, + } + } + + const reductoResponse = await fetch('https://platform.reducto.ai/parse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${validatedData.apiKey}`, + }, + body: JSON.stringify(reductoBody), + }) + + if (!reductoResponse.ok) { + const errorText = await reductoResponse.text() + logger.error(`[${requestId}] Reducto API error:`, errorText) + return NextResponse.json( + { + success: false, + error: `Reducto API error: ${reductoResponse.statusText}`, + }, + { status: reductoResponse.status } + ) + } + + const reductoData = await reductoResponse.json() + + logger.info(`[${requestId}] Reducto parse successful`) + + return NextResponse.json({ + success: true, + output: reductoData, + }) + } 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 Reducto parse:`, error) + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/blocks/blocks/pulse.ts b/apps/sim/blocks/blocks/pulse.ts new file mode 100644 index 0000000000..212f325d71 --- /dev/null +++ b/apps/sim/blocks/blocks/pulse.ts @@ -0,0 +1,143 @@ +import { PulseIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import type { PulseParserOutput } from '@/tools/pulse/types' + +export const PulseBlock: BlockConfig = { + type: 'pulse', + name: 'Pulse', + description: 'Extract text from documents using Pulse OCR', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Pulse into the workflow. Extract text from PDF documents, images, and Office files via URL or upload.', + docsLink: 'https://docs.sim.ai/tools/pulse', + category: 'tools', + bgColor: '#E0E0E0', + icon: PulseIcon, + subBlocks: [ + { + id: 'inputMethod', + title: 'Select Input Method', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'url', label: 'Document URL' }, + { id: 'upload', label: 'Upload Document' }, + ], + }, + { + id: 'filePath', + title: 'Document URL', + type: 'short-input' as SubBlockType, + placeholder: 'Enter full URL to a document (https://example.com/document.pdf)', + condition: { + field: 'inputMethod', + value: 'url', + }, + }, + { + id: 'fileUpload', + title: 'Upload Document', + type: 'file-upload' as SubBlockType, + acceptedTypes: 'application/pdf,image/*,.docx,.pptx,.xlsx', + condition: { + field: 'inputMethod', + value: 'upload', + }, + maxSize: 50, + }, + { + id: 'pages', + title: 'Specific Pages', + type: 'short-input', + placeholder: 'e.g. 1-3,5 (leave empty for all pages)', + }, + { + id: 'chunking', + title: 'Chunking Strategy', + type: 'short-input', + placeholder: 'e.g. semantic,header,page,recursive', + }, + { + id: 'chunkSize', + title: 'Chunk Size', + type: 'short-input', + placeholder: 'Max characters per chunk', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your Pulse API key', + password: true, + required: true, + }, + ], + tools: { + access: ['pulse_parser'], + config: { + tool: () => 'pulse_parser', + params: (params) => { + if (!params || !params.apiKey || params.apiKey.trim() === '') { + throw new Error('Pulse API key is required') + } + + const parameters: Record = { + 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 + } + + if (params.pages && params.pages.trim() !== '') { + parameters.pages = params.pages.trim() + } + + if (params.chunking && params.chunking.trim() !== '') { + parameters.chunking = params.chunking.trim() + } + + if (params.chunkSize && params.chunkSize.trim() !== '') { + const size = Number.parseInt(params.chunkSize.trim(), 10) + if (!Number.isNaN(size) && size > 0) { + parameters.chunkSize = size + } + } + + return parameters + }, + }, + }, + inputs: { + inputMethod: { type: 'string', description: 'Input method selection' }, + filePath: { type: 'string', description: 'Document URL' }, + fileUpload: { type: 'json', description: 'Uploaded document file' }, + apiKey: { type: 'string', description: 'Pulse API key' }, + pages: { type: 'string', description: 'Page range selection' }, + chunking: { + type: 'string', + description: 'Chunking strategies (semantic, header, page, recursive)', + }, + chunkSize: { type: 'string', description: 'Maximum characters per chunk' }, + }, + outputs: { + markdown: { type: 'string', description: 'Extracted content in markdown format' }, + page_count: { type: 'number', description: 'Number of pages in the document' }, + job_id: { type: 'string', description: 'Unique job identifier' }, + 'plan-info': { type: 'json', description: 'Plan usage information' }, + bounding_boxes: { type: 'json', description: 'Bounding box layout information' }, + extraction_url: { type: 'string', description: 'URL for extraction results (large documents)' }, + html: { type: 'string', description: 'HTML content if requested' }, + structured_output: { type: 'json', description: 'Structured output if schema was provided' }, + chunks: { type: 'json', description: 'Chunked content if chunking was enabled' }, + figures: { type: 'json', description: 'Extracted figures if figure extraction was enabled' }, + }, +} diff --git a/apps/sim/blocks/blocks/reducto.ts b/apps/sim/blocks/blocks/reducto.ts new file mode 100644 index 0000000000..5dd33dcb65 --- /dev/null +++ b/apps/sim/blocks/blocks/reducto.ts @@ -0,0 +1,148 @@ +import { ReductoIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, type SubBlockType } from '@/blocks/types' +import type { ReductoParserOutput } from '@/tools/reducto/types' + +export const ReductoBlock: BlockConfig = { + type: 'reducto', + name: 'Reducto', + description: 'Extract text from PDF documents', + authMode: AuthMode.ApiKey, + longDescription: `Integrate Reducto Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`, + docsLink: 'https://docs.sim.ai/tools/reducto', + category: 'tools', + bgColor: '#5c0c5c', + icon: ReductoIcon, + subBlocks: [ + { + id: 'inputMethod', + title: 'Select Input Method', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'url', label: 'PDF Document URL' }, + { id: 'upload', label: 'Upload PDF Document' }, + ], + }, + { + id: 'filePath', + title: 'PDF Document URL', + type: 'short-input' as SubBlockType, + placeholder: 'Enter full URL to a PDF document (https://example.com/document.pdf)', + condition: { + field: 'inputMethod', + value: 'url', + }, + }, + { + id: 'fileUpload', + title: 'Upload PDF', + type: 'file-upload' as SubBlockType, + acceptedTypes: 'application/pdf', + condition: { + field: 'inputMethod', + value: 'upload', + }, + maxSize: 50, + }, + { + id: 'pages', + title: 'Specific Pages', + type: 'short-input', + placeholder: 'e.g. 1,2,3 (1-indexed, leave empty for all)', + }, + { + id: 'tableOutputFormat', + title: 'Table Format', + type: 'dropdown', + options: [ + { id: 'md', label: 'Markdown' }, + { id: 'html', label: 'HTML' }, + ], + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your Reducto API key', + password: true, + required: true, + }, + ], + tools: { + access: ['reducto_parser'], + config: { + tool: () => 'reducto_parser', + params: (params) => { + if (!params || !params.apiKey || params.apiKey.trim() === '') { + throw new Error('Reducto API key is required') + } + + const parameters: Record = { + 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 + } + + 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 + } + + if (params.tableOutputFormat) { + parameters.tableOutputFormat = params.tableOutputFormat + } + + return parameters + }, + }, + }, + inputs: { + inputMethod: { type: 'string', description: 'Input method selection' }, + filePath: { type: 'string', description: 'PDF document URL' }, + fileUpload: { type: 'json', description: 'Uploaded PDF file' }, + apiKey: { type: 'string', description: 'Reducto API key' }, + pages: { type: 'string', description: 'Page selection' }, + tableOutputFormat: { type: 'string', description: 'Table output format' }, + }, + outputs: { + job_id: { type: 'string', description: 'Unique identifier for the processing job' }, + duration: { type: 'number', description: 'Processing time in seconds' }, + usage: { type: 'json', description: 'Resource consumption data (num_pages, credits)' }, + result: { type: 'json', description: 'Parsed document content with chunks and blocks' }, + pdf_url: { type: 'string', description: 'Storage URL of converted PDF' }, + studio_link: { type: 'string', description: 'Link to Reducto studio interface' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index c40b719d43..916453eda2 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -93,9 +93,11 @@ import { PipedriveBlock } from '@/blocks/blocks/pipedrive' import { PolymarketBlock } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock } from '@/blocks/blocks/postgresql' import { PostHogBlock } from '@/blocks/blocks/posthog' +import { PulseBlock } from '@/blocks/blocks/pulse' import { QdrantBlock } from '@/blocks/blocks/qdrant' import { RDSBlock } from '@/blocks/blocks/rds' import { RedditBlock } from '@/blocks/blocks/reddit' +import { ReductoBlock } from '@/blocks/blocks/reducto' import { ResendBlock } from '@/blocks/blocks/resend' import { ResponseBlock } from '@/blocks/blocks/response' import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' @@ -237,6 +239,7 @@ export const registry: Record = { microsoft_planner: MicrosoftPlannerBlock, microsoft_teams: MicrosoftTeamsBlock, mistral_parse: MistralParseBlock, + reducto: ReductoBlock, mongodb: MongoDBBlock, mysql: MySQLBlock, neo4j: Neo4jBlock, @@ -253,6 +256,7 @@ export const registry: Record = { polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, + pulse: PulseBlock, qdrant: QdrantBlock, rds: RDSBlock, sqs: SQSBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index d8ebc1641e..2c1bcb62bf 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4678,3 +4678,349 @@ export function BedrockIcon(props: SVGProps) { ) } + +export function ReductoIcon(props: SVGProps) { + return ( + + + + ) +} + +export function PulseIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/sim/tools/pulse/index.ts b/apps/sim/tools/pulse/index.ts new file mode 100644 index 0000000000..119abedb10 --- /dev/null +++ b/apps/sim/tools/pulse/index.ts @@ -0,0 +1,2 @@ +export { pulseParserTool } from '@/tools/pulse/parser' +export * from './types' diff --git a/apps/sim/tools/pulse/parser.ts b/apps/sim/tools/pulse/parser.ts new file mode 100644 index 0000000000..55dba481a8 --- /dev/null +++ b/apps/sim/tools/pulse/parser.ts @@ -0,0 +1,283 @@ +import { createLogger } from '@sim/logger' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { PulseParserInput, PulseParserOutput } from '@/tools/pulse/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('PulseParserTool') + +export const pulseParserTool: ToolConfig = { + id: 'pulse_parser', + name: 'Pulse Document Parser', + description: 'Parse documents (PDF, images, Office docs) using Pulse OCR API', + version: '1.0.0', + + params: { + filePath: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'URL to a document to be processed', + }, + fileUpload: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'File upload data from file-upload component', + }, + pages: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Page range to process (1-indexed, e.g., "1-2,5")', + }, + extractFigure: { + type: 'boolean', + required: false, + visibility: 'hidden', + description: 'Enable figure extraction from the document', + }, + figureDescription: { + type: 'boolean', + required: false, + visibility: 'hidden', + description: 'Generate descriptions/captions for extracted figures', + }, + returnHtml: { + type: 'boolean', + required: false, + visibility: 'hidden', + description: 'Include HTML in the response', + }, + chunking: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Chunking strategies (comma-separated: semantic, header, page, recursive)', + }, + chunkSize: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Maximum characters per chunk when chunking is enabled', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Pulse API key', + }, + }, + + request: { + url: '/api/tools/pulse/parse', + method: 'POST', + headers: () => { + return { + 'Content-Type': 'application/json', + Accept: 'application/json', + } + }, + body: (params) => { + if (!params || typeof params !== 'object') { + throw new Error('Invalid parameters: Parameters must be provided as an object') + } + + if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') { + throw new Error('Missing or invalid API key: A valid Pulse API key is required') + } + + // Check if we have a file upload instead of direct URL + if ( + params.fileUpload && + (!params.filePath || params.filePath === 'null' || params.filePath === '') + ) { + if ( + typeof params.fileUpload === 'object' && + params.fileUpload !== null && + (params.fileUpload.url || params.fileUpload.path) + ) { + let uploadedFilePath: string = params.fileUpload.url ?? params.fileUpload.path ?? '' + + if (!uploadedFilePath) { + throw new Error('Invalid file upload: Upload data is missing or invalid') + } + + if (uploadedFilePath.startsWith('/')) { + const baseUrl = getBaseUrl() + if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') + uploadedFilePath = `${baseUrl}${uploadedFilePath}` + } + + params.filePath = uploadedFilePath + logger.info('Using uploaded file:', uploadedFilePath) + } else { + throw new Error('Invalid file upload: Upload data is missing or invalid') + } + } + + if ( + !params.filePath || + typeof params.filePath !== 'string' || + params.filePath.trim() === '' + ) { + throw new Error('Missing or invalid file path: Please provide a URL to a document') + } + + let filePathToValidate = params.filePath.trim() + if (filePathToValidate.startsWith('/')) { + const baseUrl = getBaseUrl() + if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') + filePathToValidate = `${baseUrl}${filePathToValidate}` + } + + let url + try { + url = new URL(filePathToValidate) + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`) + } + + if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) { + throw new Error( + 'Google Drive links are not supported. ' + + 'Please upload your document or provide a direct download link.' + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a document` + ) + } + + const requestBody: Record = { + apiKey: params.apiKey.trim(), + filePath: url.toString(), + } + + // Check if this is an internal workspace file path + if (params.fileUpload?.path?.startsWith('/api/files/serve/')) { + requestBody.filePath = params.fileUpload.path + } + + // Add optional parameters + if (params.pages && typeof params.pages === 'string' && params.pages.trim() !== '') { + requestBody.pages = params.pages.trim() + } + + if (params.extractFigure !== undefined) { + requestBody.extractFigure = params.extractFigure + } + + if (params.figureDescription !== undefined) { + requestBody.figureDescription = params.figureDescription + } + + if (params.returnHtml !== undefined) { + requestBody.returnHtml = params.returnHtml + } + + if (params.chunking && typeof params.chunking === 'string' && params.chunking.trim() !== '') { + requestBody.chunking = params.chunking.trim() + } + + if (params.chunkSize !== undefined && params.chunkSize > 0) { + requestBody.chunkSize = params.chunkSize + } + + return requestBody + }, + }, + + transformResponse: async (response) => { + let parseResult + try { + parseResult = await response.json() + } catch (jsonError) { + throw new Error( + `Failed to parse Pulse response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}` + ) + } + + if (!parseResult || typeof parseResult !== 'object') { + throw new Error('Invalid response format from Pulse API') + } + + // Pass through the native Pulse API response + const pulseData = + parseResult.output && typeof parseResult.output === 'object' + ? parseResult.output + : parseResult + + return { + success: true, + output: { + markdown: pulseData.markdown ?? '', + page_count: pulseData.page_count ?? 0, + job_id: pulseData.job_id ?? '', + 'plan-info': pulseData['plan-info'] ?? { pages_used: 0, tier: 'unknown' }, + bounding_boxes: pulseData.bounding_boxes ?? null, + extraction_url: pulseData.extraction_url ?? null, + html: pulseData.html ?? null, + structured_output: pulseData.structured_output ?? null, + chunks: pulseData.chunks ?? null, + figures: pulseData.figures ?? null, + }, + } + }, + + outputs: { + markdown: { + type: 'string', + description: 'Extracted content in markdown format', + }, + page_count: { + type: 'number', + description: 'Number of pages in the document', + }, + job_id: { + type: 'string', + description: 'Unique job identifier', + }, + 'plan-info': { + type: 'object', + description: 'Plan usage information', + properties: { + pages_used: { type: 'number', description: 'Number of pages used' }, + tier: { type: 'string', description: 'Plan tier' }, + note: { type: 'string', description: 'Optional note', optional: true }, + }, + }, + bounding_boxes: { + type: 'json', + description: 'Bounding box layout information', + optional: true, + }, + extraction_url: { + type: 'string', + description: 'URL for extraction results (for large documents)', + optional: true, + }, + html: { + type: 'string', + description: 'HTML content if requested', + optional: true, + }, + structured_output: { + type: 'json', + description: 'Structured output if schema was provided', + optional: true, + }, + chunks: { + type: 'json', + description: 'Chunked content if chunking was enabled', + optional: true, + }, + figures: { + type: 'json', + description: 'Extracted figures if figure extraction was enabled', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/pulse/types.ts b/apps/sim/tools/pulse/types.ts new file mode 100644 index 0000000000..d11cb6e8ba --- /dev/null +++ b/apps/sim/tools/pulse/types.ts @@ -0,0 +1,93 @@ +import type { ToolResponse } from '@/tools/types' + +/** + * Input parameters for the Pulse parser tool + */ +export interface PulseParserInput { + /** URL to a document to be processed */ + filePath: string + + /** File upload data (from file-upload component) */ + fileUpload?: { + url?: string + path?: string + } + + /** Pulse API key for authentication */ + apiKey: string + + /** Page range to process (1-indexed, e.g., "1-2,5") */ + pages?: string + + /** Whether to extract figures from the document */ + extractFigure?: boolean + + /** Whether to generate figure descriptions/captions */ + figureDescription?: boolean + + /** Whether to include HTML in the response */ + returnHtml?: boolean + + /** Chunking strategies (comma-separated: semantic, header, page, recursive) */ + chunking?: string + + /** Maximum characters per chunk when chunking is enabled */ + chunkSize?: number +} + +/** + * Plan info returned by the Pulse API + */ +export interface PulsePlanInfo { + /** Number of pages used */ + pages_used: number + + /** Plan tier */ + tier: string + + /** Optional note */ + note?: string +} + +/** + * Native output structure from the Pulse API + */ +export interface PulseParserOutputData { + /** Extracted content in markdown format */ + markdown: string + + /** Number of pages in the document */ + page_count: number + + /** Unique job identifier */ + job_id: string + + /** Plan usage information */ + 'plan-info': PulsePlanInfo + + /** Bounding box layout information */ + bounding_boxes?: Record + + /** URL for extraction results (for large documents) */ + extraction_url?: string + + /** HTML content if requested */ + html?: string + + /** Structured output if schema was provided */ + structured_output?: Record + + /** Chunked content if chunking was enabled */ + chunks?: unknown[] + + /** Extracted figures if figure extraction was enabled */ + figures?: unknown[] +} + +/** + * Complete response from the Pulse parser tool + */ +export interface PulseParserOutput extends ToolResponse { + /** The native Pulse API output */ + output: PulseParserOutputData +} diff --git a/apps/sim/tools/reducto/index.ts b/apps/sim/tools/reducto/index.ts new file mode 100644 index 0000000000..3e5f63211b --- /dev/null +++ b/apps/sim/tools/reducto/index.ts @@ -0,0 +1,3 @@ +import { reductoParserTool } from '@/tools/reducto/parser' + +export { reductoParserTool } diff --git a/apps/sim/tools/reducto/parser.ts b/apps/sim/tools/reducto/parser.ts new file mode 100644 index 0000000000..bd7b74c297 --- /dev/null +++ b/apps/sim/tools/reducto/parser.ts @@ -0,0 +1,203 @@ +import { createLogger } from '@sim/logger' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { ReductoParserInput, ReductoParserOutput } from '@/tools/reducto/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ReductoParserTool') + +export const reductoParserTool: ToolConfig = { + id: 'reducto_parser', + name: 'Reducto PDF Parser', + description: 'Parse PDF documents using Reducto OCR API', + version: '1.0.0', + + params: { + filePath: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'URL to a PDF document to be processed', + }, + fileUpload: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'File upload data from file-upload component', + }, + pages: { + type: 'array', + required: false, + visibility: 'user-only', + description: 'Specific pages to process (1-indexed page numbers)', + }, + tableOutputFormat: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Table output format (html or markdown). Defaults to markdown.', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Reducto API key (REDUCTO_API_KEY)', + }, + }, + + request: { + url: '/api/tools/reducto/parse', + method: 'POST', + headers: (params) => { + return { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + } + }, + body: (params) => { + if (!params || typeof params !== 'object') { + throw new Error('Invalid parameters: Parameters must be provided as an object') + } + + if (!params.apiKey || typeof params.apiKey !== 'string' || params.apiKey.trim() === '') { + throw new Error('Missing or invalid API key: A valid Reducto API key is required') + } + + // Check if we have a file upload instead of direct URL + if ( + params.fileUpload && + (!params.filePath || params.filePath === 'null' || params.filePath === '') + ) { + if ( + typeof params.fileUpload === 'object' && + params.fileUpload !== null && + (params.fileUpload.url || params.fileUpload.path) + ) { + let uploadedFilePath = (params.fileUpload.url || params.fileUpload.path) as string + + if (uploadedFilePath.startsWith('/')) { + const baseUrl = getBaseUrl() + if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') + uploadedFilePath = `${baseUrl}${uploadedFilePath}` + } + + params.filePath = uploadedFilePath as string + logger.info('Using uploaded file:', uploadedFilePath) + } else { + throw new Error('Invalid file upload: Upload data is missing or invalid') + } + } + + if ( + !params.filePath || + typeof params.filePath !== 'string' || + params.filePath.trim() === '' + ) { + throw new Error('Missing or invalid file path: Please provide a URL to a PDF document') + } + + let filePathToValidate = params.filePath.trim() + if (filePathToValidate.startsWith('/')) { + const baseUrl = getBaseUrl() + if (!baseUrl) throw new Error('Failed to get base URL for file path conversion') + filePathToValidate = `${baseUrl}${filePathToValidate}` + } + + let url + try { + url = new URL(filePathToValidate) + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Invalid protocol: ${url.protocol}. URL must use HTTP or HTTPS protocol`) + } + + if (url.hostname.includes('drive.google.com') || url.hostname.includes('docs.google.com')) { + throw new Error( + 'Google Drive links are not supported by the Reducto API. ' + + 'Please upload your PDF to a public web server or provide a direct download link.' + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Invalid URL format: ${errorMessage}. Please provide a valid HTTP or HTTPS URL to a PDF document.` + ) + } + + const requestBody: Record = { + apiKey: params.apiKey, + filePath: url.toString(), + } + + // Check if this is an internal workspace file path + if (params.fileUpload?.path?.startsWith('/api/files/serve/')) { + requestBody.filePath = params.fileUpload.path + } + + if (params.tableOutputFormat && ['html', 'md'].includes(params.tableOutputFormat)) { + requestBody.tableOutputFormat = params.tableOutputFormat + } + + // Page selection + if (params.pages !== undefined && params.pages !== null) { + if (Array.isArray(params.pages) && params.pages.length > 0) { + const validPages = params.pages.filter( + (page) => typeof page === 'number' && Number.isInteger(page) && page >= 0 + ) + + if (validPages.length > 0) { + requestBody.pages = validPages + } + } + } + + return requestBody + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!data || typeof data !== 'object') { + throw new Error('Invalid response format from Reducto API') + } + + // Pass through the native Reducto response + const reductoData = data.output ?? data + + return { + success: true, + output: { + job_id: reductoData.job_id, + duration: reductoData.duration, + usage: reductoData.usage, + result: reductoData.result, + pdf_url: reductoData.pdf_url ?? null, + studio_link: reductoData.studio_link ?? null, + }, + } + }, + + outputs: { + job_id: { type: 'string', description: 'Unique identifier for the processing job' }, + duration: { type: 'number', description: 'Processing time in seconds' }, + usage: { + type: 'json', + description: 'Resource consumption data', + }, + result: { + type: 'json', + description: 'Parsed document content with chunks and blocks', + }, + pdf_url: { + type: 'string', + description: 'Storage URL of converted PDF', + optional: true, + }, + studio_link: { + type: 'string', + description: 'Link to Reducto studio interface', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/reducto/types.ts b/apps/sim/tools/reducto/types.ts new file mode 100644 index 0000000000..9a86b08d91 --- /dev/null +++ b/apps/sim/tools/reducto/types.ts @@ -0,0 +1,160 @@ +import type { ToolResponse } from '@/tools/types' + +/** + * Input parameters for the Reducto parser tool + */ +export interface ReductoParserInput { + /** URL to a document to be processed */ + filePath: string + + /** File upload data (from file-upload component) */ + fileUpload?: { + url?: string + path?: string + } + + /** Reducto API key for authentication */ + apiKey: string + + /** Specific pages to process (1-indexed) */ + pages?: number[] + + /** Table output format (html or md) */ + tableOutputFormat?: 'html' | 'md' +} + +/** + * Bounding box for spatial location data + */ +export interface ReductoBoundingBox { + left: number + top: number + width: number + height: number + page: number +} + +/** + * Granular confidence scores + */ +export interface ReductoGranularConfidence { + ocr: string | null + layout: string | null + order: string | null +} + +/** + * Block type classification + */ +export type ReductoBlockType = + | 'Header' + | 'Footer' + | 'Title' + | 'SectionHeader' + | 'Text' + | 'ListItem' + | 'Table' + | 'Figure' + | 'Caption' + | 'Equation' + | 'Code' + | 'PageNumber' + | 'Watermark' + | 'Handwriting' + | 'Other' + +/** + * Parse block - structured content element + */ +export interface ReductoParseBlock { + type: ReductoBlockType + bbox: ReductoBoundingBox + content: string + image_url: string | null + chart_data: string[] | null + confidence: string | null + granular_confidence: ReductoGranularConfidence | null + extra: Record | null +} + +/** + * Parse chunk - document segment + */ +export interface ReductoParseChunk { + content: string + embed: string + enriched: string | null + blocks: ReductoParseBlock[] + enrichment_success: boolean +} + +/** + * OCR word data + */ +export interface ReductoOcrWord { + text: string + bbox: ReductoBoundingBox + confidence: number +} + +/** + * OCR line data + */ +export interface ReductoOcrLine { + text: string + bbox: ReductoBoundingBox + words: ReductoOcrWord[] +} + +/** + * OCR result data + */ +export interface ReductoOcrResult { + lines: ReductoOcrLine[] + words: ReductoOcrWord[] +} + +/** + * Full result - when response fits in payload + */ +export interface ReductoFullResult { + type: 'full' + chunks: ReductoParseChunk[] + ocr: ReductoOcrResult | null + custom: unknown +} + +/** + * URL result - when response exceeds size limits + */ +export interface ReductoUrlResult { + type: 'url' + url: string +} + +/** + * Usage information returned by Reducto API + */ +export interface ReductoUsage { + num_pages: number + credits: number | null +} + +/** + * Native Reducto API response structure + */ +export interface ReductoParserOutputData { + job_id: string + duration: number + usage: ReductoUsage + result: ReductoFullResult | ReductoUrlResult + pdf_url: string | null + studio_link: string | null +} + +/** + * Complete response from the Reducto parser tool + */ +export interface ReductoParserOutput extends ToolResponse { + output: ReductoParserOutputData +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index a75ce452d0..d16bcc04cd 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1032,6 +1032,7 @@ import { posthogUpdatePropertyDefinitionTool, posthogUpdateSurveyTool, } from '@/tools/posthog' +import { pulseParserTool } from '@/tools/pulse' import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdrant' import { rdsDeleteTool, @@ -1056,6 +1057,7 @@ import { redditUnsaveTool, redditVoteTool, } from '@/tools/reddit' +import { reductoParserTool } from '@/tools/reducto' import { mailSendTool } from '@/tools/resend' import { s3CopyObjectTool, @@ -2126,6 +2128,7 @@ export const tools: Record = { google_slides_add_image: googleSlidesAddImageTool, perplexity_chat: perplexityChatTool, perplexity_search: perplexitySearchTool, + pulse_parser: pulseParserTool, posthog_capture_event: posthogCaptureEventTool, posthog_batch_events: posthogBatchEventsTool, posthog_list_persons: posthogListPersonsTool, @@ -2248,6 +2251,7 @@ export const tools: Record = { apollo_task_search: apolloTaskSearchTool, apollo_email_accounts: apolloEmailAccountsTool, mistral_parser: mistralParserTool, + reducto_parser: reductoParserTool, thinking_tool: thinkingTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool,