Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import { CanvasWorkflowIntegrationModal } from 'features/controlLayers/components/CanvasWorkflowIntegration/CanvasWorkflowIntegrationModal';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { CropImageModal } from 'features/cropper/components/CropImageModal';
import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const GlobalModalIsolator = memo(() => {
<SaveWorkflowAsDialog />
<CanvasManagerProviderGate>
<CanvasPasteModal />
<CanvasWorkflowIntegrationModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
<CropImageModal />
Expand Down
1 change: 1 addition & 0 deletions invokeai/frontend/web/src/app/logging/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const $logger = atom<Logger>(Roarr.child(BASE_CONTEXT));

export const zLogNamespace = z.enum([
'canvas',
'canvas-workflow-integration',
'config',
'dnd',
'events',
Expand Down
3 changes: 3 additions & 0 deletions invokeai/frontend/web/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/sli
import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice';
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { canvasWorkflowIntegrationSliceConfig } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice';
import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice';
import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice';
Expand Down Expand Up @@ -62,6 +63,7 @@ const SLICE_CONFIGS = {
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
[canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig,
Expand Down Expand Up @@ -91,6 +93,7 @@ const ALL_REDUCERS = {
canvasSliceConfig.slice.reducer,
canvasSliceConfig.undoableConfig?.reduxUndoOptions
),
[canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig.slice.reducer,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer,
[gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
Button,
ButtonGroup,
Flex,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spacer,
Spinner,
Text,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
canvasWorkflowIntegrationClosed,
selectCanvasWorkflowIntegrationIsOpen,
selectCanvasWorkflowIntegrationIsProcessing,
selectCanvasWorkflowIntegrationSelectedWorkflowId,
} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

import { CanvasWorkflowIntegrationParameterPanel } from './CanvasWorkflowIntegrationParameterPanel';
import { CanvasWorkflowIntegrationWorkflowSelector } from './CanvasWorkflowIntegrationWorkflowSelector';
import { useCanvasWorkflowIntegrationExecute } from './useCanvasWorkflowIntegrationExecute';

export const CanvasWorkflowIntegrationModal = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();

const isOpen = useAppSelector(selectCanvasWorkflowIntegrationIsOpen);
const isProcessing = useAppSelector(selectCanvasWorkflowIntegrationIsProcessing);
const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);

const { execute, canExecute } = useCanvasWorkflowIntegrationExecute();

const onClose = useCallback(() => {
if (!isProcessing) {
dispatch(canvasWorkflowIntegrationClosed());
}
}, [dispatch, isProcessing]);

const onExecute = useCallback(() => {
execute();
}, [execute]);

return (
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Heading size="md">{t('controlLayers.workflowIntegration.title', 'Run Workflow on Canvas')}</Heading>
</ModalHeader>
<ModalCloseButton isDisabled={isProcessing} />

<ModalBody>
<Flex direction="column" gap={4}>
<Text fontSize="sm" color="base.400">
{t(
'controlLayers.workflowIntegration.description',
'Select a workflow with Form Builder and an image parameter to run on the current canvas layer. You can adjust parameters before executing. The result will be added back to the canvas.'
)}
</Text>

<CanvasWorkflowIntegrationWorkflowSelector />

{selectedWorkflowId && <CanvasWorkflowIntegrationParameterPanel />}
</Flex>
</ModalBody>

<ModalFooter>
<ButtonGroup>
<Button variant="ghost" onClick={onClose} isDisabled={isProcessing}>
{t('common.cancel')}
</Button>
<Spacer />
<Button
onClick={onExecute}
isDisabled={!canExecute || isProcessing}
loadingText={t('controlLayers.workflowIntegration.executing', 'Executing...')}
>
{isProcessing && <Spinner size="sm" mr={2} />}
{t('controlLayers.workflowIntegration.execute', 'Execute Workflow')}
</Button>
</ButtonGroup>
</ModalFooter>
</ModalContent>
</Modal>
);
});

CanvasWorkflowIntegrationModal.displayName = 'CanvasWorkflowIntegrationModal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Box } from '@invoke-ai/ui-library';
import { WorkflowFormPreview } from 'features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview';
import { memo } from 'react';

export const CanvasWorkflowIntegrationParameterPanel = memo(() => {
return (
<Box w="full">
<WorkflowFormPreview />
</Box>
);
});

CanvasWorkflowIntegrationParameterPanel.displayName = 'CanvasWorkflowIntegrationParameterPanel';
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Flex, FormControl, FormLabel, Select, Spinner, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
canvasWorkflowIntegrationWorkflowSelected,
selectCanvasWorkflowIntegrationSelectedWorkflowId,
} from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';

import { useFilteredWorkflows } from './useFilteredWorkflows';

export const CanvasWorkflowIntegrationWorkflowSelector = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();

const selectedWorkflowId = useAppSelector(selectCanvasWorkflowIntegrationSelectedWorkflowId);
const { data: workflowsData, isLoading } = useListWorkflowsInfiniteInfiniteQuery(
{
per_page: 100, // Get a reasonable number of workflows
page: 0,
},
{
selectFromResult: ({ data, isLoading }) => ({
data,
isLoading,
}),
}
);

const workflows = useMemo(() => {
if (!workflowsData) {
return [];
}
// Flatten all pages into a single list
return workflowsData.pages.flatMap((page) => page.items);
}, [workflowsData]);

// Filter workflows to only show those with ImageFields
const { filteredWorkflows, isFiltering } = useFilteredWorkflows(workflows);

const onChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const workflowId = e.target.value || null;
dispatch(canvasWorkflowIntegrationWorkflowSelected({ workflowId }));
},
[dispatch]
);

if (isLoading || isFiltering) {
return (
<Flex alignItems="center" gap={2}>
<Spinner size="sm" />
<Text>
{isFiltering
? t('controlLayers.workflowIntegration.filteringWorkflows', 'Filtering workflows...')
: t('controlLayers.workflowIntegration.loadingWorkflows', 'Loading workflows...')}
</Text>
</Flex>
);
}

if (filteredWorkflows.length === 0) {
return (
<Text color="warning.400" fontSize="sm">
{workflows.length === 0
? t('controlLayers.workflowIntegration.noWorkflowsFound', 'No workflows found.')
: t(
'controlLayers.workflowIntegration.noWorkflowsWithImageField',
'No workflows with Form Builder and image input fields found. Create a workflow with the Form Builder and add an image field.'
)}
</Text>
);
}

return (
<FormControl>
<FormLabel>{t('controlLayers.workflowIntegration.selectWorkflow', 'Select Workflow')}</FormLabel>
<Select
placeholder={t('controlLayers.workflowIntegration.selectPlaceholder', 'Choose a workflow...')}
value={selectedWorkflowId || ''}
onChange={onChange}
>
{filteredWorkflows.map((workflow) => (
<option key={workflow.workflow_id} value={workflow.workflow_id}>
{workflow.name || t('controlLayers.workflowIntegration.unnamedWorkflow', 'Unnamed Workflow')}
</option>
))}
</Select>
</FormControl>
);
});

CanvasWorkflowIntegrationWorkflowSelector.displayName = 'CanvasWorkflowIntegrationWorkflowSelector';
Loading