diff --git a/Dockerfile.backend b/Dockerfile.backend index d0143a3df..fae0d1462 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,26 +1,28 @@ -FROM --platform=$BUILDPLATFORM node:20-slim +FROM node:20-slim # Set working directory WORKDIR /app COPY .sequelizerc .sequelizerc -COPY .env .env # Install node dependencies COPY package*.json ./ COPY src ./src -COPY public ./public +COPY public ./public COPY server ./server COPY tsconfig.json ./ COPY server/tsconfig.json ./server/ -# COPY server/start.sh ./ +# COPY server/start.sh ./ # Install dependencies RUN npm install --legacy-peer-deps +# Build TypeScript server +RUN npm run build:server + # Expose backend port EXPOSE ${BACKEND_PORT:-8080} -# Run migrations & start backend using start script +# Run migrations & start backend using plain node CMD ["npm", "run", "server"] # CMD ["sh", "-c", "npm run migrate && npm run server"] \ No newline at end of file diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 9cb25d6fb..b75bb1abb 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM node:18-alpine AS builder +FROM node:18-alpine AS builder WORKDIR /app diff --git a/package.json b/package.json index 41b1255cd..5eae375b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.28", + "version": "0.0.29", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -83,12 +83,11 @@ "winston": "^3.5.1" }, "scripts": { - "start": "concurrently -k \"npm run server\" \"npm run client\"", - "server": "cross-env NODE_OPTIONS='--max-old-space-size=8000' nodemon server/src/server.ts", + "start": "npm run build:server && concurrently -k \"npm run server\" \"npm run client\"", + "server": "cross-env NODE_OPTIONS='--max-old-space-size=512' node server/dist/server/src/server.js", "client": "vite", "build": "vite build", "build:server": "tsc -p server/tsconfig.json", - "start:server": "cross-env NODE_OPTIONS='--max-old-space-size=8000' server/dist/server/src/server.js", "preview": "vite preview", "lint": "./node_modules/.bin/eslint .", "migrate": "sequelize-cli db:migrate", diff --git a/server/docker-entrypoint.sh b/server/docker-entrypoint.sh index ad670fafa..9101cb0fd 100644 --- a/server/docker-entrypoint.sh +++ b/server/docker-entrypoint.sh @@ -27,7 +27,7 @@ wait_for_postgres() { wait_for_postgres # Run the application with migrations before startup -NODE_OPTIONS="--max-old-space-size=4096" node -e "require('./server/src/db/migrate')().then(() => { console.log('Migration process completed.'); })" +NODE_OPTIONS="--max-old-space-size=4096" node -e "require('./server/dist/server/src/db/migrate')().then(() => { console.log('Migration process completed.'); })" -# Run the server normally +# Run the server normally exec "$@" \ No newline at end of file diff --git a/server/src/api/record.ts b/server/src/api/record.ts index b2c2422a3..25c6f95ac 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -16,7 +16,7 @@ import { WorkflowFile } from "maxun-core"; import { addGoogleSheetUpdateTask, googleSheetUpdateTasks, processGoogleSheetUpdates } from "../workflow-management/integrations/gsheet"; import { addAirtableUpdateTask, airtableUpdateTasks, processAirtableUpdates } from "../workflow-management/integrations/airtable"; import { sendWebhook } from "../routes/webhook"; -import { convertPageToHTML, convertPageToMarkdown } from '../markdownify/scrape'; +import { convertPageToHTML, convertPageToMarkdown, convertPageToScreenshot } from '../markdownify/scrape'; const router = Router(); @@ -689,7 +689,9 @@ async function executeRun(id: string, userId: string, requestedFormats?: string[ // Override if API request defines formats if (requestedFormats && Array.isArray(requestedFormats) && requestedFormats.length > 0) { - formats = requestedFormats.filter((f): f is 'markdown' | 'html' => ['markdown', 'html'].includes(f)); + formats = requestedFormats.filter((f): f is 'markdown' | 'html' | 'screenshot-visible' | 'screenshot-fullpage' => + ['markdown', 'html', 'screenshot-visible', 'screenshot-fullpage'].includes(f) + ); } await run.update({ @@ -707,6 +709,7 @@ async function executeRun(id: string, userId: string, requestedFormats?: string[ let markdown = ''; let html = ''; const serializableOutput: any = {}; + const binaryOutput: any = {}; const SCRAPE_TIMEOUT = 120000; @@ -728,14 +731,52 @@ async function executeRun(id: string, userId: string, requestedFormats?: string[ serializableOutput.html = [{ content: html }]; } + if (formats.includes("screenshot-visible")) { + const screenshotPromise = convertPageToScreenshot(url, currentPage, false); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Screenshot conversion timed out after ${SCRAPE_TIMEOUT/1000}s`)), SCRAPE_TIMEOUT); + }); + const screenshotBuffer = await Promise.race([screenshotPromise, timeoutPromise]); + + if (!binaryOutput['screenshot-visible']) { + binaryOutput['screenshot-visible'] = { + data: screenshotBuffer.toString('base64'), + mimeType: 'image/png' + }; + } + } + + if (formats.includes("screenshot-fullpage")) { + const screenshotPromise = convertPageToScreenshot(url, currentPage, true); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Screenshot conversion timed out after ${SCRAPE_TIMEOUT/1000}s`)), SCRAPE_TIMEOUT); + }); + const screenshotBuffer = await Promise.race([screenshotPromise, timeoutPromise]); + + if (!binaryOutput['screenshot-fullpage']) { + binaryOutput['screenshot-fullpage'] = { + data: screenshotBuffer.toString('base64'), + mimeType: 'image/png' + }; + } + } + await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), log: `${formats.join(', ')} conversion completed successfully`, serializableOutput, - binaryOutput: {}, + binaryOutput, }); + // Upload binary output (screenshots) to MinIO if present + let uploadedBinaryOutput: Record = {}; + if (Object.keys(binaryOutput).length > 0) { + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, binaryOutput); + await run.update({ binaryOutput: uploadedBinaryOutput }); + } + logger.log('info', `Markdown robot execution completed for API run ${id}`); // Push success socket event @@ -775,6 +816,8 @@ async function executeRun(id: string, userId: string, requestedFormats?: string[ if (formats.includes('markdown')) webhookPayload.markdown = markdown; if (formats.includes('html')) webhookPayload.html = html; + if (uploadedBinaryOutput['screenshot-visible']) webhookPayload.screenshot_visible = uploadedBinaryOutput['screenshot-visible']; + if (uploadedBinaryOutput['screenshot-fullpage']) webhookPayload.screenshot_fullpage = uploadedBinaryOutput['screenshot-fullpage']; try { await sendWebhook(plainRun.robotMetaId, 'run_completed', webhookPayload); diff --git a/server/src/markdownify/scrape.ts b/server/src/markdownify/scrape.ts index 09df42767..70c06084e 100644 --- a/server/src/markdownify/scrape.ts +++ b/server/src/markdownify/scrape.ts @@ -123,3 +123,28 @@ export async function convertPageToHTML(url: string, page: Page): Promise { + try { + const screenshotType = fullPage ? 'full page' : 'visible viewport'; + logger.log('info', `[Scrape] Taking ${screenshotType} screenshot of ${url}`); + + await gotoWithFallback(page, url); + + const screenshot = await page.screenshot({ + type: 'png', + fullPage + }); + + return screenshot; + } catch (error: any) { + logger.error(`[Scrape] Error during screenshot: ${error.message}`); + throw error; + } +} diff --git a/server/src/models/Robot.ts b/server/src/models/Robot.ts index 39218de24..1ce269b09 100644 --- a/server/src/models/Robot.ts +++ b/server/src/models/Robot.ts @@ -11,7 +11,7 @@ interface RobotMeta { params: any[]; type?: 'extract' | 'scrape'; url?: string; - formats?: ('markdown' | 'html')[]; + formats?: ('markdown' | 'html' | 'screenshot-visible' | 'screenshot-fullpage')[]; } interface RobotWorkflow { diff --git a/server/src/models/Run.ts b/server/src/models/Run.ts index 1e292dbbf..6f560f48c 100644 --- a/server/src/models/Run.ts +++ b/server/src/models/Run.ts @@ -23,7 +23,7 @@ interface RunAttributes { runByUserId?: string; runByScheduleId?: string; runByAPI?: boolean; - serializableOutput: Record; + serializableOutput: Record; binaryOutput: Record; retryCount?: number; } @@ -45,7 +45,7 @@ class Run extends Model implements RunAttr public runByUserId!: string; public runByScheduleId!: string; public runByAPI!: boolean; - public serializableOutput!: Record; + public serializableOutput!: Record; public binaryOutput!: Record; public retryCount!: number; } diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts index e7d4e1152..1f7125f4e 100644 --- a/server/src/pgboss-worker.ts +++ b/server/src/pgboss-worker.ts @@ -20,7 +20,7 @@ import { addAirtableUpdateTask, airtableUpdateTasks, processAirtableUpdates } fr import { io as serverIo } from "./server"; import { sendWebhook } from './routes/webhook'; import { BinaryOutputService } from './storage/mino'; -import { convertPageToMarkdown, convertPageToHTML } from './markdownify/scrape'; +import { convertPageToMarkdown, convertPageToHTML, convertPageToScreenshot } from './markdownify/scrape'; if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) { throw new Error('Failed to start pgboss worker: one or more required environment variables are missing.'); @@ -244,6 +244,7 @@ async function processRunExecution(job: Job) { let markdown = ''; let html = ''; const serializableOutput: any = {}; + const binaryOutput: any = {}; const SCRAPE_TIMEOUT = 120000; @@ -265,15 +266,52 @@ async function processRunExecution(job: Job) { serializableOutput.html = [{ content: html }]; } + if (formats.includes("screenshot-visible")) { + const screenshotPromise = convertPageToScreenshot(url, currentPage, false); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Screenshot conversion timed out after ${SCRAPE_TIMEOUT/1000}s`)), SCRAPE_TIMEOUT); + }); + const screenshotBuffer = await Promise.race([screenshotPromise, timeoutPromise]); + + if (!binaryOutput['screenshot-visible']) { + binaryOutput['screenshot-visible'] = { + data: screenshotBuffer.toString('base64'), + mimeType: 'image/png' + }; + } + } + + if (formats.includes("screenshot-fullpage")) { + const screenshotPromise = convertPageToScreenshot(url, currentPage, true); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Screenshot conversion timed out after ${SCRAPE_TIMEOUT/1000}s`)), SCRAPE_TIMEOUT); + }); + const screenshotBuffer = await Promise.race([screenshotPromise, timeoutPromise]); + + if (!binaryOutput['screenshot-fullpage']) { + binaryOutput['screenshot-fullpage'] = { + data: screenshotBuffer.toString('base64'), + mimeType: 'image/png' + }; + } + } + // Success update await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), log: `${formats.join(', ').toUpperCase()} conversion completed successfully`, serializableOutput, - binaryOutput: {}, + binaryOutput, }); + let uploadedBinaryOutput: Record = {}; + if (Object.keys(binaryOutput).length > 0) { + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, binaryOutput); + await run.update({ binaryOutput: uploadedBinaryOutput }); + } + logger.log('info', `Markdown robot execution completed for run ${data.runId}`); // Notify sockets @@ -304,6 +342,8 @@ async function processRunExecution(job: Job) { if (formats.includes('markdown')) webhookPayload.markdown = markdown; if (formats.includes('html')) webhookPayload.html = html; + if (uploadedBinaryOutput['screenshot-visible']) webhookPayload.screenshot_visible = uploadedBinaryOutput['screenshot-visible']; + if (uploadedBinaryOutput['screenshot-fullpage']) webhookPayload.screenshot_fullpage = uploadedBinaryOutput['screenshot-fullpage']; await sendWebhook(plainRun.robotMetaId, 'run_completed', webhookPayload); logger.log('info', `Webhooks sent successfully for markdown robot run ${data.runId}`); @@ -427,7 +467,7 @@ async function processRunExecution(job: Job) { logger.log('info', `Workflow execution completed for run ${data.runId}`); - const binaryOutputService = new BinaryOutputService('maxuncloud-run-screenshots'); + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput( run, interpretationInfo.binaryOutput diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 349334669..5a758ee94 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -210,12 +210,13 @@ router.get( requireSignIn, async (req: AuthenticatedRequest, res) => { try { - const { id } = req.params; - if (!id) { - return res.status(400).json({ message: "User ID is required" }); + if (!req.user || !req.user.id) { + return res.status(401).json({ message: "Unauthorized" }); } - const user = await User.findByPk(id, { + const userId = req.user.id; + + const user = await User.findByPk(userId, { attributes: { exclude: ["password"] }, }); diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 45d4bc532..3941b01ff 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -456,7 +456,7 @@ router.post('/recordings/scrape', requireSignIn, async (req: AuthenticatedReques } // Validate format - const validFormats = ['markdown', 'html']; + const validFormats = ['markdown', 'html', 'screenshot-visible', 'screenshot-fullpage']; if (!Array.isArray(formats) || formats.length === 0) { return res.status(400).json({ error: 'At least one output format must be selected.' }); diff --git a/server/src/server.ts b/server/src/server.ts index 61f577560..316bf3dec 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -280,27 +280,39 @@ if (require.main === module) { const run = await Run.findOne({ where: { browserId, status: 'running' } }); if (run) { const limitedData = { - scrapeSchemaOutput: browser.interpreter.serializableDataByType?.scrapeSchema - ? { "schema-tabular": browser.interpreter.serializableDataByType.scrapeSchema } - : {}, + scrapeSchemaOutput: browser.interpreter.serializableDataByType?.scrapeSchema || {}, scrapeListOutput: browser.interpreter.serializableDataByType?.scrapeList || {}, binaryOutput: browser.interpreter.binaryData || [] }; const binaryOutputRecord = limitedData.binaryOutput.reduce((acc: Record, item: any, index: number) => { - acc[`item-${index}`] = item; + const key = item.name || `Screenshot ${index + 1}`; + acc[key] = { data: item.data, mimeType: item.mimeType }; return acc; }, {}); + let uploadedBinaryOutput = {}; + if (Object.keys(binaryOutputRecord).length > 0) { + try { + const { BinaryOutputService } = require('./storage/mino'); + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, binaryOutputRecord); + logger.log('info', `Successfully uploaded ${Object.keys(uploadedBinaryOutput).length} screenshots to MinIO for interrupted run`); + } catch (minioError: any) { + logger.log('error', `Failed to upload binary data to MinIO during shutdown: ${minioError.message}`); + uploadedBinaryOutput = binaryOutputRecord; + } + } + await run.update({ status: 'failed', finishedAt: new Date().toLocaleString(), log: 'Process interrupted during execution - partial data preserved', serializableOutput: { - scrapeSchema: Object.values(limitedData.scrapeSchemaOutput), - scrapeList: Object.values(limitedData.scrapeListOutput), + scrapeSchema: limitedData.scrapeSchemaOutput, + scrapeList: limitedData.scrapeListOutput, }, - binaryOutput: binaryOutputRecord + binaryOutput: uploadedBinaryOutput }); } } diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index 49237522a..652e72d8d 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -13,7 +13,7 @@ import { WorkflowFile } from "maxun-core"; import { Page } from "playwright-core"; import { sendWebhook } from "../../routes/webhook"; import { addAirtableUpdateTask, airtableUpdateTasks, processAirtableUpdates } from "../integrations/airtable"; -import { convertPageToMarkdown, convertPageToHTML } from "../../markdownify/scrape"; +import { convertPageToMarkdown, convertPageToHTML, convertPageToScreenshot } from "../../markdownify/scrape"; async function createWorkflowAndStoreMetadata(id: string, userId: string) { try { @@ -268,6 +268,7 @@ async function executeRun(id: string, userId: string) { let markdown = ''; let html = ''; const serializableOutput: any = {}; + const binaryOutput: any = {}; const SCRAPE_TIMEOUT = 120000; @@ -290,13 +291,51 @@ async function executeRun(id: string, userId: string) { serializableOutput.html = [{ content: html }]; } + if (formats.includes("screenshot-visible")) { + const screenshotPromise = convertPageToScreenshot(url, currentPage, false); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Screenshot conversion timed out after ${SCRAPE_TIMEOUT/1000}s`)), SCRAPE_TIMEOUT); + }); + const screenshotBuffer = await Promise.race([screenshotPromise, timeoutPromise]); + + if (!binaryOutput['screenshot-visible']) { + binaryOutput['screenshot-visible'] = { + data: screenshotBuffer.toString('base64'), + mimeType: 'image/png' + }; + } + } + + // Screenshot - full page + if (formats.includes("screenshot-fullpage")) { + const screenshotPromise = convertPageToScreenshot(url, currentPage, true); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Screenshot conversion timed out after ${SCRAPE_TIMEOUT/1000}s`)), SCRAPE_TIMEOUT); + }); + const screenshotBuffer = await Promise.race([screenshotPromise, timeoutPromise]); + + if (!binaryOutput['screenshot-fullpage']) { + binaryOutput['screenshot-fullpage'] = { + data: screenshotBuffer.toString('base64'), + mimeType: 'image/png' + }; + } + } + await run.update({ status: 'success', finishedAt: new Date().toLocaleString(), log: `${formats.join(', ')} conversion completed successfully`, serializableOutput, - binaryOutput: {}, + binaryOutput, }); + + let uploadedBinaryOutput: Record = {}; + if (Object.keys(binaryOutput).length > 0) { + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, binaryOutput); + await run.update({ binaryOutput: uploadedBinaryOutput }); + } logger.log('info', `Markdown robot execution completed for scheduled run ${id}`); @@ -335,6 +374,8 @@ async function executeRun(id: string, userId: string) { if (formats.includes('markdown')) webhookPayload.markdown = markdown; if (formats.includes('html')) webhookPayload.html = html; + if (uploadedBinaryOutput['screenshot-visible']) webhookPayload.screenshot_visible = uploadedBinaryOutput['screenshot-visible']; + if (uploadedBinaryOutput['screenshot-fullpage']) webhookPayload.screenshot_fullpage = uploadedBinaryOutput['screenshot-fullpage']; try { await sendWebhook(plainRun.robotMetaId, 'run_completed', webhookPayload); diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index 769e9048e..51055e9af 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -482,11 +482,37 @@ export const BrowserWindow = () => { validatedChildSelectors.forEach((selector, index) => { try { + const listElements = evaluateXPathAllWithShadowSupport( + iframeElement.contentDocument!, + listSelector, + listSelector.includes(">>") || listSelector.startsWith("//") + ); + + if (listElements.length === 0) return; + + const hasNumericPredicate = /\[\d+\](?![^\[]*@)/.test(selector); + + if (hasNumericPredicate && listElements.length >= 3) { + const allMatches = evaluateXPathAllWithShadowSupport( + iframeElement.contentDocument!, + selector, + selector.includes(">>") || selector.startsWith("//") + ); + + const matchRatio = allMatches.length / listElements.length; + + if (matchRatio < 0.6) { + return; + } + } + + const firstListElement = listElements[0]; + const elements = evaluateXPathAllWithShadowSupport( iframeElement.contentDocument!, selector, selector.includes(">>") || selector.startsWith("//") - ); + ).filter(el => firstListElement.contains(el as Node)); if (elements.length === 0) return; @@ -591,6 +617,40 @@ export const BrowserWindow = () => { selectorObj: fieldData.selectorObj } }); + const anchorParent = element.closest('a'); + if (anchorParent) { + const href = anchorParent.getAttribute('href'); + if (href && href !== '#' && !href.startsWith('javascript:') && isValidData(href)) { + let anchorSelector = selector; + if (selector.includes('/a[')) { + const anchorMatch = selector.match(/(.*\/a\[[^\]]+\])/); + if (anchorMatch) { + anchorSelector = anchorMatch[1]; + } + } + + const fieldId = Date.now() + index * 1000 + 500; + candidateFields.push({ + id: fieldId, + element: anchorParent as HTMLElement, + isLeaf: true, + depth: 0, + position: position, + field: { + id: fieldId, + type: "text", + label: `Label ${index + 1} Link`, + data: href, + selectorObj: { + selector: anchorSelector, + attribute: 'href', + tag: 'A', + isShadow: anchorParent.getRootNode() instanceof ShadowRoot, + } + } + }); + } + } } } } @@ -610,8 +670,43 @@ export const BrowserWindow = () => { }); const filteredCandidates = removeParentChildDuplicates(candidateFields); - const finalFields = removeDuplicateContent(filteredCandidates); - return finalFields; + const cleanedCandidates = filteredCandidates.filter((candidate) => { + const data = candidate.field.data.trim(); + + const textChildren = Array.from(candidate.element.children).filter(child => + (child.textContent || '').trim().length > 0 + ); + + if (textChildren.length === 0) { + return true; + } + + const childCandidates = filteredCandidates.filter((other) => { + if (other === candidate) return false; + return candidate.element.contains(other.element); + }); + + if (childCandidates.length === 0) { + return true; + } + + let coveredLength = 0; + childCandidates.forEach(child => { + const childText = child.field.data.trim(); + if (data.includes(childText)) { + coveredLength += childText.length; + } + }); + + const coverageRatio = coveredLength / data.length; + const hasMultipleChildTexts = childCandidates.length >= 2; + const highCoverage = coverageRatio > 0.7; + + return !(hasMultipleChildTexts && highCoverage); + }); + + const finalFields = removeDuplicateContent(cleanedCandidates); + return finalFields; }, [currentSnapshot] ); diff --git a/src/components/robot/pages/RobotCreate.tsx b/src/components/robot/pages/RobotCreate.tsx index 486f3bff3..8552435e1 100644 --- a/src/components/robot/pages/RobotCreate.tsx +++ b/src/components/robot/pages/RobotCreate.tsx @@ -16,10 +16,10 @@ import { CardContent, Tabs, Tab, - RadioGroup, - Radio, FormControl, - FormLabel + Select, + MenuItem, + InputLabel } from '@mui/material'; import { ArrowBack, PlayCircleOutline, Article, Code, Description } from '@mui/icons-material'; import { useGlobalInfoStore } from '../../../context/globalInfo'; @@ -376,7 +376,7 @@ const RobotCreate: React.FC = () => { /> - Turn websites into LLM-ready Markdown & clean HTML for AI apps. + Turn websites into LLM-ready Markdown, clean HTML, or screenshots for AI apps. @@ -399,40 +399,52 @@ const RobotCreate: React.FC = () => { sx={{ mb: 2 }} /> - -

Output Format (Select at least one)

- { - if (e.target.checked) { - setOutputFormats([...outputFormats, 'markdown']); - } else { - setOutputFormats(outputFormats.filter(f => f !== 'markdown')); - } - }} - /> - } - label="Markdown" - /> - - { - if (e.target.checked) { - setOutputFormats([...outputFormats, 'html']); - } else { - setOutputFormats(outputFormats.filter(f => f !== 'html')); - } - }} - /> - } - label="HTML" - /> -
+ + + Output Formats * + + +