Feature/v2/automate (#4248)

* automate feature
* Moved all providers to app level to simplify homepage 
* Circular dependency fixes
* You will see that now toolRegistry gets a tool config and a tool
settings object. These enable automate to run the tools using as much
static code as possible.

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-08-22 14:40:27 +01:00
committed by GitHub
co-authored by Connor Yoh
parent 7d9c0b0298
commit 23d86deae7
84 changed files with 4784 additions and 572 deletions
+2 -2
View File
@@ -111,7 +111,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius={30}
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
styles={{
@@ -144,7 +144,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
height: '100%',
width: '100%',
border: 'none',
borderRadius: '30px',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-file-manager)'
}}
styles={{
@@ -0,0 +1,56 @@
import React from 'react';
import { Text, Button, Stack } from '@mantine/core';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
retry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const Fallback = this.props.fallback;
return <Fallback error={this.state.error} retry={this.retry} />;
}
return (
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
<Text size="lg" fw={500} c="red">Something went wrong</Text>
{process.env.NODE_ENV === 'development' && this.state.error && (
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
{this.state.error.message}
</Text>
)}
<Button onClick={this.retry} variant="light">
Try Again
</Button>
</Stack>
);
}
return this.props.children;
}
}
+4 -32
View File
@@ -1,13 +1,11 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { getSubcategoryLabel, ToolRegistryEntry } from "../../data/toolsTaxonomy";
import ToolButton from "./toolPicker/ToolButton";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import "./toolPicker/ToolPicker.css";
import { SubcategoryGroup, useToolSections } from "../../hooks/useToolSections";
import SubcategoryHeader from "./shared/SubcategoryHeader";
import { useToolSections } from "../../hooks/useToolSections";
import NoToolsFound from "./shared/NoToolsFound";
import { TFunction } from "i18next";
import { renderToolButtons } from "./shared/renderToolButtons";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -16,32 +14,6 @@ interface ToolPickerProps {
isSearching?: boolean;
}
// Helper function to render tool buttons for a subcategory
const renderToolButtons = (
t: TFunction,
subcategory: SubcategoryGroup,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
@@ -0,0 +1,70 @@
/**
* AddWatermarkSingleStepSettings - Used for automation only
*
* This component combines all watermark settings into a single step interface
* for use in the automation system. It includes type selection and all relevant
* settings in one unified component.
*/
import React from "react";
import { Stack } from "@mantine/core";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import WatermarkTypeSettings from "./WatermarkTypeSettings";
import WatermarkWording from "./WatermarkWording";
import WatermarkTextStyle from "./WatermarkTextStyle";
import WatermarkImageFile from "./WatermarkImageFile";
import WatermarkFormatting from "./WatermarkFormatting";
interface AddWatermarkSingleStepSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: AddWatermarkSingleStepSettingsProps) => {
return (
<Stack gap="lg">
{/* Watermark Type Selection */}
<WatermarkTypeSettings
watermarkType={parameters.watermarkType}
onWatermarkTypeChange={(type) => onParameterChange("watermarkType", type)}
disabled={disabled}
/>
{/* Conditional settings based on watermark type */}
{parameters.watermarkType === "text" && (
<>
<WatermarkWording
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
<WatermarkTextStyle
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{parameters.watermarkType === "image" && (
<WatermarkImageFile
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Formatting settings for both text and image */}
{parameters.watermarkType && (
<WatermarkFormatting
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
</Stack>
);
};
export default AddWatermarkSingleStepSettings;
@@ -6,7 +6,7 @@ import NumberInputWithUnit from "../shared/NumberInputWithUnit";
interface WatermarkFormattingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -6,7 +6,7 @@ import FileUploadButton from "../../shared/FileUploadButton";
interface WatermarkImageFileProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -17,7 +17,7 @@ const WatermarkImageFile = ({ parameters, onParameterChange, disabled = false }:
<Stack gap="sm">
<FileUploadButton
file={parameters.watermarkImage}
onChange={(file) => onParameterChange('watermarkImage', file)}
onChange={(file) => onParameterChange('watermarkImage', file || undefined)}
accept="image/*"
disabled={disabled}
placeholder={t('watermark.settings.image.choose', 'Choose Image')}
@@ -5,7 +5,7 @@ import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAdd
interface WatermarkStyleSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -19,7 +19,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
<NumberInput
value={parameters.rotation}
onChange={(value) => onParameterChange('rotation', value || 0)}
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : (parseInt(value as string, 10) || 0))}
min={-360}
max={360}
disabled={disabled}
@@ -28,7 +28,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.opacity', 'Opacity (%)')}</Text>
<NumberInput
value={parameters.opacity}
onChange={(value) => onParameterChange('opacity', value || 50)}
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={100}
disabled={disabled}
@@ -40,7 +40,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
<NumberInput
value={parameters.widthSpacer}
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={200}
disabled={disabled}
@@ -49,7 +49,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
<NumberInput
value={parameters.heightSpacer}
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={200}
disabled={disabled}
@@ -6,7 +6,7 @@ import { alphabetOptions } from "../../../constants/addWatermarkConstants";
interface WatermarkTextStyleProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -6,7 +6,7 @@ import { removeEmojis } from "../../../utils/textUtils";
interface WatermarkWordingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Text,
Stack,
Group,
TextInput,
Divider,
Modal
} from '@mantine/core';
import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
import ToolList from './ToolList';
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
interface AutomationCreationProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
onBack: () => void;
onComplete: (automation: AutomationConfig) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
const { t } = useTranslation();
const {
automationName,
setAutomationName,
selectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
} = useAutomationForm({ mode, existingAutomation, toolRegistry });
const [configModalOpen, setConfigModalOpen] = useState(false);
const [configuraingToolIndex, setConfiguringToolIndex] = useState(-1);
const [unsavedWarningOpen, setUnsavedWarningOpen] = useState(false);
const configureTool = (index: number) => {
setConfiguringToolIndex(index);
setConfigModalOpen(true);
};
const handleToolConfigSave = (parameters: Record<string, any>) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
parameters
});
}
setConfigModalOpen(false);
setConfiguringToolIndex(-1);
};
const handleToolConfigCancel = () => {
setConfigModalOpen(false);
setConfiguringToolIndex(-1);
};
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 = () => {
if (hasUnsavedChanges()) {
setUnsavedWarningOpen(true);
} else {
onBack();
}
};
const handleConfirmBack = () => {
setUnsavedWarningOpen(false);
onBack();
};
const handleCancelBack = () => {
setUnsavedWarningOpen(false);
};
const saveAutomation = async () => {
if (!canSaveAutomation()) return;
const automation = {
name: automationName.trim(),
description: '',
operations: selectedTools.map(tool => ({
operation: tool.operation,
parameters: tool.parameters || {}
}))
};
try {
const { automationStorage } = await import('../../../services/automationStorage');
const savedAutomation = await automationStorage.saveAutomation(automation);
onComplete(savedAutomation);
} catch (error) {
console.error('Error saving automation:', error);
}
};
const currentConfigTool = configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
return (
<div>
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
</Text>
<Divider mb="md" />
<Stack gap="md">
{/* Automation Name */}
<TextInput
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
value={automationName}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
{/* Selected Tools List */}
{selectedTools.length > 0 && (
<ToolList
tools={selectedTools}
toolRegistry={toolRegistry}
onToolUpdate={updateTool}
onToolRemove={removeTool}
onToolConfigure={configureTool}
onToolAdd={handleToolAdd}
getToolName={getToolName}
getToolDefaultParameters={getToolDefaultParameters}
/>
)}
<Divider />
{/* Save Button */}
<Button
leftSection={<CheckIcon />}
onClick={saveAutomation}
disabled={!canSaveAutomation()}
fullWidth
>
{t('automate.creation.save', 'Save Automation')}
</Button>
</Stack>
{/* Tool Configuration Modal */}
{currentConfigTool && (
<ToolConfigurationModal
opened={configModalOpen}
tool={currentConfigTool}
onSave={handleToolConfigSave}
onCancel={handleToolConfigCancel}
toolRegistry={toolRegistry}
/>
)}
{/* Unsaved Changes Warning Modal */}
<Modal
opened={unsavedWarningOpen}
onClose={handleCancelBack}
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
centered
>
<Stack gap="md">
<Text>
{t('automate.creation.unsavedChanges.message', 'You have unsaved changes. Are you sure you want to go back? All changes will be lost.')}
</Text>
<Group gap="md" justify="flex-end">
<Button variant="outline" onClick={handleCancelBack}>
{t('automate.creation.unsavedChanges.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleConfirmBack}>
{t('automate.creation.unsavedChanges.confirm', 'Go Back')}
</Button>
</Group>
</Stack>
</Modal>
</div>
);
}
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
title?: string;
/** MUI Icon component for the badge */
badgeIcon?: React.ComponentType<any>;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
onClick: () => void;
/** Whether to keep the icon at normal color (for special cases like "Add New") */
keepIconColor?: boolean;
/** Show menu for saved/suggested automations */
showMenu?: boolean;
/** Edit handler */
onEdit?: () => void;
/** Delete handler */
onDelete?: () => void;
}
export default function AutomationEntry({
title,
badgeIcon: BadgeIcon,
operations,
onClick,
keepIconColor = false,
showMenu = false,
onEdit,
onDelete
}: AutomationEntryProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
const renderContent = () => {
if (title) {
// Custom automation with title
return (
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
{title}
</Text>
</Group>
);
} else {
// Suggested automation showing tool chain
return (
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Group gap="xs" justify="flex-start" style={{ flex: 1 }}>
{operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)}
</Text>
{index < operations.length - 1 && (
<Text size="xs" c="dimmed" style={{ color: 'var(--mantine-color-text)' }}>
</Text>
)}
</React.Fragment>
))}
</Group>
</Group>
);
}
};
return (
<Box
style={{
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
borderRadius: 'var(--mantine-radius-md)',
transition: 'background-color 0.15s ease',
padding: '0.75rem 1rem',
cursor: 'pointer'
}}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Group gap="md" align="center" justify="space-between" style={{ width: '100%' }}>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start' }}>
{renderContent()}
</div>
{showMenu && (
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onEdit && (
<Menu.Item
leftSection={<EditIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
{t('edit', 'Edit')}
</Menu.Item>
)}
{onDelete && (
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{t('delete', 'Delete')}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Box>
);
}
@@ -0,0 +1,223 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
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: AutomationConfig;
onComplete: () => void;
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[]>([]);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
React.useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const tool = toolRegistry[op.operation];
return {
id: `${op.operation}-${index}`,
operation: op.operation,
name: tool?.name || op.operation,
status: EXECUTION_STATUS.PENDING
};
});
setExecutionSteps(steps);
setCurrentStepIndex(-1);
}
}, [automation, toolRegistry]);
// Cleanup when component unmounts
React.useEffect(() => {
return () => {
// Reset progress state when component unmounts
setExecutionSteps([]);
setCurrentStepIndex(-1);
// Clean up any blob URLs
cleanup();
};
}, [cleanup]);
const executeAutomation = async () => {
if (!selectedFiles || selectedFiles.length === 0) {
return;
}
if (!automateOperation) {
console.error('No automateOperation provided');
return;
}
// Reset progress tracking
setCurrentStepIndex(0);
setExecutionSteps(prev => prev.map(step => ({ ...step, status: EXECUTION_STATUS.PENDING, error: undefined })));
try {
// Use the automateOperation.executeOperation to handle file consumption properly
await automateOperation.executeOperation(
{
automationConfig: automation,
onStepStart: (stepIndex: number, operationName: string) => {
setCurrentStepIndex(stepIndex);
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
));
},
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
));
},
onStepError: (stepIndex: number, error: string) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.ERROR, error } : step
));
}
},
selectedFiles
);
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
} catch (error: any) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
};
const getProgress = () => {
if (executionSteps.length === 0) return 0;
const completedSteps = executionSteps.filter(step => step.status === EXECUTION_STATUS.COMPLETED).length;
return (completedSteps / executionSteps.length) * 100;
};
const getStepIcon = (step: ExecutionStep) => {
switch (step.status) {
case EXECUTION_STATUS.COMPLETED:
return <CheckIcon style={{ fontSize: 16, color: 'green' }} />;
case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}></span>;
case EXECUTION_STATUS.RUNNING:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />;
default:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderRadius: '50%'
}} />;
}
};
return (
<div>
<Stack gap="md">
{/* Automation Info */}
<Card padding="md" withBorder>
<Text size="sm" fw={500} mb="xs">
{automation?.name || t("automate.sequence.unnamed", "Unnamed Automation")}
</Text>
<Text size="xs" c="dimmed">
{t("automate.sequence.steps", "{{count}} steps", { count: executionSteps.length })}
</Text>
</Card>
{/* Progress Bar */}
{isExecuting && (
<div>
<Text size="sm" mb="xs">
Progress: {currentStepIndex + 1}/{executionSteps.length}
</Text>
<Progress value={getProgress()} size="lg" />
</div>
)}
{/* Execution Steps */}
<Stack gap="xs">
{executionSteps.map((step, index) => (
<Group key={step.id} gap="sm" align="center">
<Text size="xs" c="dimmed" style={{ minWidth: "1rem", textAlign: "center" }}>
{index + 1}
</Text>
{getStepIcon(step)}
<div style={{ flex: 1 }}>
<Text
size="sm"
style={{
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}
</Text>
{step.error && (
<Text size="xs" c="red" mt="xs">
{step.error}
</Text>
)}
</div>
</Group>
))}
</Stack>
{/* Action Buttons */}
<Group justify="space-between" mt="xl">
<Button
leftSection={<PlayArrowIcon />}
onClick={executeAutomation}
disabled={isExecuting || !selectedFiles || selectedFiles.length === 0}
loading={isExecuting}
>
{isExecuting
? t("automate.sequence.running", "Running Automation...")
: t("automate.sequence.run", "Run Automation")}
</Button>
{hasResults && (
<Button variant="light" onClick={onComplete}>
{t("automate.sequence.finish", "Finish")}
</Button>
)}
</Group>
</Stack>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
</div>
);
}
@@ -0,0 +1,76 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Title, Stack, Divider } from "@mantine/core";
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: AutomationConfig[];
onCreateNew: () => void;
onRun: (automation: AutomationConfig) => void;
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
}
export default function AutomationSelection({
savedAutomations,
onCreateNew,
onRun,
onEdit,
onDelete
}: AutomationSelectionProps) {
const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations();
return (
<div>
<Title order={3} size="h4" fw={600} mb="md" style={{color: 'var(--mantine-color-dimmed)'}}>
{t("automate.selection.saved.title", "Saved")}
</Title>
<Stack gap="xs">
<AutomationEntry
title={t("automate.selection.createNew.title", "Create New Automation")}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={onCreateNew}
keepIconColor={true}
/>
{/* Saved Automations */}
{savedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
title={automation.name}
badgeIcon={SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
/>
))}
<Divider pb='sm' />
{/* Suggested Automations */}
<div>
<Title order={3} size="h4" fw={600} mb="md"style={ {color: 'var(--mantine-color-dimmed)'}}>
{t("automate.selection.suggested.title", "Suggested")}
</Title>
<Stack gap="xs">
{suggestedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
badgeIcon={automation.icon}
operations={automation.operations.map(op => op.operation)}
onClick={() => onRun(automation)}
/>
))}
</Stack>
</div>
</Stack>
</div>
);
}
@@ -0,0 +1,138 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Title,
Button,
Group,
Stack,
Text,
Alert
} from '@mantine/core';
import SettingsIcon from '@mui/icons-material/Settings';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import WarningIcon from '@mui/icons-material/Warning';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { getAvailableToExtensions } from '../../../utils/convertUtils';
interface ToolConfigurationModalProps {
opened: boolean;
tool: {
id: string;
operation: string;
name: string;
parameters?: any;
};
onSave: (parameters: any) => void;
onCancel: () => void;
toolRegistry: ToolRegistry;
}
export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) {
const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({});
const [isValid, setIsValid] = useState(true);
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation];
const SettingsComponent = toolInfo?.settingsComponent;
// Initialize parameters from tool (which should contain defaults from registry)
useEffect(() => {
if (tool.parameters) {
setParameters(tool.parameters);
} else {
// Fallback to empty parameters if none provided
setParameters({});
}
}, [tool.parameters, tool.operation]);
// Render the settings component
const renderToolSettings = () => {
if (!SettingsComponent) {
return (
<Alert icon={<WarningIcon />} color="orange">
<Text size="sm">
{t('automate.config.noSettings', 'This tool does not have configurable settings.')}
</Text>
</Alert>
);
}
// Special handling for ConvertSettings which needs additional props
if (tool.operation === 'convert') {
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
getAvailableToExtensions={getAvailableToExtensions}
selectedFiles={[]}
disabled={false}
/>
);
}
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
disabled={false}
/>
);
};
const handleSave = () => {
if (isValid) {
onSave(parameters);
}
};
return (
<Modal
opened={opened}
onClose={onCancel}
title={
<Group gap="xs">
<SettingsIcon />
<Title order={3}>
{t('automate.config.title', 'Configure {{toolName}}', { toolName: tool.name })}
</Title>
</Group>
}
size="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
</Text>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{renderToolSettings()}
</div>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
leftSection={<CloseIcon />}
onClick={onCancel}
>
{t('automate.config.cancel', 'Cancel')}
</Button>
<Button
leftSection={<CheckIcon />}
onClick={handleSave}
disabled={!isValid}
>
{t('automate.config.save', 'Save Configuration')}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -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>
);
}
@@ -0,0 +1,182 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
export default function ToolSelector({
onSelect,
excludeTools = [],
toolRegistry,
selectedValue,
placeholder
}: ToolSelectorProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// Filter out excluded tools (like 'automate' itself)
const baseFilteredTools = useMemo(() => {
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
}, [toolRegistry, excludeTools]);
// Apply search filter
const filteredTools = useMemo(() => {
if (!searchTerm.trim()) {
return baseFilteredTools;
}
const lowercaseSearch = searchTerm.toLowerCase();
return baseFilteredTools.filter(([key, tool]) => {
return (
tool.name.toLowerCase().includes(lowercaseSearch) ||
tool.description?.toLowerCase().includes(lowercaseSearch) ||
key.toLowerCase().includes(lowercaseSearch)
);
});
}, [baseFilteredTools, searchTerm]);
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<string, ToolRegistryEntry> = {};
baseFilteredTools.forEach(([key, tool]) => {
registry[key] = tool;
});
return registry;
}, [baseFilteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(filteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
const displayGroups = useMemo(() => {
if (isSearching) {
return searchGroups || [];
}
if (!sections || sections.length === 0) {
return [];
}
// Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all');
return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections]);
const handleToolSelect = useCallback((toolKey: string) => {
onSelect(toolKey);
setOpened(false);
setSearchTerm(''); // Clear search to show the selected tool display
}, [onSelect]);
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
), [displayGroups, handleToolSelect, isSearching, t]
);
const handleSearchFocus = () => {
setOpened(true);
};
const handleSearchChange = (value: string) => {
setSearchTerm(value);
if (!opened) {
setOpened(true);
}
};
// Get display value for selected tool
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) {
return toolRegistry[selectedValue].name;
}
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
return (
<div style={{ position: 'relative', width: '100%' }}>
<Menu
opened={opened}
onChange={(isOpen) => {
setOpened(isOpen);
// Clear search term when menu closes to show proper display
if (!isOpen) {
setSearchTerm('');
}
}}
closeOnClickOutside={true}
closeOnEscape={true}
position="bottom-start"
offset={4}
withinPortal={false}
trapFocus={false}
shadow="sm"
transitionProps={{ duration: 0 }}
>
<Menu.Target>
<div style={{ width: '100%' }}>
{selectedValue && toolRegistry[selectedValue] && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and not searching
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--mantine-spacing-sm)',
padding: '0 0.5rem',
borderRadius: 'var(--mantine-radius-sm)',
}}>
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
{toolRegistry[selectedValue].icon}
</div>
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
{toolRegistry[selectedValue].name}
</Text>
</div>
</div>
) : (
// Show search input when no tool selected or actively searching
<ToolSearch
value={searchTerm}
onChange={handleSearchChange}
toolRegistry={filteredToolRegistry}
mode="filter"
placeholder={getDisplayValue()}
hideIcon={true}
onFocus={handleSearchFocus}
/>
)}
</div>
</Menu.Target>
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
<ScrollArea h={350}>
<Stack gap="sm" p="sm">
{displayGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
{isSearching
? t('tools.noSearchResults', 'No tools found')
: t('tools.noTools', 'No tools available')
}
</Text>
) : (
renderedTools
)}
</Stack>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</div>
);
}
@@ -2,6 +2,7 @@ import React from 'react';
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement {
@@ -9,6 +9,7 @@ export interface FilesStepConfig {
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
export interface MiddleStepConfig {
@@ -63,7 +64,7 @@ export function createToolFlow(config: ToolFlowConfig) {
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{/* Files Step */}
{steps.createFilesStep({
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
placeholder: config.files.placeholder,
@@ -0,0 +1,34 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import ToolButton from '../toolPicker/ToolButton';
import SubcategoryHeader from './SubcategoryHeader';
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
import { TFunction } from 'i18next';
import { SubcategoryGroup } from '../../../hooks/useToolSections';
// Helper function to render tool buttons for a subcategory
export const renderToolButtons = (
t: TFunction,
subcategory: SubcategoryGroup,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);
@@ -12,19 +12,26 @@ interface ToolSearchProps {
onToolSelect?: (toolId: string) => void;
mode: 'filter' | 'dropdown';
selectedToolKey?: string | null;
placeholder?: string;
hideIcon?: boolean;
onFocus?: () => void;
}
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
mode = 'filter',
selectedToolKey
selectedToolKey,
placeholder,
hideIcon = false,
onFocus
}: ToolSearchProps) => {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredTools = useMemo(() => {
if (!value.trim()) return [];
@@ -47,7 +54,12 @@ const ToolSearch = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
if (
searchRef.current &&
dropdownRef.current &&
!searchRef.current.contains(event.target as Node) &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
@@ -61,9 +73,10 @@ const ToolSearch = ({
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
icon={<span className="material-symbols-rounded">search</span>}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <span className="material-symbols-rounded">search</span>}
autoComplete="off"
/>
</div>
);
@@ -77,19 +90,19 @@ const ToolSearch = ({
{searchInput}
{dropdownOpen && filteredTools.length > 0 && (
<div
ref={dropdownRef}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '8px',
marginTop: '4px',
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '6px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxHeight: '300px',
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
overflowY: 'auto'
}}
>
<Stack gap="xs" style={{ padding: '8px' }}>
@@ -97,7 +110,10 @@ const ToolSearch = ({
<Button
key={id}
variant="subtle"
onClick={() => onToolSelect && onToolSelect(id)}
onClick={() => {
onToolSelect && onToolSelect(id);
setDropdownOpen(false);
}}
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
@@ -126,4 +142,4 @@ const ToolSearch = ({
);
};
export default ToolSearch;
export default ToolSearch;