mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Code review
This commit is contained in:
parent
d04979ab46
commit
63c7cd9a47
@ -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 handleToolAdd = () => {
|
||||
const newTool: AutomationTool = {
|
||||
id: `tool-${Date.now()}`,
|
||||
operation: '',
|
||||
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
|
||||
configured: false,
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const canSaveAutomation = () => {
|
||||
return (
|
||||
automationName.trim() !== '' &&
|
||||
selectedTools.length > 0 &&
|
||||
selectedTools.every(tool => tool.configured && tool.operation !== '')
|
||||
);
|
||||
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']}
|
||||
<ToolList
|
||||
tools={selectedTools}
|
||||
toolRegistry={toolRegistry}
|
||||
selectedValue={tool.operation}
|
||||
placeholder={tool.name}
|
||||
onToolUpdate={updateTool}
|
||||
onToolRemove={removeTool}
|
||||
onToolConfigure={configureTool}
|
||||
onToolAdd={handleToolAdd}
|
||||
getToolName={getToolName}
|
||||
getToolDefaultParameters={getToolDefaultParameters}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
@ -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}
|
||||
|
@ -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)}
|
||||
|
149
frontend/src/components/tools/automate/ToolList.tsx
Normal file
149
frontend/src/components/tools/automate/ToolList.tsx
Normal 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>
|
||||
);
|
||||
}
|
42
frontend/src/constants/automation.ts
Normal file
42
frontend/src/constants/automation.ts
Normal 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;
|
@ -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
|
||||
});
|
||||
}
|
114
frontend/src/hooks/tools/automate/useAutomationForm.ts
Normal file
114
frontend/src/hooks/tools/automate/useAutomationForm.ts
Normal 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
|
||||
};
|
||||
}
|
@ -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,
|
||||
|
67
frontend/src/types/automation.ts
Normal file
67
frontend/src/types/automation.ts
Normal 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;
|
||||
}
|
@ -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}`);
|
||||
|
187
frontend/src/utils/automationFileProcessor.ts
Normal file
187
frontend/src/utils/automationFileProcessor.ts
Normal 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;
|
||||
}
|
||||
}
|
71
frontend/src/utils/resourceManager.ts
Normal file
71
frontend/src/utils/resourceManager.ts
Normal 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();
|
||||
}, []);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user