diff --git a/README.md b/README.md index f389aaa84..fa182295d 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,13 @@ It supports extraction, crawling, scraping, and search — designed to scale fro 4. **[Search](https://docs.maxun.dev/robot/search/search-introduction)** – Run automated web searches to discover or scrape results, with support for time-based filters. 5. **[SDK](https://docs.maxun.dev/category/sdk)** – A complete developer toolkit for scraping, extraction, scheduling, and end-to-end data automation. 6. **[CLI](https://docs.maxun.dev/category/cli)** – Create robots, trigger runs, and retrieve extracted data from your terminal. - +7. **[Document Extraction & Parsing](https://docs.maxun.dev/)** – Extract structured data from documents (PDF, DOCX, XLSX, and CSV) using native parsers and images (Scanned PDF, JPG, and PNG) with OCR, or convert them into clean Markdown, HTML, links, or a summary. ## How Does It Work? Maxun robots are automated tools that help you collect data from websites without writing any code. Think of them as your personal web assistants that can navigate websites, extract information, and organize data just like you would manually - but faster and more efficiently. -There are four types of robots, each designed for a different job. +There are five types of robots, each designed for a different job. ### 1. Extract Extract emulates real user behavior and captures structured data. @@ -162,6 +162,13 @@ Run automated web searches to discover or scrape results, with support for time- Learn more here. +### 5. Document Extraction & Parsing +Upload a document or image — PDF, DOCX, XLSX, CSV, JPG, or PNG — and Maxun reads the text out of it, using OCR for scanned pages and photos. Convert it into clean Markdown, HTML, a list of links, or a summary, or use AI-powered extraction to pull specific structured fields. + +**Use cases:** digitize scanned receipts and invoices, pull fields from a photo of a paper form, turn a screenshot of a table into structured data, or convert a scanned document into clean Markdown for an AI workflow. + +Learn more here. + ## Quick Start ### Getting Started @@ -190,6 +197,7 @@ Maxun can run locally with or without Docker - ✨ **Turn Websites to Spreadsheets** – Direct data export to Google Sheets & Airtable - ✨ **Adapt To Website Layout Changes** – Auto-recovery from site updates - ✨ **Extract Behind Login** – Handle authentication seamlessly +- ✨ **Extract From Documents & Images** – OCR Scanned PDFs, JPG, and PNG or parse DOCX, XLSX, and CSV into structured data, Markdown, HTML, or links - ✨ **Integrations** – Connect with your favorite tools - ✨ **MCP Support** – Model Context Protocol integration - ✨ **LLM-Ready Data** – Clean Markdown for AI applications diff --git a/server/src/api/sdk.ts b/server/src/api/sdk.ts index a3e662170..b3d067432 100644 --- a/server/src/api/sdk.ts +++ b/server/src/api/sdk.ts @@ -1485,14 +1485,18 @@ const documentUpload = multer({ fileFilter: (_req, file, cb) => { const allowedMimeTypes = [ 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv', 'application/csv', + 'image/jpeg', + 'image/jpg', + 'image/png', ]; if (allowedMimeTypes.includes(file.mimetype)) { cb(null, true); } else { - cb(new Error('Only PDF, XLSX, and CSV files are allowed')); + cb(new Error('Only PDF, DOCX, XLSX, CSV, JPG, and PNG files are allowed')); } }, }); @@ -1509,7 +1513,7 @@ router.post("/sdk/robots/document", requireAPIKey, documentUpload.single('file') if (!user) return res.status(401).json({ error: 'Unauthorized' }); const file = (req as any).file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'A PDF file is required' }); + if (!file) return res.status(400).json({ error: 'A PDF, DOCX, XLSX, CSV, JPG, or PNG file is required' }); const prompt: string = (req.body.prompt || '').trim(); if (!prompt) return res.status(400).json({ error: 'prompt is required' }); @@ -1583,7 +1587,7 @@ router.post("/sdk/robots/document-parse", requireAPIKey, documentUpload.single(' if (!user) return res.status(401).json({ error: 'Unauthorized' }); const file = (req as any).file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'A PDF file is required' }); + if (!file) return res.status(400).json({ error: 'A PDF, DOCX, XLSX, CSV, JPG, or PNG file is required' }); const rawFormats = req.body['outputFormats[]'] ?? req.body.outputFormats ?? req.body.formats; const requestedFormats: string[] = Array.isArray(rawFormats) diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 58435923f..c286c8356 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -51,13 +51,27 @@ const documentUpload = multer({ if (normalizeDocumentMimeType(file.mimetype, file.originalname)) { cb(null, true); } else { - cb(new Error('Only PDF, DOCX, XLSX, and CSV files are allowed')); + cb(new Error('Only PDF, DOCX, XLSX, CSV, JPG, and PNG files are allowed')); } }, }); +const uploadDocument = (req: any, res: any, next: any) => { + documentUpload.single('file')(req, res, (err: any) => { + if (err) { + if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { + const maxMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); + return res.status(400).json({ error: `File is too large. The maximum size is ${maxMb} MB.` }); + } + return res.status(400).json({ error: err.message || 'Invalid file upload.' }); + } + next(); + }); +}; + //HELPER FUNCTION + // const normalizeRobotUrl = (rawUrl: string): string => { // let normalizedUrl: URL; // try { @@ -2117,21 +2131,21 @@ router.post('/recordings/search', requireSignIn, async (req: AuthenticatedReques /** * POST endpoint for creating a document extraction robot (doc-extract). - * Accepts a PDF or DOCX upload and an extraction prompt. Uses the configured LLM to generate - * an extraction schema and stores the document in MinIO. + * Accepts a PDF, DOCX, XLSX, CSV, JPG, or PNG upload and an extraction prompt. + * Uses the configured LLM to generate an extraction schema and stores the document in MinIO. */ router.post( '/recordings/document', requireSignIn, - documentUpload.single('file'), + uploadDocument, async (req: AuthenticatedRequest, res) => { try { if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const file = (req as any).file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'A PDF or DOCX file is required.' }); + if (!file) return res.status(400).json({ error: 'A PDF, DOCX, XLSX, CSV, JPG, or PNG file is required.' }); const documentMimeType = normalizeDocumentMimeType(file.mimetype, file.originalname); - if (!documentMimeType) return res.status(400).json({ error: 'Only PDF and DOCX files are allowed.' }); + if (!documentMimeType) return res.status(400).json({ error: 'Only PDF, DOCX, XLSX, CSV, JPG, or PNG files are allowed.' }); const { prompt, name, llmProvider, llmModel, llmApiKey, llmBaseUrl } = req.body; if (!prompt || typeof prompt !== 'string' || !prompt.trim()) { @@ -2191,21 +2205,21 @@ router.post( /** * POST endpoint for creating a document parse robot (doc-parse). - * Accepts a PDF or DOCX upload and output format list. Parses the document immediately and - * stores both the document and parsed output in MinIO / database. + * Accepts a PDF, DOCX, XLSX, CSV, JPG, or PNG upload and output format list. + * Parses the document immediately and stores both the document and parsed output in MinIO / database. */ router.post( '/recordings/document-parse', requireSignIn, - documentUpload.single('file'), + uploadDocument, async (req: AuthenticatedRequest, res) => { try { if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const file = (req as any).file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'A PDF or DOCX file is required.' }); + if (!file) return res.status(400).json({ error: 'A PDF, DOCX, XLSX, CSV, JPG, or PNG file is required.' }); const documentMimeType = normalizeDocumentMimeType(file.mimetype, file.originalname); - if (!documentMimeType) return res.status(400).json({ error: 'Only PDF and DOCX files are allowed.' }); + if (!documentMimeType) return res.status(400).json({ error: 'Only PDF, DOCX, XLSX, CSV, JPG, or PNG files are allowed.' }); const { name, formats, llmProvider, llmModel, llmApiKey, llmBaseUrl } = req.body; @@ -2218,7 +2232,7 @@ router.post( : DOC_PARSE_OUTPUT_FORMAT_OPTIONS.filter((f) => f !== 'summary'); // Summaries need a working LLM. Ollama runs locally and needs no key, but the - // hosted providers do — fail early rather than parsing the PDF and then dying. + // hosted providers do — fail early rather than parsing the document and then dying. const summaryProvider = (llmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama'; if (outputFormats.includes('summary') && summaryProvider !== 'ollama') { const envKey = summaryProvider === 'anthropic' @@ -2386,15 +2400,15 @@ router.post('/runs/document-parse-run/:id', requireSignIn, async (req: Authentic router.put( '/recordings/:id/document', requireSignIn, - documentUpload.single('file'), + uploadDocument, async (req: AuthenticatedRequest, res) => { try { if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const file = (req as any).file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'A PDF or DOCX file is required.' }); + if (!file) return res.status(400).json({ error: 'A PDF, DOCX, XLSX, CSV, JPG, or PNG file is required.' }); const documentMimeType = normalizeDocumentMimeType(file.mimetype, file.originalname); - if (!documentMimeType) return res.status(400).json({ error: 'Only PDF and DOCX files are allowed.' }); + if (!documentMimeType) return res.status(400).json({ error: 'Only PDF, DOCX, XLSX, CSV, JPG, or PNG files are allowed.' }); const robot = await Robot.findOne({ where: { 'recording_meta.id': req.params.id } }); if (!robot) return res.status(404).json({ error: 'Robot not found.' }); diff --git a/server/src/utils/document/documentFile.ts b/server/src/utils/document/documentFile.ts index 4b13dbc67..de47cb774 100644 --- a/server/src/utils/document/documentFile.ts +++ b/server/src/utils/document/documentFile.ts @@ -4,24 +4,36 @@ export const PDF_MIME_TYPE = 'application/pdf'; export const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; export const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const CSV_MIME_TYPE = 'text/csv'; +export const JPEG_MIME_TYPE = 'image/jpeg'; +export const PNG_MIME_TYPE = 'image/png'; const ALTERNATE_CSV_MIME_TYPES = new Set(['application/csv', 'text/x-csv']); +const ALTERNATE_JPEG_MIME_TYPES = new Set(['image/jpg']); const SUPPORTED_DOCUMENT_MIME_TYPES = new Set([ PDF_MIME_TYPE, DOCX_MIME_TYPE, XLSX_MIME_TYPE, CSV_MIME_TYPE, + JPEG_MIME_TYPE, + PNG_MIME_TYPE ]); export const isSupportedDocumentMimeType = (mimeType: string | undefined): boolean => - Boolean(mimeType && (SUPPORTED_DOCUMENT_MIME_TYPES.has(mimeType) || ALTERNATE_CSV_MIME_TYPES.has(mimeType))); + Boolean(mimeType && ( + SUPPORTED_DOCUMENT_MIME_TYPES.has(mimeType) + || ALTERNATE_CSV_MIME_TYPES.has(mimeType) + || ALTERNATE_JPEG_MIME_TYPES.has(mimeType))); + +export const isImageMimeType = (mimeType: string | undefined): boolean => + mimeType === JPEG_MIME_TYPE || mimeType === PNG_MIME_TYPE; export const normalizeDocumentMimeType = ( mimeType: string | undefined, originalFileName?: string ): string | null => { if (mimeType && ALTERNATE_CSV_MIME_TYPES.has(mimeType)) return CSV_MIME_TYPE; + if (mimeType && ALTERNATE_JPEG_MIME_TYPES.has(mimeType)) return JPEG_MIME_TYPE; if (isSupportedDocumentMimeType(mimeType)) return mimeType as string; const extension = path.extname(originalFileName || '').toLowerCase(); @@ -29,13 +41,17 @@ export const normalizeDocumentMimeType = ( if (extension === '.docx') return DOCX_MIME_TYPE; if (extension === '.xlsx') return XLSX_MIME_TYPE; if (extension === '.csv') return CSV_MIME_TYPE; + if (extension === '.jpg' || extension === '.jpeg') return JPEG_MIME_TYPE; + if (extension === '.png') return PNG_MIME_TYPE; return null; }; -export const getDocumentExtensionForMimeType = (mimeType: string): '.pdf' | '.docx' | '.xlsx' | '.csv' => { +export const getDocumentExtensionForMimeType = (mimeType: string): '.pdf' | '.docx' | '.xlsx' | '.csv' | '.jpg' | '.png' => { if (mimeType === DOCX_MIME_TYPE) return '.docx'; if (mimeType === XLSX_MIME_TYPE) return '.xlsx'; if (mimeType === CSV_MIME_TYPE || ALTERNATE_CSV_MIME_TYPES.has(mimeType)) return '.csv'; + if (mimeType === JPEG_MIME_TYPE || ALTERNATE_JPEG_MIME_TYPES.has(mimeType)) return '.jpg'; + if (mimeType === PNG_MIME_TYPE) return '.png'; return '.pdf'; }; diff --git a/server/src/workflow-management/classes/DocumentInterpreter.ts b/server/src/workflow-management/classes/DocumentInterpreter.ts index d59915d34..c35c5262b 100644 --- a/server/src/workflow-management/classes/DocumentInterpreter.ts +++ b/server/src/workflow-management/classes/DocumentInterpreter.ts @@ -8,7 +8,8 @@ import type { PaddleOcrResult } from 'ppu-paddle-ocr'; import logger from '../../logger'; import { OutputFormats } from '../../constants/output-formats'; import { parseMarkdown } from '../../markdownify/markdown'; -import { DOCX_MIME_TYPE, PDF_MIME_TYPE, XLSX_MIME_TYPE, CSV_MIME_TYPE } from '../../utils/document/documentFile'; +import { DOCX_MIME_TYPE, PDF_MIME_TYPE, XLSX_MIME_TYPE, CSV_MIME_TYPE, + PNG_MIME_TYPE, isImageMimeType } from '../../utils/document/documentFile'; import { assertLlmBaseUrlAllowed, resolveOpenAiApiKey } from '../../utils/llm-endpoint'; import * as XLSX from 'xlsx'; @@ -52,6 +53,8 @@ interface ParsedDocument { pages: ParsedPage[]; tables: string[][][]; sourceHtml?: string; + /** True when the document came from a flat image, which has no page structure. */ + isImage?: boolean; } interface ExtractionResult { @@ -81,6 +84,14 @@ const normalizeWhitespace = (value: string): string => const cleanText = (value: string): string => normalizeWhitespace(value.replace(/\x00/g, '')); +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const OCR_ASCII_NOISE_THRESHOLD = 0.80; const asciiRatio = (value: string): number => { @@ -188,7 +199,29 @@ const buildCleanTextFromOCRData = (data: any): string => { return resultLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); }; +const MAX_OCR_PIXELS = 25_000_000; // ~25 MP, 5000x5000 pixels + +const downscaleIfOversized = async (imagePath: string): Promise => { + const sharp = (await import('sharp')).default; + const meta = await sharp(imagePath).metadata(); + const pixelCount = (meta.width || 0) * (meta.height || 0); + if (pixelCount <= MAX_OCR_PIXELS) return; + + const scale = Math.sqrt(MAX_OCR_PIXELS / pixelCount); + const isPng = meta.format === 'png'; + const tmp = `${imagePath}.resized.${isPng ? 'png' : 'jpg'}`; + const resized = sharp(imagePath).resize( + Math.max(1, Math.floor(meta.width! * scale)), + Math.max(1, Math.floor(meta.height! * scale)) + ); + await (isPng ? resized.png() : resized.jpeg()).toFile(tmp); + await fs.promises.rename(tmp, imagePath); + logger.info(`[DocumentInterpreter] Downscaled ${meta.width}×${meta.height} image to fit OCR pixel budget`); +}; + const preprocessPageImage = async (imagePath: string): Promise => { + await downscaleIfOversized(imagePath); + try { const { createCanvas, loadImage } = await import('canvas'); const img = await loadImage(imagePath); @@ -1415,6 +1448,83 @@ export class DocumentInterpreter { }; } + private static async parseImage(buffer: Buffer, documentMimeType: string): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maxun-img-')); + const tempFile = path.join(tmpDir, documentMimeType === PNG_MIME_TYPE ? 'image.png' : 'image.jpg'); + + try { + await fs.promises.writeFile(tempFile, buffer); + await downscaleIfOversized(tempFile); + + let script = 'Latin'; + try { + script = await this.detectScript(tempFile); + } catch (detectErr: any) { + logger.warn(`[DocumentInterpreter] Script detection failed (${detectErr.message}), defaulting to Latin`); + } + logger.info(`[DocumentInterpreter] OCR on uploaded image (mimeType: ${documentMimeType}) script: ${script}`); + + let text = ''; + let tables: string[][][] = []; + let paddleSucceeded = false; + + try { + const { lines } = await PaddleOCRProvider.recognizePage(tempFile, script); + text = cleanText(reconstructTextFromPaddleLines(lines)); + // Treat an empty PaddleOCR result as a failure, not a success. + if (text) { + tables = extractTablesFromPaddleLines([lines]); + paddleSucceeded = true; + } + } catch (paddleErr: any) { + logger.warn(`[DocumentInterpreter] PaddleOCR failed (${paddleErr.message}), falling back to Tesseract`); + } + + if (!paddleSucceeded) { + try { + text = cleanText(await this.ocrImageWithTesseract(tempFile, script)); + } catch (tesseractErr: any) { + logger.error(`[DocumentInterpreter] Tesseract also failed on image (${tesseractErr.message})`); + throw new Error('Could not read the image. The file may be corrupt, truncated, or an unsupported image variant.'); + } + } + + const pages: ParsedPage[] = [{ pageNumber: 1, text }]; + logger.info(`[DocumentInterpreter] OCR complete — ${text.length} chars from image (mimeType: ${documentMimeType})`); + return { text, pageCount: 1, pages, tables, isImage: true }; + } finally { + fs.rmSync(tmpDir, { force: true, recursive: true }); + } + } + + private static async ocrImageWithTesseract(imagePath: string, script: string): Promise { + const langs = (this.SCRIPT_TO_LANGS[script] || ['eng']).join('+'); + const { createWorker } = await import('tesseract.js'); + let worker: any; + try { + worker = await createWorker(langs, 1, { + cachePath: process.env.TESSERACT_CACHE_PATH || '/tmp/tesseract-cache', + logger: () => {}, + } as any); + } catch { + worker = await createWorker('eng', 1, { + cachePath: process.env.TESSERACT_CACHE_PATH || '/tmp/tesseract-cache', + logger: () => {}, + } as any); + } + try { + await worker.setParameters({ + tessedit_pageseg_mode: '3', + preserve_interword_spaces: '1', + }); + await preprocessPageImage(imagePath); + const { data } = await worker.recognize(imagePath); + return buildCleanTextFromOCRData(data); + } finally { + await worker.terminate(); + } + } + private static buildSchemaPrompt( prompt: string, sampleText: string @@ -1553,6 +1663,9 @@ export class DocumentInterpreter { if (documentMimeType === CSV_MIME_TYPE) { return this.parseCSV(buffer); } + if (isImageMimeType(documentMimeType)) { + return this.parseImage(buffer, documentMimeType); + } return this.parsePDF(buffer); } @@ -1613,7 +1726,7 @@ export class DocumentInterpreter { for (const page of doc.pages) { const text = cleanText(page.text); if (!text) continue; - parts.push(`## Page ${page.pageNumber}\n\n${text}`); + parts.push(doc.isImage ? text : `## Page ${page.pageNumber}\n\n${text}`); } for (let i = 0; i < doc.tables.length; i++) { @@ -1640,20 +1753,22 @@ export class DocumentInterpreter { if (!text) continue; const paragraphs = text .split(/\n{2,}/) - .map((p) => `

${p.replace(/\n/g, '
')}

`) + .map((p) => `

${escapeHtml(p).replace(/\n/g, '
')}

`) .join('\n'); - parts.push( - `
\n

Page ${page.pageNumber}

\n${paragraphs}\n
` - ); + parts.push( + doc.isImage + ? paragraphs + : `
\n

Page ${page.pageNumber}

\n${paragraphs}\n
` + ); } for (let i = 0; i < doc.tables.length; i++) { const table = doc.tables[i]; if (table.length === 0) continue; - const header = `${table[0].map((cell) => `${cell.trim()}`).join('')}`; + const header = `${table[0].map((cell) => `${escapeHtml(cell.trim())}`).join('')}`; const body = `${table .slice(1) - .map((row) => `${row.map((cell) => `${cell.trim()}`).join('')}`) + .map((row) => `${row.map((cell) => `${escapeHtml(cell.trim())}`).join('')}`) .join('\n')}`; parts.push(`\n${header}\n${body}\n
`); } @@ -1663,7 +1778,11 @@ export class DocumentInterpreter { } private static extractLinks(text: string, sourceHtml?: string): string[] { - const urlPattern = /https?:\/\/[^\s<>"')\]]+/g; + // An image carries no link annotations, so the only links available are URLs that + // appear as visible text — and those are usually written without a scheme. Match + // three shapes: full http(s) URLs, "www."-prefixed hosts, and bare domains with a + // recognizable TLD. The last two get "https://" prepended so the output is usable. + const urlPattern = /(?:https?:\/\/|www\.)[^\s<>"')\]]+|(?"')\]]*)?/gi; const raw = text.match(urlPattern) || []; const htmlLinks = sourceHtml ? Array.from(sourceHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) @@ -1672,7 +1791,10 @@ export class DocumentInterpreter { : []; return [...new Set([ - ...raw.map((url) => url.replace(/[.,;:!?]+$/, '')), + ...raw.map((match) => { + const url = match.replace(/[.,;:!?]+$/, ''); + return /^https?:\/\//i.test(url) ? url : `https://${url}`; + }), ...htmlLinks, ])]; } diff --git a/src/components/robot/pages/RobotCreate.tsx b/src/components/robot/pages/RobotCreate.tsx index f63790684..68c48e68e 100644 --- a/src/components/robot/pages/RobotCreate.tsx +++ b/src/components/robot/pages/RobotCreate.tsx @@ -582,7 +582,7 @@ const RobotCreate: React.FC = () => { }; const handleCreateDocumentRobot = async () => { - if (!documentFile) { notify('error', 'Please upload a PDF, DOCX, XLSX, or CSV file'); return; } + if (!documentFile) { notify('error', 'Please upload a PDF, DOCX, XLSX, CSV, JPG, or PNG file'); return; } if (!documentPrompt.trim()) { notify('error', 'Please enter an extraction prompt'); return; } if (!documentRobotName.trim()) { notify('error', 'Please enter a robot name'); return; } @@ -612,7 +612,7 @@ const RobotCreate: React.FC = () => { }; const handleCreateDocumentParseRobot = async () => { - if (!documentFile) { notify('error', 'Please upload a PDF, DOCX, XLSX, or CSV file'); return; } + if (!documentFile) { notify('error', 'Please upload a PDF, DOCX, XLSX, CSV, JPG, or PNG file'); return; } if (!documentRobotName.trim()) { notify('error', 'Please enter a robot name'); return; } if (documentParseFormats.length === 0) { notify('error', 'Please select at least one output format'); return; } @@ -1828,7 +1828,7 @@ const RobotCreate: React.FC = () => { alt="Maxun Logo" /> - Process PDFs with AI — extract structured fields or convert to Markdown, HTML, links, and summary. + Process documents with AI — extract structured fields or convert to Markdown, HTML, links, and summary. @@ -1902,7 +1902,7 @@ const RobotCreate: React.FC = () => { setDocumentFile(e.target.files?.[0] || null)} /> @@ -1912,8 +1912,8 @@ const RobotCreate: React.FC = () => { ) : ( <> - Click to upload a PDF, DOCX, CSV, or XLSX - Supported files: PDF, DOCX, CSV, XLSX • Max file size: 10 MB + Click to upload a PDF, DOCX, XLSX, CSV, JPG, or PNG + Supported files: PDF, DOCX, XLSX, CSV, JPG, or PNG • Max file size: 10 MB )} diff --git a/src/components/robot/pages/RobotEditPage.tsx b/src/components/robot/pages/RobotEditPage.tsx index b95e6b641..d70e5c692 100644 --- a/src/components/robot/pages/RobotEditPage.tsx +++ b/src/components/robot/pages/RobotEditPage.tsx @@ -1296,7 +1296,7 @@ export const RobotEditPage = ({ handleStart }: RobotSettingsProps) => { setReplacementFile(e.target.files?.[0] || null)} /> @@ -1306,7 +1306,7 @@ export const RobotEditPage = ({ handleStart }: RobotSettingsProps) => { ) : ( <> - Click to upload a new PDF or DOCX + Click to upload a new PDF, DOCX, XLSX, CSV, JPG, or PNG Max file size: 10 MB )}