Code review

This commit is contained in:
Connor Yoh 2025-08-22 11:38:28 +01:00
parent d04979ab46
commit 63c7cd9a47
12 changed files with 751 additions and 343 deletions

View File

@ -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<string, ToolRegistryEntry>; // 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<string, ToolRegistryEntry>;
}
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
const { t } = useTranslation();
const [automationName, setAutomationName] = useState('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const {
automationName,
setAutomationName,
selectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
} = useAutomationForm({ mode, existingAutomation, toolRegistry });
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<string, any>) => {
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 && (
<div>
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t('automate.creation.tools.selected', 'Selected Tools')} ({selectedTools.length})
</Text>
<Stack gap="0" style={{
}}>
{selectedTools.map((tool, index) => (
<React.Fragment key={tool.id}>
<div
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
position: 'relative',
padding: 'var(--mantine-spacing-xs)'
}}
>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => 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)'
}}
>
<CloseIcon style={{ fontSize: 12 }} />
</ActionIcon>
<div style={{ paddingRight: '1.25rem' }}>
{/* Tool Selection Dropdown with inline settings cog */}
<Group gap="xs" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<ToolSelector
key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => {
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}
/>
</div>
{/* Settings cog - only show if tool is selected, aligned right */}
{tool.operation && (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => configureTool(index)}
title={t('automate.creation.tools.configure', 'Configure tool')}
style={{ color: 'var(--mantine-color-gray-6)' }}
>
<SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
</Group>
{/* Configuration status underneath */}
{tool.operation && !tool.configured && (
<Text pl="md" size="xs" c="dimmed" mt="xs">
{t('automate.creation.tools.notConfigured', "! Not Configured")}
</Text>
)}
</div>
</div>
{index < selectedTools.length - 1 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
</React.Fragment>
))}
{/* Arrow before Add Tool Button */}
{selectedTools.length > 0 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
{/* Add Tool Button */}
<div style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden'
}}>
<AutomationEntry
title={t('automate.creation.tools.addTool', 'Add Tool')}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={() => {
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}
/>
</div>
</Stack>
</div>
<ToolList
tools={selectedTools}
toolRegistry={toolRegistry}
onToolUpdate={updateTool}
onToolRemove={removeTool}
onToolConfigure={configureTool}
onToolAdd={handleToolAdd}
getToolName={getToolName}
getToolDefaultParameters={getToolDefaultParameters}
/>
)}
<Divider />

View File

@ -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<ExecutionStep[]>([]);
@ -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 <CheckIcon style={{ fontSize: 16, color: 'green' }} />;
case 'error':
case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}></span>;
case 'running':
case EXECUTION_STATUS.RUNNING:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />;
default:
return <div style={{
@ -175,8 +173,8 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
<Text
size="sm"
style={{
color: step.status === 'running' ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
fontWeight: step.status === 'running' ? 500 : 400
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
}}
>
{step.name}

View File

@ -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)}

View File

@ -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<string, ToolRegistryEntry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;
onToolAdd: () => void;
getToolName: (operation: string) => string;
getToolDefaultParameters: (operation: string) => Record<string, any>;
}
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 (
<div>
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
</Text>
<Stack gap="0">
{tools.map((tool, index) => (
<React.Fragment key={tool.id}>
<div
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
position: 'relative',
padding: 'var(--mantine-spacing-xs)'
}}
>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => 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)'
}}
>
<CloseIcon style={{ fontSize: 12 }} />
</ActionIcon>
<div style={{ paddingRight: '1.25rem' }}>
{/* Tool Selection Dropdown with inline settings cog */}
<Group gap="xs" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<ToolSelector
key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
excludeTools={['automate']}
toolRegistry={toolRegistry}
selectedValue={tool.operation}
placeholder={tool.name}
/>
</div>
{/* Settings cog - only show if tool is selected, aligned right */}
{tool.operation && (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => onToolConfigure(index)}
title={t('automate.creation.tools.configure', 'Configure tool')}
style={{ color: 'var(--mantine-color-gray-6)' }}
>
<SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
</Group>
{/* Configuration status underneath */}
{tool.operation && !tool.configured && (
<Text pl="md" size="xs" c="dimmed" mt="xs">
{t('automate.creation.tools.notConfigured', "! Not Configured")}
</Text>
)}
</div>
</div>
{index < tools.length - 1 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
</React.Fragment>
))}
{/* Arrow before Add Tool Button */}
{tools.length > 0 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
{/* Add Tool Button */}
<div style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden'
}}>
<AutomationEntry
title={t('automate.creation.tools.addTool', 'Add Tool')}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={onToolAdd}
keepIconColor={true}
/>
</div>
</Stack>
</div>
);
}

View File

@ -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;

View File

@ -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
});
}

View File

@ -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<string, ToolRegistryEntry>;
}
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
const { t } = useTranslation();
const [automationName, setAutomationName] = useState('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = (operation: string) => {
const tool = toolRegistry?.[operation] as any;
return tool?.name || t(`tools.${operation}.name`, operation);
};
const getToolDefaultParameters = (operation: string): Record<string, any> => {
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<AutomationTool>) => {
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
};
}

View File

@ -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<any>({});
const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(AUTOMATION_STEPS.SELECTION);
const [stepData, setStepData] = useState<AutomationStepData>({ 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 (
<AutomationSelection
savedAutomations={savedAutomations}
onCreateNew={() => 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) => {
<AutomationCreation
mode={stepData.mode}
existingAutomation={stepData.automation}
onBack={() => 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,

View File

@ -0,0 +1,67 @@
/**
* Types for automation functionality
*/
export interface AutomationOperation {
operation: string;
parameters: Record<string, any>;
}
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<string, any>;
}
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;
}

View File

@ -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<File[]> => {
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}`);

View File

@ -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<AutomationProcessingResult> {
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<AutomationProcessingResult> {
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<AutomationProcessingResult> {
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<string, any>,
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;
}
}

View File

@ -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<string>();
/**
* 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();
}, []);
}