diff --git a/frontend/src/components/tools/automate/AutomationCreation.tsx b/frontend/src/components/tools/automate/AutomationCreation.tsx index df897f094..8126ebc65 100644 --- a/frontend/src/components/tools/automate/AutomationCreation.tsx +++ b/frontend/src/components/tools/automate/AutomationCreation.tsx @@ -1,145 +1,62 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Text, - Title, Stack, Group, TextInput, - ActionIcon, Divider, Modal } from '@mantine/core'; -import DeleteIcon from '@mui/icons-material/Delete'; -import SettingsIcon from '@mui/icons-material/Settings'; import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import AddCircleOutline from '@mui/icons-material/AddCircleOutline'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; import ToolConfigurationModal from './ToolConfigurationModal'; -import ToolSelector from './ToolSelector'; -import AutomationEntry from './AutomationEntry'; +import ToolList from './ToolList'; +import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation'; +import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm'; -export enum AutomationMode { - CREATE = 'create', - EDIT = 'edit', - SUGGESTED = 'suggested' -} interface AutomationCreationProps { mode: AutomationMode; - existingAutomation?: any; + existingAutomation?: AutomationConfig; onBack: () => void; - onComplete: (automation: any) => void; - toolRegistry: Record; // Pass registry as prop to break circular dependency -} - -interface AutomationTool { - id: string; - operation: string; - name: string; - configured: boolean; - parameters?: any; + onComplete: (automation: AutomationConfig) => void; + toolRegistry: Record; } export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) { const { t } = useTranslation(); + + const { + automationName, + setAutomationName, + selectedTools, + addTool, + removeTool, + updateTool, + hasUnsavedChanges, + canSaveAutomation, + getToolName, + getToolDefaultParameters + } = useAutomationForm({ mode, existingAutomation, toolRegistry }); - const [automationName, setAutomationName] = useState(''); - const [selectedTools, setSelectedTools] = useState([]); const [configModalOpen, setConfigModalOpen] = useState(false); const [configuraingToolIndex, setConfiguringToolIndex] = useState(-1); const [unsavedWarningOpen, setUnsavedWarningOpen] = useState(false); - // Initialize based on mode and existing automation - useEffect(() => { - if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) { - setAutomationName(existingAutomation.name || ''); - - // Handle both string array (suggested) and object array (custom) operations - const operations = existingAutomation.operations || []; - const tools = operations.map((op: any, index: number) => { - const operation = typeof op === 'string' ? op : op.operation; - return { - id: `${operation}-${Date.now()}-${index}`, - operation: operation, - name: getToolName(operation), - configured: mode === AutomationMode.EDIT ? true : (typeof op === 'object' ? op.configured || false : false), - parameters: typeof op === 'object' ? op.parameters || {} : {} - }; - }); - - setSelectedTools(tools); - } else if (mode === AutomationMode.CREATE && selectedTools.length === 0) { - // Initialize with 2 empty tools for new automation - const defaultTools = [ - { - id: `tool-1-${Date.now()}`, - operation: '', - name: t('automate.creation.tools.selectTool', 'Select a tool...'), - configured: false, - parameters: {} - }, - { - id: `tool-2-${Date.now() + 1}`, - operation: '', - name: t('automate.creation.tools.selectTool', 'Select a tool...'), - configured: false, - parameters: {} - } - ]; - setSelectedTools(defaultTools); - } - }, [mode, existingAutomation, selectedTools.length, t]); - - const getToolName = (operation: string) => { - const tool = toolRegistry?.[operation] as any; - return tool?.name || t(`tools.${operation}.name`, operation); - }; - - const getToolDefaultParameters = (operation: string): any => { - const config = toolRegistry[operation]?.operationConfig; - if (config?.defaultParameters) { - return { ...config.defaultParameters }; // Return a copy to avoid mutations - } - return {}; - }; - - const addTool = (operation: string) => { - - const newTool: AutomationTool = { - id: `${operation}-${Date.now()}`, - operation, - name: getToolName(operation), - configured: false, - parameters: getToolDefaultParameters(operation) - }; - - setSelectedTools([...selectedTools, newTool]); - }; - - const removeTool = (index: number) => { - // Don't allow removing tools if only 2 remain - if (selectedTools.length <= 2) return; - setSelectedTools(selectedTools.filter((_, i) => i !== index)); - }; const configureTool = (index: number) => { setConfiguringToolIndex(index); setConfigModalOpen(true); }; - const handleToolConfigSave = (parameters: any) => { + const handleToolConfigSave = (parameters: Record) => { if (configuraingToolIndex >= 0) { - const updatedTools = [...selectedTools]; - updatedTools[configuraingToolIndex] = { - ...updatedTools[configuraingToolIndex], + updateTool(configuraingToolIndex, { configured: true, parameters - }; - setSelectedTools(updatedTools); + }); } setConfigModalOpen(false); setConfiguringToolIndex(-1); @@ -150,19 +67,15 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o setConfiguringToolIndex(-1); }; - const hasUnsavedChanges = () => { - return ( - automationName.trim() !== '' || - selectedTools.some(tool => tool.operation !== '' || tool.configured) - ); - }; - - const canSaveAutomation = () => { - return ( - automationName.trim() !== '' && - selectedTools.length > 0 && - selectedTools.every(tool => tool.configured && tool.operation !== '') - ); + const handleToolAdd = () => { + const newTool: AutomationTool = { + id: `tool-${Date.now()}`, + operation: '', + name: t('automate.creation.tools.selectTool', 'Select a tool...'), + configured: false, + parameters: {} + }; + updateTool(selectedTools.length, newTool); }; const handleBackClick = () => { @@ -190,14 +103,14 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o description: '', operations: selectedTools.map(tool => ({ operation: tool.operation, - parameters: tool.parameters + parameters: tool.parameters || {} })) }; try { const { automationStorage } = await import('../../../services/automationStorage'); - await automationStorage.saveAutomation(automation); - onComplete(automation); + const savedAutomation = await automationStorage.saveAutomation(automation); + onComplete(savedAutomation); } catch (error) { console.error('Error saving automation:', error); } @@ -224,130 +137,16 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o {/* Selected Tools List */} {selectedTools.length > 0 && ( -
- - {t('automate.creation.tools.selected', 'Selected Tools')} ({selectedTools.length}) - - - {selectedTools.map((tool, index) => ( - -
- {/* Delete X in top right */} - removeTool(index)} - title={t('automate.creation.tools.remove', 'Remove tool')} - style={{ - position: 'absolute', - top: '4px', - right: '4px', - zIndex: 1, - color: 'var(--mantine-color-gray-6)' - }} - > - - - -
- {/* Tool Selection Dropdown with inline settings cog */} - -
- { - const updatedTools = [...selectedTools]; - - // Get default parameters from the tool - const defaultParams = getToolDefaultParameters(newOperation); - - updatedTools[index] = { - ...updatedTools[index], - operation: newOperation, - name: getToolName(newOperation), - configured: false, - parameters: defaultParams - }; - setSelectedTools(updatedTools); - }} - excludeTools={['automate']} - toolRegistry={toolRegistry} - selectedValue={tool.operation} - placeholder={tool.name} - /> -
- - {/* Settings cog - only show if tool is selected, aligned right */} - {tool.operation && ( - configureTool(index)} - title={t('automate.creation.tools.configure', 'Configure tool')} - style={{ color: 'var(--mantine-color-gray-6)' }} - > - - - )} -
- - {/* Configuration status underneath */} - {tool.operation && !tool.configured && ( - - {t('automate.creation.tools.notConfigured', "! Not Configured")} - - )} -
-
- - {index < selectedTools.length - 1 && ( -
- -
- )} -
- ))} - - {/* Arrow before Add Tool Button */} - {selectedTools.length > 0 && ( -
- -
- )} - - {/* Add Tool Button */} -
- { - const newTool: AutomationTool = { - id: `tool-${Date.now()}`, - operation: '', - name: t('automate.creation.tools.selectTool', 'Select a tool...'), - configured: false, - parameters: {} - }; - setSelectedTools([...selectedTools, newTool]); - }} - keepIconColor={true} - /> -
-
-
+ )} diff --git a/frontend/src/components/tools/automate/AutomationRun.tsx b/frontend/src/components/tools/automate/AutomationRun.tsx index 33263dd1f..b204d6931 100644 --- a/frontend/src/components/tools/automate/AutomationRun.tsx +++ b/frontend/src/components/tools/automate/AutomationRun.tsx @@ -5,25 +5,21 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import CheckIcon from "@mui/icons-material/Check"; import { useFileSelection } from "../../../contexts/FileContext"; import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry"; +import { AutomationConfig, ExecutionStep } from "../../../types/automation"; +import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation"; +import { useResourceCleanup } from "../../../utils/resourceManager"; interface AutomationRunProps { - automation: any; + automation: AutomationConfig; onComplete: () => void; - automateOperation?: any; -} - -interface ExecutionStep { - id: string; - operation: string; - name: string; - status: 'pending' | 'running' | 'completed' | 'error'; - error?: string; + automateOperation?: any; // TODO: Type this properly when available } export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) { const { t } = useTranslation(); const { selectedFiles } = useFileSelection(); const toolRegistry = useFlatToolRegistry(); + const cleanup = useResourceCleanup(); // Progress tracking state const [executionSteps, setExecutionSteps] = useState([]); @@ -42,7 +38,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio id: `${op.operation}-${index}`, operation: op.operation, name: tool?.name || op.operation, - status: 'pending' as const + status: EXECUTION_STATUS.PENDING as const }; }); setExecutionSteps(steps); @@ -56,8 +52,10 @@ export default function AutomationRun({ automation, onComplete, automateOperatio // Reset progress state when component unmounts setExecutionSteps([]); setCurrentStepIndex(-1); + // Clean up any blob URLs + cleanup(); }; - }, []); + }, [cleanup]); const executeAutomation = async () => { if (!selectedFiles || selectedFiles.length === 0) { @@ -71,7 +69,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio // Reset progress tracking setCurrentStepIndex(0); - setExecutionSteps(prev => prev.map(step => ({ ...step, status: 'pending' as const, error: undefined }))); + setExecutionSteps(prev => prev.map(step => ({ ...step, status: EXECUTION_STATUS.PENDING as const, error: undefined }))); try { // Use the automateOperation.executeOperation to handle file consumption properly @@ -81,17 +79,17 @@ export default function AutomationRun({ automation, onComplete, automateOperatio onStepStart: (stepIndex: number, operationName: string) => { setCurrentStepIndex(stepIndex); setExecutionSteps(prev => prev.map((step, idx) => - idx === stepIndex ? { ...step, status: 'running' as const } : step + idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING as const } : step )); }, onStepComplete: (stepIndex: number, resultFiles: File[]) => { setExecutionSteps(prev => prev.map((step, idx) => - idx === stepIndex ? { ...step, status: 'completed' as const } : step + idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED as const } : step )); }, onStepError: (stepIndex: number, error: string) => { setExecutionSteps(prev => prev.map((step, idx) => - idx === stepIndex ? { ...step, status: 'error' as const, error } : step + idx === stepIndex ? { ...step, status: EXECUTION_STATUS.ERROR as const, error } : step )); } }, @@ -109,24 +107,24 @@ export default function AutomationRun({ automation, onComplete, automateOperatio const getProgress = () => { if (executionSteps.length === 0) return 0; - const completedSteps = executionSteps.filter(step => step.status === 'completed').length; + const completedSteps = executionSteps.filter(step => step.status === EXECUTION_STATUS.COMPLETED).length; return (completedSteps / executionSteps.length) * 100; }; const getStepIcon = (step: ExecutionStep) => { switch (step.status) { - case 'completed': + case EXECUTION_STATUS.COMPLETED: return ; - case 'error': + case EXECUTION_STATUS.ERROR: return ; - case 'running': + case EXECUTION_STATUS.RUNNING: return
; default: return
{step.name} diff --git a/frontend/src/components/tools/automate/AutomationSelection.tsx b/frontend/src/components/tools/automate/AutomationSelection.tsx index 3a0efc8d4..e751ea401 100644 --- a/frontend/src/components/tools/automate/AutomationSelection.tsx +++ b/frontend/src/components/tools/automate/AutomationSelection.tsx @@ -5,13 +5,14 @@ import AddCircleOutline from "@mui/icons-material/AddCircleOutline"; import SettingsIcon from "@mui/icons-material/Settings"; import AutomationEntry from "./AutomationEntry"; import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations"; +import { AutomationConfig } from "../../../types/automation"; interface AutomationSelectionProps { - savedAutomations: any[]; + savedAutomations: AutomationConfig[]; onCreateNew: () => void; - onRun: (automation: any) => void; - onEdit: (automation: any) => void; - onDelete: (automation: any) => void; + onRun: (automation: AutomationConfig) => void; + onEdit: (automation: AutomationConfig) => void; + onDelete: (automation: AutomationConfig) => void; } export default function AutomationSelection({ @@ -44,7 +45,7 @@ export default function AutomationSelection({ key={automation.id} title={automation.name} badgeIcon={SettingsIcon} - operations={automation.operations.map((op: any) => typeof op === 'string' ? op : op.operation)} + operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)} onClick={() => onRun(automation)} showMenu={true} onEdit={() => onEdit(automation)} diff --git a/frontend/src/components/tools/automate/ToolList.tsx b/frontend/src/components/tools/automate/ToolList.tsx new file mode 100644 index 000000000..8b24b5c17 --- /dev/null +++ b/frontend/src/components/tools/automate/ToolList.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Text, Stack, Group, ActionIcon } from '@mantine/core'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SettingsIcon from '@mui/icons-material/Settings'; +import CloseIcon from '@mui/icons-material/Close'; +import AddCircleOutline from '@mui/icons-material/AddCircleOutline'; +import { AutomationTool } from '../../../types/automation'; +import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; +import ToolSelector from './ToolSelector'; +import AutomationEntry from './AutomationEntry'; + +interface ToolListProps { + tools: AutomationTool[]; + toolRegistry: Record; + onToolUpdate: (index: number, updates: Partial) => void; + onToolRemove: (index: number) => void; + onToolConfigure: (index: number) => void; + onToolAdd: () => void; + getToolName: (operation: string) => string; + getToolDefaultParameters: (operation: string) => Record; +} + +export default function ToolList({ + tools, + toolRegistry, + onToolUpdate, + onToolRemove, + onToolConfigure, + onToolAdd, + getToolName, + getToolDefaultParameters +}: ToolListProps) { + const { t } = useTranslation(); + + const handleToolSelect = (index: number, newOperation: string) => { + const defaultParams = getToolDefaultParameters(newOperation); + + onToolUpdate(index, { + operation: newOperation, + name: getToolName(newOperation), + configured: false, + parameters: defaultParams + }); + }; + + return ( +
+ + {t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length}) + + + {tools.map((tool, index) => ( + +
+ {/* Delete X in top right */} + onToolRemove(index)} + title={t('automate.creation.tools.remove', 'Remove tool')} + style={{ + position: 'absolute', + top: '4px', + right: '4px', + zIndex: 1, + color: 'var(--mantine-color-gray-6)' + }} + > + + + +
+ {/* Tool Selection Dropdown with inline settings cog */} + +
+ handleToolSelect(index, newOperation)} + excludeTools={['automate']} + toolRegistry={toolRegistry} + selectedValue={tool.operation} + placeholder={tool.name} + /> +
+ + {/* Settings cog - only show if tool is selected, aligned right */} + {tool.operation && ( + onToolConfigure(index)} + title={t('automate.creation.tools.configure', 'Configure tool')} + style={{ color: 'var(--mantine-color-gray-6)' }} + > + + + )} +
+ + {/* Configuration status underneath */} + {tool.operation && !tool.configured && ( + + {t('automate.creation.tools.notConfigured', "! Not Configured")} + + )} +
+
+ + {index < tools.length - 1 && ( +
+ +
+ )} +
+ ))} + + {/* Arrow before Add Tool Button */} + {tools.length > 0 && ( +
+ +
+ )} + + {/* Add Tool Button */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/constants/automation.ts b/frontend/src/constants/automation.ts new file mode 100644 index 000000000..c1aeaeff0 --- /dev/null +++ b/frontend/src/constants/automation.ts @@ -0,0 +1,42 @@ +/** + * Constants for automation functionality + */ + +export const AUTOMATION_CONSTANTS = { + // Timeouts + OPERATION_TIMEOUT: 300000, // 5 minutes in milliseconds + + // Default values + DEFAULT_TOOL_COUNT: 2, + MIN_TOOL_COUNT: 2, + + // File prefixes + FILE_PREFIX: 'automated_', + RESPONSE_ZIP_PREFIX: 'response_', + RESULT_FILE_PREFIX: 'result_', + PROCESSED_FILE_PREFIX: 'processed_', + + // Operation types + CONVERT_OPERATION_TYPE: 'convert', + + // Storage keys + DB_NAME: 'StirlingPDF_Automations', + DB_VERSION: 1, + STORE_NAME: 'automations', + + // UI delays + SPINNER_ANIMATION_DURATION: '1s' +} as const; + +export const AUTOMATION_STEPS = { + SELECTION: 'selection', + CREATION: 'creation', + RUN: 'run' +} as const; + +export const EXECUTION_STATUS = { + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + ERROR: 'error' +} as const; \ No newline at end of file diff --git a/frontend/src/hooks/tools/automate/useAutomateOperation.ts b/frontend/src/hooks/tools/automate/useAutomateOperation.ts index acf28cb86..f1aeaf900 100644 --- a/frontend/src/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/src/hooks/tools/automate/useAutomateOperation.ts @@ -2,13 +2,8 @@ import { useToolOperation } from '../shared/useToolOperation'; import { useCallback } from 'react'; import { executeAutomationSequence } from '../../../utils/automationExecutor'; import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry'; - -interface AutomateParameters { - automationConfig?: any; - onStepStart?: (stepIndex: number, operationName: string) => void; - onStepComplete?: (stepIndex: number, resultFiles: File[]) => void; - onStepError?: (stepIndex: number, error: string) => void; -} +import { AutomateParameters } from '../../../types/automation'; +import { AUTOMATION_CONSTANTS } from '../../../constants/automation'; export function useAutomateOperation() { const toolRegistry = useFlatToolRegistry(); @@ -22,7 +17,7 @@ export function useAutomateOperation() { // Execute the automation sequence and return the final results const finalResults = await executeAutomationSequence( - params.automationConfig, + params.automationConfig!, files, toolRegistry, (stepIndex: number, operationName: string) => { @@ -49,6 +44,6 @@ export function useAutomateOperation() { endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor buildFormData: () => new FormData(), // Not used with customProcessor customProcessor, - filePrefix: 'automated_' + filePrefix: AUTOMATION_CONSTANTS.FILE_PREFIX }); } \ No newline at end of file diff --git a/frontend/src/hooks/tools/automate/useAutomationForm.ts b/frontend/src/hooks/tools/automate/useAutomationForm.ts new file mode 100644 index 000000000..b9444f871 --- /dev/null +++ b/frontend/src/hooks/tools/automate/useAutomationForm.ts @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation'; +import { AUTOMATION_CONSTANTS } from '../../../constants/automation'; +import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; + +interface UseAutomationFormProps { + mode: AutomationMode; + existingAutomation?: AutomationConfig; + toolRegistry: Record; +} + +export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) { + const { t } = useTranslation(); + + const [automationName, setAutomationName] = useState(''); + const [selectedTools, setSelectedTools] = useState([]); + + const getToolName = (operation: string) => { + const tool = toolRegistry?.[operation] as any; + return tool?.name || t(`tools.${operation}.name`, operation); + }; + + const getToolDefaultParameters = (operation: string): Record => { + const config = toolRegistry[operation]?.operationConfig; + if (config?.defaultParameters) { + return { ...config.defaultParameters }; + } + return {}; + }; + + // Initialize based on mode and existing automation + useEffect(() => { + if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) { + setAutomationName(existingAutomation.name || ''); + + const operations = existingAutomation.operations || []; + const tools = operations.map((op, index) => { + const operation = typeof op === 'string' ? op : op.operation; + return { + id: `${operation}-${Date.now()}-${index}`, + operation: operation, + name: getToolName(operation), + configured: mode === AutomationMode.EDIT ? true : (typeof op === 'object' ? op.configured || false : false), + parameters: typeof op === 'object' ? op.parameters || {} : {} + }; + }); + + setSelectedTools(tools); + } else if (mode === AutomationMode.CREATE && selectedTools.length === 0) { + // Initialize with default empty tools for new automation + const defaultTools = Array.from({ length: AUTOMATION_CONSTANTS.DEFAULT_TOOL_COUNT }, (_, index) => ({ + id: `tool-${index + 1}-${Date.now()}`, + operation: '', + name: t('automate.creation.tools.selectTool', 'Select a tool...'), + configured: false, + parameters: {} + })); + setSelectedTools(defaultTools); + } + }, [mode, existingAutomation, selectedTools.length, t, getToolName]); + + const addTool = (operation: string) => { + const newTool: AutomationTool = { + id: `${operation}-${Date.now()}`, + operation, + name: getToolName(operation), + configured: false, + parameters: getToolDefaultParameters(operation) + }; + + setSelectedTools([...selectedTools, newTool]); + }; + + const removeTool = (index: number) => { + if (selectedTools.length <= AUTOMATION_CONSTANTS.MIN_TOOL_COUNT) return; + setSelectedTools(selectedTools.filter((_, i) => i !== index)); + }; + + const updateTool = (index: number, updates: Partial) => { + const updatedTools = [...selectedTools]; + updatedTools[index] = { ...updatedTools[index], ...updates }; + setSelectedTools(updatedTools); + }; + + const hasUnsavedChanges = () => { + return ( + automationName.trim() !== '' || + selectedTools.some(tool => tool.operation !== '' || tool.configured) + ); + }; + + const canSaveAutomation = () => { + return ( + automationName.trim() !== '' && + selectedTools.length > 0 && + selectedTools.every(tool => tool.configured && tool.operation !== '') + ); + }; + + return { + automationName, + setAutomationName, + selectedTools, + setSelectedTools, + addTool, + removeTool, + updateTool, + hasUnsavedChanges, + canSaveAutomation, + getToolName, + getToolDefaultParameters + }; +} \ No newline at end of file diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index b3b72a50f..9e7a7b864 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -6,34 +6,36 @@ import { useFileSelection } from "../contexts/FileContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import { createFilesToolStep } from "../components/tools/shared/FilesToolStep"; import AutomationSelection from "../components/tools/automate/AutomationSelection"; -import AutomationCreation, { AutomationMode } from "../components/tools/automate/AutomationCreation"; +import AutomationCreation from "../components/tools/automate/AutomationCreation"; import AutomationRun from "../components/tools/automate/AutomationRun"; import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperation"; import { BaseToolProps } from "../types/tool"; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations"; +import { AutomationConfig, AutomationStepData, AutomationMode } from "../types/automation"; +import { AUTOMATION_STEPS } from "../constants/automation"; const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useFileSelection(); - const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>('selection'); - const [stepData, setStepData] = useState({}); + const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(AUTOMATION_STEPS.SELECTION); + const [stepData, setStepData] = useState({ step: AUTOMATION_STEPS.SELECTION }); const automateOperation = useAutomateOperation(); const toolRegistry = useFlatToolRegistry(); const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null; const { savedAutomations, deleteAutomation, refreshAutomations } = useSavedAutomations(); - const handleStepChange = (data: any) => { + const handleStepChange = (data: AutomationStepData) => { // If navigating away from run step, reset automation results - if (currentStep === 'run' && data.step !== 'run') { + if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) { automateOperation.resetResults(); } // If navigating to run step with a different automation, reset results - if (data.step === 'run' && data.automation && + if (data.step === AUTOMATION_STEPS.RUN && data.automation && stepData.automation && data.automation.id !== stepData.automation.id) { automateOperation.resetResults(); } @@ -47,8 +49,8 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { automateOperation.resetResults(); // Reset to selection step - setCurrentStep('selection'); - setStepData({}); + setCurrentStep(AUTOMATION_STEPS.SELECTION); + setStepData({ step: AUTOMATION_STEPS.SELECTION }); onComplete?.([]); // Pass empty array since automation creation doesn't produce files }; @@ -58,10 +60,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return ( handleStepChange({ step: 'creation', mode: AutomationMode.CREATE })} - onRun={(automation: any) => handleStepChange({ step: 'run', automation })} - onEdit={(automation: any) => handleStepChange({ step: 'creation', mode: AutomationMode.EDIT, automation })} - onDelete={async (automation: any) => { + onCreateNew={() => handleStepChange({ step: AUTOMATION_STEPS.CREATION, mode: AutomationMode.CREATE })} + onRun={(automation: AutomationConfig) => handleStepChange({ step: AUTOMATION_STEPS.RUN, automation })} + onEdit={(automation: AutomationConfig) => handleStepChange({ step: AUTOMATION_STEPS.CREATION, mode: AutomationMode.EDIT, automation })} + onDelete={async (automation: AutomationConfig) => { try { await deleteAutomation(automation.id); } catch (error) { @@ -77,10 +79,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { handleStepChange({ step: 'selection' })} + onBack={() => handleStepChange({ step: AUTOMATION_STEPS.SELECTION })} onComplete={() => { refreshAutomations(); - handleStepChange({ step: 'selection' }); + handleStepChange({ step: AUTOMATION_STEPS.SELECTION }); }} toolRegistry={toolRegistry} /> @@ -116,34 +118,34 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const automationSteps = [ createStep(t('automate.selection.title', 'Automation Selection'), { isVisible: true, - isCollapsed: currentStep !== 'selection', - onCollapsedClick: () => setCurrentStep('selection') - }, currentStep === 'selection' ? renderCurrentStep() : null), + isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION, + onCollapsedClick: () => setCurrentStep(AUTOMATION_STEPS.SELECTION) + }, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null), createStep(stepData.mode === AutomationMode.EDIT ? t('automate.creation.editTitle', 'Edit Automation') : t('automate.creation.createTitle', 'Create Automation'), { - isVisible: currentStep === 'creation', + isVisible: currentStep === AUTOMATION_STEPS.CREATION, isCollapsed: false - }, currentStep === 'creation' ? renderCurrentStep() : null), + }, currentStep === AUTOMATION_STEPS.CREATION ? renderCurrentStep() : null), // Files step - only visible during run mode { ...filesStep, - isVisible: currentStep === 'run' + isVisible: currentStep === AUTOMATION_STEPS.RUN }, // Run step createStep(t('automate.run.title', 'Run Automation'), { - isVisible: currentStep === 'run', + isVisible: currentStep === AUTOMATION_STEPS.RUN, isCollapsed: hasResults, - }, currentStep === 'run' ? renderCurrentStep() : null) + }, currentStep === AUTOMATION_STEPS.RUN ? renderCurrentStep() : null) ]; return createToolFlow({ files: { - selectedFiles: currentStep === 'run' ? selectedFiles : [], - isCollapsed: currentStep !== 'run' || hasResults, + selectedFiles: currentStep === AUTOMATION_STEPS.RUN ? selectedFiles : [], + isCollapsed: currentStep !== AUTOMATION_STEPS.RUN || hasResults, isVisible: false, // Hide the default files step since we add our own }, steps: automationSteps, diff --git a/frontend/src/types/automation.ts b/frontend/src/types/automation.ts new file mode 100644 index 000000000..947001874 --- /dev/null +++ b/frontend/src/types/automation.ts @@ -0,0 +1,67 @@ +/** + * Types for automation functionality + */ + +export interface AutomationOperation { + operation: string; + parameters: Record; +} + +export interface AutomationConfig { + id: string; + name: string; + description?: string; + operations: AutomationOperation[]; + createdAt: string; + updatedAt: string; +} + +export interface AutomationTool { + id: string; + operation: string; + name: string; + configured: boolean; + parameters?: Record; +} + +export interface AutomationStepData { + step: 'selection' | 'creation' | 'run'; + mode?: AutomationMode; + automation?: AutomationConfig; +} + +export interface ExecutionStep { + id: string; + operation: string; + name: string; + status: 'pending' | 'running' | 'completed' | 'error'; + error?: string; +} + +export interface AutomationExecutionCallbacks { + onStepStart?: (stepIndex: number, operationName: string) => void; + onStepComplete?: (stepIndex: number, resultFiles: File[]) => void; + onStepError?: (stepIndex: number, error: string) => void; +} + +export interface AutomateParameters extends AutomationExecutionCallbacks { + automationConfig?: AutomationConfig; +} + +export enum AutomationMode { + CREATE = 'create', + EDIT = 'edit', + SUGGESTED = 'suggested' +} + +export interface SuggestedAutomation { + id: string; + name: string; + operations: string[]; + icon: any; // MUI Icon component +} + +// Export the AutomateParameters interface that was previously defined inline +export interface AutomateParameters extends AutomationExecutionCallbacks { + automationConfig?: AutomationConfig; +} \ No newline at end of file diff --git a/frontend/src/utils/automationExecutor.ts b/frontend/src/utils/automationExecutor.ts index 135e0190b..9ef1f6e2f 100644 --- a/frontend/src/utils/automationExecutor.ts +++ b/frontend/src/utils/automationExecutor.ts @@ -1,32 +1,10 @@ import axios from 'axios'; import { ToolRegistry } from '../data/toolsTaxonomy'; -import { zipFileService } from '../services/zipFileService'; +import { AutomationConfig, AutomationExecutionCallbacks } from '../types/automation'; +import { AUTOMATION_CONSTANTS } from '../constants/automation'; +import { AutomationFileProcessor } from './automationFileProcessor'; +import { ResourceManager } from './resourceManager'; -/** - * Extract zip files from response blob - */ -const extractZipFiles = async (blob: Blob): Promise => { - try { - // Convert blob to File for the zip service - const zipFile = new File([blob], `response_${Date.now()}.zip`, { type: 'application/zip' }); - - // Extract PDF files from the ZIP - const result = await zipFileService.extractPdfFiles(zipFile); - - if (!result.success || result.extractedFiles.length === 0) { - console.error('ZIP extraction failed:', result.errors); - throw new Error(`ZIP extraction failed: ${result.errors.join(', ')}`); - } - - console.log(`📦 Extracted ${result.extractedFiles.length} files from ZIP`); - return result.extractedFiles; - } catch (error) { - console.error('Failed to extract ZIP files:', error); - // Fallback: treat as single PDF file - const file = new File([blob], `result_${Date.now()}.pdf`, { type: 'application/pdf' }); - return [file]; - } -}; /** * Execute a tool operation directly without using React hooks @@ -68,15 +46,20 @@ export const executeToolOperation = async ( const response = await axios.post(endpoint, formData, { responseType: 'blob', - timeout: 300000 // 5 minute timeout for large files + timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT }); console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`); - // Multi-file responses are typically ZIP files - const resultFiles = await extractZipFiles(response.data); - console.log(`📁 Extracted ${resultFiles.length} files from response`); - return resultFiles; + // Multi-file responses are typically ZIP files, but may be single files + const result = await AutomationFileProcessor.extractAutomationZipFiles(response.data); + + if (result.errors.length > 0) { + console.warn(`⚠️ File processing warnings:`, result.errors); + } + + console.log(`📁 Processed ${result.files.length} files from response`); + return result.files; } else { // Single-file processing - separate API call per file @@ -95,16 +78,16 @@ export const executeToolOperation = async ( const response = await axios.post(endpoint, formData, { responseType: 'blob', - timeout: 300000 // 5 minute timeout for large files + timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT }); console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`); // Create result file - const resultFile = new File( - [response.data], - `processed_${file.name}`, - { type: 'application/pdf' } + const resultFile = ResourceManager.createResultFile( + response.data, + file.name, + AUTOMATION_CONSTANTS.FILE_PREFIX ); resultFiles.push(resultFile); console.log(`✅ Created result file: ${resultFile.name}`); diff --git a/frontend/src/utils/automationFileProcessor.ts b/frontend/src/utils/automationFileProcessor.ts new file mode 100644 index 000000000..0ab29cfc6 --- /dev/null +++ b/frontend/src/utils/automationFileProcessor.ts @@ -0,0 +1,187 @@ +/** + * File processing utilities specifically for automation workflows + */ + +import axios, { AxiosResponse } from 'axios'; +import { zipFileService } from '../services/zipFileService'; +import { ResourceManager } from './resourceManager'; +import { AUTOMATION_CONSTANTS } from '../constants/automation'; + +export interface AutomationProcessingOptions { + timeout?: number; + responseType?: 'blob' | 'json'; +} + +export interface AutomationProcessingResult { + success: boolean; + files: File[]; + errors: string[]; +} + +export class AutomationFileProcessor { + /** + * Check if a blob is a ZIP file by examining its header + */ + static isZipFile(blob: Blob): boolean { + // This is a simple check - in a real implementation you might want to read the first few bytes + // For now, we'll rely on the extraction attempt and fallback + return blob.type === 'application/zip' || blob.type === 'application/x-zip-compressed'; + } + + /** + * Extract files from a ZIP blob during automation execution, with fallback for non-ZIP files + */ + static async extractAutomationZipFiles(blob: Blob): Promise { + try { + const zipFile = ResourceManager.createTimestampedFile( + blob, + AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX, + '.zip', + 'application/zip' + ); + + const result = await zipFileService.extractPdfFiles(zipFile); + + if (!result.success || result.extractedFiles.length === 0) { + console.warn('ZIP extraction failed, treating as single file'); + // Fallback: treat as single PDF file + const fallbackFile = ResourceManager.createTimestampedFile( + blob, + AUTOMATION_CONSTANTS.RESULT_FILE_PREFIX, + '.pdf' + ); + + return { + success: true, + files: [fallbackFile], + errors: [`ZIP extraction failed, treated as single file: ${result.errors?.join(', ') || 'Unknown error'}`] + }; + } + + return { + success: true, + files: result.extractedFiles, + errors: [] + }; + } catch (error) { + console.warn('Failed to extract automation ZIP files, falling back to single file:', error); + // Fallback: treat as single PDF file + const fallbackFile = ResourceManager.createTimestampedFile( + blob, + AUTOMATION_CONSTANTS.RESULT_FILE_PREFIX, + '.pdf' + ); + + return { + success: true, + files: [fallbackFile], + errors: [`ZIP extraction failed, treated as single file: ${error}`] + }; + } + } + + /** + * Process a single file through an automation step + */ + static async processAutomationSingleFile( + endpoint: string, + formData: FormData, + originalFileName: string, + options: AutomationProcessingOptions = {} + ): Promise { + try { + const response = await axios.post(endpoint, formData, { + responseType: options.responseType || 'blob', + timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT + }); + + if (response.status !== 200) { + return { + success: false, + files: [], + errors: [`Automation step failed - HTTP ${response.status}: ${response.statusText}`] + }; + } + + const resultFile = ResourceManager.createResultFile( + response.data, + originalFileName, + AUTOMATION_CONSTANTS.FILE_PREFIX + ); + + return { + success: true, + files: [resultFile], + errors: [] + }; + } catch (error: any) { + return { + success: false, + files: [], + errors: [`Automation step failed: ${error.response?.data || error.message}`] + }; + } + } + + /** + * Process multiple files through an automation step + */ + static async processAutomationMultipleFiles( + endpoint: string, + formData: FormData, + options: AutomationProcessingOptions = {} + ): Promise { + try { + const response = await axios.post(endpoint, formData, { + responseType: options.responseType || 'blob', + timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT + }); + + if (response.status !== 200) { + return { + success: false, + files: [], + errors: [`Automation step failed - HTTP ${response.status}: ${response.statusText}`] + }; + } + + // Multi-file responses are typically ZIP files + return await this.extractAutomationZipFiles(response.data); + } catch (error: any) { + return { + success: false, + files: [], + errors: [`Automation step failed: ${error.response?.data || error.message}`] + }; + } + } + + /** + * Build form data for automation tool operations + */ + static buildAutomationFormData( + parameters: Record, + files: File | File[], + fileFieldName: string = 'fileInput' + ): FormData { + const formData = new FormData(); + + // Add files + if (Array.isArray(files)) { + files.forEach(file => formData.append(fileFieldName, file)); + } else { + formData.append(fileFieldName, files); + } + + // Add parameters + Object.entries(parameters).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(item => formData.append(key, item)); + } else if (value !== undefined && value !== null) { + formData.append(key, value); + } + }); + + return formData; + } +} \ No newline at end of file diff --git a/frontend/src/utils/resourceManager.ts b/frontend/src/utils/resourceManager.ts new file mode 100644 index 000000000..4dae97e01 --- /dev/null +++ b/frontend/src/utils/resourceManager.ts @@ -0,0 +1,71 @@ +/** + * Utilities for managing file resources and blob URLs + */ + +import { useCallback } from 'react'; +import { AUTOMATION_CONSTANTS } from '../constants/automation'; + +export class ResourceManager { + private static blobUrls = new Set(); + + /** + * Create a blob URL and track it for cleanup + */ + static createBlobUrl(blob: Blob): string { + const url = URL.createObjectURL(blob); + this.blobUrls.add(url); + return url; + } + + /** + * Revoke a specific blob URL + */ + static revokeBlobUrl(url: string): void { + if (this.blobUrls.has(url)) { + URL.revokeObjectURL(url); + this.blobUrls.delete(url); + } + } + + /** + * Revoke all tracked blob URLs + */ + static revokeAllBlobUrls(): void { + this.blobUrls.forEach(url => URL.revokeObjectURL(url)); + this.blobUrls.clear(); + } + + /** + * Create a File with proper naming convention + */ + static createResultFile( + data: BlobPart, + originalName: string, + prefix: string = AUTOMATION_CONSTANTS.PROCESSED_FILE_PREFIX, + type: string = 'application/pdf' + ): File { + return new File([data], `${prefix}${originalName}`, { type }); + } + + /** + * Create a timestamped file for responses + */ + static createTimestampedFile( + data: BlobPart, + prefix: string, + extension: string = '.pdf', + type: string = 'application/pdf' + ): File { + const timestamp = Date.now(); + return new File([data], `${prefix}${timestamp}${extension}`, { type }); + } +} + +/** + * Hook for automatic cleanup on component unmount + */ +export function useResourceCleanup(): () => void { + return useCallback(() => { + ResourceManager.revokeAllBlobUrls(); + }, []); +} \ No newline at end of file