merge conflict

This commit is contained in:
Jennifer Player 2024-02-14 17:28:59 -05:00
commit 5ed7972e5f
34 changed files with 693 additions and 202 deletions

View File

@ -540,7 +540,7 @@ class Graph(BaseModel):
except NodeNotFoundError:
return False
def get_node(self, node_path: str) -> InvocationsUnion:
def get_node(self, node_path: str) -> BaseInvocation:
"""Gets a node from the graph using a node path."""
# Materialized graphs may have nodes at the top level
graph, node_id = self._get_graph_and_node(node_path)
@ -891,7 +891,7 @@ class GraphExecutionState(BaseModel):
# If next is still none, there's no next node, return None
return next_node
def complete(self, node_id: str, output: InvocationOutputsUnion):
def complete(self, node_id: str, output: BaseInvocationOutput) -> None:
"""Marks a node as complete"""
if node_id not in self.execution_graph.nodes:

View File

@ -1,10 +1,11 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import List, Union
from typing import Callable, List, Union
import torch.nn as nn
from diffusers.models import AutoencoderKL, UNet2DConditionModel
from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel
def _conv_forward_asymmetric(self, input, weight, bias):
@ -26,70 +27,50 @@ def _conv_forward_asymmetric(self, input, weight, bias):
@contextmanager
def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL], seamless_axes: List[str]):
# Callable: (input: Tensor, weight: Tensor, bias: Optional[Tensor]) -> Tensor
to_restore: list[tuple[nn.Conv2d | nn.ConvTranspose2d, Callable]] = []
try:
to_restore = []
for m_name, m in model.named_modules():
if isinstance(model, UNet2DConditionModel):
if ".attentions." in m_name:
if not isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
continue
if isinstance(model, UNet2DConditionModel) and m_name.startswith("down_blocks.") and ".resnets." in m_name:
# down_blocks.1.resnets.1.conv1
_, block_num, _, resnet_num, submodule_name = m_name.split(".")
block_num = int(block_num)
resnet_num = int(resnet_num)
# Could be configurable to allow skipping arbitrary numbers of down blocks
if block_num >= len(model.down_blocks):
continue
if ".resnets." in m_name:
if ".conv2" in m_name:
continue
if ".conv_shortcut" in m_name:
continue
"""
if isinstance(model, UNet2DConditionModel):
if False and ".upsamplers." in m_name:
# Skip the second resnet (could be configurable)
if resnet_num > 0:
continue
if False and ".downsamplers." in m_name:
# Skip Conv2d layers (could be configurable)
if submodule_name == "conv2":
continue
if True and ".resnets." in m_name:
if True and ".conv1" in m_name:
if False and "down_blocks" in m_name:
continue
if False and "mid_block" in m_name:
continue
if False and "up_blocks" in m_name:
continue
m.asymmetric_padding_mode = {}
m.asymmetric_padding = {}
m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant"
m.asymmetric_padding["x"] = (
m._reversed_padding_repeated_twice[0],
m._reversed_padding_repeated_twice[1],
0,
0,
)
m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant"
m.asymmetric_padding["y"] = (
0,
0,
m._reversed_padding_repeated_twice[2],
m._reversed_padding_repeated_twice[3],
)
if True and ".conv2" in m_name:
continue
if True and ".conv_shortcut" in m_name:
continue
if True and ".attentions." in m_name:
continue
if False and m_name in ["conv_in", "conv_out"]:
continue
"""
if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
m.asymmetric_padding_mode = {}
m.asymmetric_padding = {}
m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant"
m.asymmetric_padding["x"] = (
m._reversed_padding_repeated_twice[0],
m._reversed_padding_repeated_twice[1],
0,
0,
)
m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant"
m.asymmetric_padding["y"] = (
0,
0,
m._reversed_padding_repeated_twice[2],
m._reversed_padding_repeated_twice[3],
)
to_restore.append((m, m._conv_forward))
m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d)
to_restore.append((m, m._conv_forward))
m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d)
yield

View File

@ -175,6 +175,7 @@
"statusUpscaling": "Upscaling",
"statusUpscalingESRGAN": "Upscaling (ESRGAN)",
"template": "Template",
"toResolve": "To resolve",
"training": "Training",
"trainingDesc1": "A dedicated workflow for training your own embeddings and checkpoints using Textual Inversion and Dreambooth from the web interface.",
"trainingDesc2": "InvokeAI already supports training custom embeddourings using Textual Inversion using the main script.",
@ -900,6 +901,7 @@
"doesNotExist": "does not exist",
"downloadWorkflow": "Download Workflow JSON",
"edge": "Edge",
"editMode": "Edit in Workflow Editor",
"enum": "Enum",
"enumDescription": "Enums are values that may be one of a number of options.",
"executionStateCompleted": "Completed",
@ -995,6 +997,7 @@
"problemReadingMetadata": "Problem reading metadata from image",
"problemReadingWorkflow": "Problem reading workflow from image",
"problemSettingTitle": "Problem Setting Title",
"resetToDefaultValue": "Reset to default value",
"reloadNodeTemplates": "Reload Node Templates",
"removeLinearView": "Remove from Linear View",
"reorderLinearView": "Reorder Linear View",
@ -1068,6 +1071,7 @@
"vaeModelFieldDescription": "TODO",
"validateConnections": "Validate Connections and Graph",
"validateConnectionsHelp": "Prevent invalid connections from being made, and invalid graphs from being invoked",
"viewMode": "Use in Linear View",
"unableToGetWorkflowVersion": "Unable to get workflow schema version",
"unrecognizedWorkflowVersion": "Unrecognized workflow schema version {{version}}",
"version": "Version",

View File

@ -6,7 +6,6 @@ import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/typ
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { t } from 'i18next';
import { z } from 'zod';
import { fromZodError } from 'zod-validation-error';
@ -53,7 +52,6 @@ export const addWorkflowLoadRequestedListener = () => {
});
}
dispatch(setActiveTab('nodes'));
requestAnimationFrame(() => {
$flow.get()?.fitView();
});

View File

@ -1,22 +1,28 @@
import { useStore } from '@nanostores/react';
import { useAppToaster } from 'app/components/Toaster';
import { $authToken } from 'app/store/nanostores/authToken';
import { useAppDispatch } from 'app/store/storeHooks';
import { imageDownloaded } from 'features/gallery/store/actions';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useImageUrlToBlob } from './useImageUrlToBlob';
export const useDownloadImage = () => {
const toaster = useAppToaster();
const { t } = useTranslation();
const imageUrlToBlob = useImageUrlToBlob();
const dispatch = useAppDispatch();
const authToken = useStore($authToken);
const downloadImage = useCallback(
async (image_url: string, image_name: string) => {
try {
const blob = await imageUrlToBlob(image_url);
const requestOpts = authToken
? {
headers: {
Authorization: `Bearer ${authToken}`,
},
}
: {};
const blob = await fetch(image_url, requestOpts).then((resp) => resp.blob());
if (!blob) {
throw new Error('Unable to create Blob');
}
@ -40,7 +46,7 @@ export const useDownloadImage = () => {
});
}
},
[t, toaster, imageUrlToBlob, dispatch]
[t, toaster, dispatch, authToken]
);
return { downloadImage };

View File

@ -1,6 +1,7 @@
import { IconButton } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import {
selectWorkflowSlice,
workflowExposedFieldAdded,
@ -18,7 +19,7 @@ type Props = {
const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const value = useFieldValue(nodeId, fieldName);
const selectIsExposed = useMemo(
() =>
createSelector(selectWorkflowSlice, (workflow) => {
@ -30,8 +31,8 @@ const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
const isExposed = useAppSelector(selectIsExposed);
const handleExposeField = useCallback(() => {
dispatch(workflowExposedFieldAdded({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
dispatch(workflowExposedFieldAdded({ nodeId, fieldName, value }));
}, [dispatch, fieldName, nodeId, value]);
const handleUnexposeField = useCallback(() => {
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));

View File

@ -8,7 +8,7 @@ import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice'
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiInfoBold, PiTrashSimpleBold, PiDotsSixVerticalBold } from 'react-icons/pi';
import { PiDotsSixVerticalBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
import EditableFieldTitle from './EditableFieldTitle';
import FieldTooltipContent from './FieldTooltipContent';

View File

@ -1,25 +1,23 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
const TopCenterPanel = () => {
const name = useAppSelector((s) => s.workflow.name);
return (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex flexDir="column" gap="2">
<Flex gap="2">
<AddNodeButton />
<WorkflowLibraryButton />
</Flex>
<Flex gap="2">
<AddNodeButton />
<UpdateNodesButton />
</Flex>
<Spacer />
<WorkflowName />
{!!name.length && <WorkflowName />}
<Spacer />
<ClearFlowButton />
<SaveWorkflowButton />

View File

@ -25,6 +25,7 @@ const UpdateNodesButton = () => {
icon={<PiWarningBold />}
onClick={handleClickUpdateNodes}
pointerEvents="auto"
colorScheme="warning"
/>
);
};

View File

@ -1,15 +0,0 @@
import { Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { memo } from 'react';
const TopCenterPanel = () => {
const name = useAppSelector((s) => s.workflow.name);
return (
<Text m={2} fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold" opacity={0.8}>
{name}
</Text>
);
};
export default memo(TopCenterPanel);

View File

@ -1,15 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
const TopRightPanel = () => {
return (
<Flex gap={2} position="absolute" top={2} insetInlineEnd={2}>
<WorkflowLibraryButton />
<WorkflowLibraryMenu />
</Flex>
);
};
export default memo(TopRightPanel);

View File

@ -0,0 +1,43 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiPencilBold } from 'react-icons/pi';
export const ModeToggle = () => {
const dispatch = useAppDispatch();
const mode = useAppSelector((s) => s.workflow.mode);
const { t } = useTranslation();
const onClickEdit = useCallback(() => {
dispatch(workflowModeChanged('edit'));
}, [dispatch]);
const onClickView = useCallback(() => {
dispatch(workflowModeChanged('view'));
}, [dispatch]);
return (
<Flex justifyContent="flex-end">
{mode === 'view' && (
<IconButton
aria-label={t('nodes.editMode')}
tooltip={t('nodes.editMode')}
onClick={onClickEdit}
icon={<PiPencilBold />}
colorScheme="invokeBlue"
/>
)}
{mode === 'edit' && (
<IconButton
aria-label={t('nodes.viewMode')}
tooltip={t('nodes.viewMode')}
onClick={onClickView}
icon={<PiEyeBold />}
colorScheme="invokeBlue"
/>
)}
</Flex>
);
};

View File

@ -1,22 +1,37 @@
import 'reactflow/dist/style.css';
import { Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import QueueControls from 'features/queue/components/QueueControls';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef } from 'react';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import InspectorPanel from './inspector/InspectorPanel';
import { WorkflowViewMode } from './viewMode/WorkflowViewMode';
import WorkflowPanel from './workflow/WorkflowPanel';
import { WorkflowMenu } from './WorkflowMenu';
import { WorkflowName } from './WorkflowName';
const panelGroupStyles: CSSProperties = { height: '100%', width: '100%' };
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
return {
mode: workflow.mode,
};
});
const NodeEditorPanelGroup = () => {
const { mode } = useAppSelector(selector);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const panelStorage = usePanelStorage();
const handleDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
return;
@ -27,22 +42,33 @@ const NodeEditorPanelGroup = () => {
return (
<Flex w="full" h="full" gap={2} flexDir="column">
<QueueControls />
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
storage={panelStorage}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowPanel />
</Panel>
<ResizeHandle orientation="horizontal" onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<InspectorPanel />
</Panel>
</PanelGroup>
<Flex w="full" justifyContent="space-between" alignItems="center" gap="4" padding={1}>
<Flex justifyContent="space-between" alignItems="center" gap="4">
<WorkflowLibraryButton />
<WorkflowName />
</Flex>
<WorkflowMenu />
</Flex>
{mode === 'view' && <WorkflowViewMode />}
{mode === 'edit' && (
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
storage={panelStorage}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowPanel />
</Panel>
<ResizeHandle orientation="horizontal" onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<InspectorPanel />
</Panel>
</PanelGroup>
)}
</Flex>
);
};

View File

@ -0,0 +1,26 @@
import { Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { ModeToggle } from './ModeToggle';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
return {
mode: workflow.mode,
};
});
export const WorkflowMenu = () => {
const { mode } = useAppSelector(selector);
return (
<Flex gap="2" alignItems="center">
{mode === 'edit' && <SaveWorkflowButton />}
<NewWorkflowButton />
<ModeToggle />
</Flex>
);
};

View File

@ -0,0 +1,37 @@
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useTranslation } from 'react-i18next';
import { PiDotOutlineFill } from 'react-icons/pi';
import WorkflowInfoTooltipContent from './viewMode/WorkflowInfoTooltipContent';
import { WorkflowWarning } from './viewMode/WorkflowWarning';
export const WorkflowName = () => {
const { name, isTouched, mode } = useAppSelector((s) => s.workflow);
const { t } = useTranslation();
return (
<Flex gap="1" alignItems="center">
{name.length ? (
<Tooltip label={<WorkflowInfoTooltipContent />} placement="top">
<Text fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold">
{name}
</Text>
</Tooltip>
) : (
<Text fontSize="lg" fontStyle="italic" fontWeight="semibold">
{t('workflows.unnamedWorkflow')}
</Text>
)}
{isTouched && mode === 'edit' && (
<Tooltip label="Workflow has unsaved changes">
<Flex>
<Icon as={PiDotOutlineFill} boxSize="20px" sx={{ color: 'invokeYellow.500' }} />
</Flex>
</Tooltip>
)}
<WorkflowWarning />
</Flex>
);
};

View File

@ -0,0 +1,53 @@
import { Flex, FormLabel, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
import FieldTooltipContent from 'features/nodes/components/flow/nodes/Invocation/fields/FieldTooltipContent';
import InputFieldRenderer from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { t } from 'i18next';
import { memo } from 'react';
import { PiArrowCounterClockwiseBold, PiInfoBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
const WorkflowField = ({ nodeId, fieldName }: Props) => {
const label = useFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, 'input');
const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName);
return (
<Flex layerStyle="second" position="relative" borderRadius="base" w="full" p={4} gap="2" flexDir="column">
<Flex alignItems="center">
<FormLabel fontSize="sm">{label || fieldTemplateTitle}</FormLabel>
<Spacer />
{isValueChanged && (
<IconButton
aria-label={t('nodes.resetToDefaultValue')}
tooltip={t('nodes.resetToDefaultValue')}
variant="ghost"
size="sm"
onClick={onReset}
icon={<PiArrowCounterClockwiseBold />}
/>
)}
<Tooltip
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="input" />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
>
<Flex h="24px" alignItems="center">
<Icon fontSize="md" color="base.300" as={PiInfoBold} />
</Flex>
</Tooltip>
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
);
};
export default memo(WorkflowField);

View File

@ -0,0 +1,68 @@
import { Box, Flex, Text } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
return {
name: workflow.name,
description: workflow.description,
notes: workflow.notes,
author: workflow.author,
tags: workflow.tags,
};
});
const WorkflowInfoTooltipContent = () => {
const { name, description, notes, author, tags } = useAppSelector(selector);
const { t } = useTranslation();
return (
<Flex flexDir="column" gap="2">
{!!name.length && (
<Box>
<Text fontWeight="semibold">{t('nodes.workflowName')}</Text>
<Text opacity={0.7} fontStyle="oblique 5deg">
{name}
</Text>
</Box>
)}
{!!author.length && (
<Box>
<Text fontWeight="semibold">{t('nodes.workflowAuthor')}</Text>
<Text opacity={0.7} fontStyle="oblique 5deg">
{author}
</Text>
</Box>
)}
{!!tags.length && (
<Box>
<Text fontWeight="semibold">{t('nodes.workflowTags')}</Text>
<Text opacity={0.7} fontStyle="oblique 5deg">
{tags}
</Text>
</Box>
)}
{!!description.length && (
<Box>
<Text fontWeight="semibold">{t('nodes.workflowDescription')}</Text>
<Text opacity={0.7} fontStyle="oblique 5deg">
{description}
</Text>
</Box>
)}
{!!notes.length && (
<Box>
<Text fontWeight="semibold">{t('nodes.workflowNotes')}</Text>
<Text opacity={0.7} fontStyle="oblique 5deg">
{notes}
</Text>
</Box>
)}
</Flex>
);
};
export default memo(WorkflowInfoTooltipContent);

View File

@ -0,0 +1,39 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { t } from 'i18next';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import WorkflowField from './WorkflowField';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
return {
fields: workflow.exposedFields,
name: workflow.name,
};
});
export const WorkflowViewMode = () => {
const { isLoading } = useGetOpenAPISchemaQuery();
const { fields } = useAppSelector(selector);
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full">
{isLoading ? (
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />
) : fields.length ? (
fields.map(({ nodeId, fieldName }) => (
<WorkflowField key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
))
) : (
<IAINoContentFallback label={t('nodes.noFieldsLinearview')} icon={null} />
)}
</Flex>
</ScrollableContent>
</Box>
);
};

View File

@ -0,0 +1,21 @@
import { Flex, Icon, Tooltip } from '@invoke-ai/ui-library';
import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate';
import { PiWarningBold } from 'react-icons/pi';
import { WorkflowWarningTooltip } from './WorkflowWarningTooltip';
export const WorkflowWarning = () => {
const nodesNeedUpdate = useGetNodesNeedUpdate();
if (!nodesNeedUpdate) {
return <></>;
}
return (
<Tooltip label={<WorkflowWarningTooltip />}>
<Flex h="full" alignItems="center" gap="2">
<Icon color="warning.400" as={PiWarningBold} />
</Flex>
</Tooltip>
);
};

View File

@ -0,0 +1,20 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useTranslation } from 'react-i18next';
export const WorkflowWarningTooltip = () => {
const { t } = useTranslation();
return (
<Flex flexDir="column" gap="2">
<Flex flexDir="column" gap="2">
<Text fontWeight="semibold">{t('toast.loadedWithWarnings')}</Text>
<Flex flexDir="column">
<Text>{t('common.toResolve')}:</Text>
<Text>
{t('nodes.editMode')} &gt;&gt; {t('nodes.updateAllNodes')} &gt;&gt; {t('common.save')}
</Text>
</Flex>
</Flex>
</Flex>
);
};

View File

@ -12,17 +12,17 @@ const WorkflowPanel = () => {
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
<Tabs variant="line" display="flex" w="full" h="full" flexDir="column">
<TabList>
<Tab>{t('common.linear')}</Tab>
<Tab>{t('common.details')}</Tab>
<Tab>{t('common.linear')}</Tab>
<Tab>JSON</Tab>
</TabList>
<TabPanels>
<TabPanel>
<WorkflowLinearTab />
<WorkflowGeneralTab />
</TabPanel>
<TabPanel>
<WorkflowGeneralTab />
<WorkflowLinearTab />
</TabPanel>
<TabPanel>
<WorkflowJSONTab />

View File

@ -0,0 +1,28 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { isEqual } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useFieldOriginalValue = (nodeId: string, fieldName: string) => {
const dispatch = useAppDispatch();
const selectOriginalExposedFieldValues = useMemo(
() =>
createSelector(
selectWorkflowSlice,
(workflow) =>
workflow.originalExposedFieldValues.find((v) => v.nodeId === nodeId && v.fieldName === fieldName)?.value
),
[nodeId, fieldName]
);
const originalValue = useAppSelector(selectOriginalExposedFieldValues);
const value = useFieldValue(nodeId, fieldName);
const isValueChanged = useMemo(() => !isEqual(value, originalValue), [value, originalValue]);
const onReset = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: originalValue }));
}, [dispatch, fieldName, nodeId, originalValue]);
return { originalValue, isValueChanged, onReset };
};

View File

@ -0,0 +1,23 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useFieldValue = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createMemoizedSelector(selectNodesSlice, (nodes) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
}
return node?.data.inputs[fieldName]?.value;
}),
[fieldName, nodeId]
);
const value = useAppSelector(selector);
return value;
};

View File

@ -18,6 +18,7 @@ import type {
MainModelFieldValue,
SchedulerFieldValue,
SDXLRefinerModelFieldValue,
StatefulFieldValue,
StringFieldValue,
T2IAdapterModelFieldValue,
VAEModelFieldValue,
@ -36,6 +37,7 @@ import {
zMainModelFieldValue,
zSchedulerFieldValue,
zSDXLRefinerModelFieldValue,
zStatefulFieldValue,
zStringFieldValue,
zT2IAdapterModelFieldValue,
zVAEModelFieldValue,
@ -478,6 +480,9 @@ export const nodesSlice = createSlice({
selectedEdgesChanged: (state, action: PayloadAction<string[]>) => {
state.selectedEdges = action.payload;
},
fieldValueReset: (state, action: FieldValueAction<StatefulFieldValue>) => {
fieldValueReducer(state, action, zStatefulFieldValue);
},
fieldStringValueChanged: (state, action: FieldValueAction<StringFieldValue>) => {
fieldValueReducer(state, action, zStringFieldValue);
},
@ -760,6 +765,7 @@ export const {
edgesChanged,
edgesDeleted,
edgeUpdated,
fieldValueReset,
fieldBoardValueChanged,
fieldBooleanValueChanged,
fieldColorValueChanged,
@ -834,7 +840,6 @@ export const isAnyNodeOrEdgeMutation = isAnyOf(
nodeIsOpenChanged,
nodeLabelChanged,
nodeNotesChanged,
nodesChanged,
nodesDeleted,
nodeUseCacheChanged,
notesNodeValueChanged,

View File

@ -1,4 +1,4 @@
import type { FieldType } from 'features/nodes/types/field';
import type { FieldIdentifier, FieldType, StatefulFieldValue } from 'features/nodes/types/field';
import type {
AnyNode,
InvocationNodeEdge,
@ -33,9 +33,16 @@ export type NodesState = {
selectionMode: SelectionMode;
};
export type WorkflowMode = 'edit' | 'view';
export type FieldIdentifierWithValue = FieldIdentifier & {
value: StatefulFieldValue;
};
export type WorkflowsState = Omit<WorkflowV2, 'nodes' | 'edges'> & {
_version: 1;
isTouched: boolean;
mode: WorkflowMode;
originalExposedFieldValues: FieldIdentifierWithValue[];
};
export type NodeTemplatesState = {

View File

@ -2,11 +2,16 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { workflowLoaded } from 'features/nodes/store/actions';
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice';
import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged, nodesDeleted } from 'features/nodes/store/nodesSlice';
import type {
FieldIdentifierWithValue,
WorkflowMode,
WorkflowsState as WorkflowState,
} from 'features/nodes/store/types';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow';
import { cloneDeep, isEqual, uniqBy } from 'lodash-es';
import { cloneDeep, isEqual, omit, uniqBy } from 'lodash-es';
export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
name: '',
@ -23,7 +28,9 @@ export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
export const initialWorkflowState: WorkflowState = {
_version: 1,
isTouched: true,
isTouched: false,
mode: 'view',
originalExposedFieldValues: [],
...blankWorkflow,
};
@ -31,15 +38,25 @@ export const workflowSlice = createSlice({
name: 'workflow',
initialState: initialWorkflowState,
reducers: {
workflowExposedFieldAdded: (state, action: PayloadAction<FieldIdentifier>) => {
workflowModeChanged: (state, action: PayloadAction<WorkflowMode>) => {
state.mode = action.payload;
},
workflowExposedFieldAdded: (state, action: PayloadAction<FieldIdentifierWithValue>) => {
state.exposedFields = uniqBy(
state.exposedFields.concat(action.payload),
state.exposedFields.concat(omit(action.payload, 'value')),
(field) => `${field.nodeId}-${field.fieldName}`
);
state.originalExposedFieldValues = uniqBy(
state.originalExposedFieldValues.concat(action.payload),
(field) => `${field.nodeId}-${field.fieldName}`
);
state.isTouched = true;
},
workflowExposedFieldRemoved: (state, action: PayloadAction<FieldIdentifier>) => {
state.exposedFields = state.exposedFields.filter((field) => !isEqual(field, action.payload));
state.originalExposedFieldValues = state.originalExposedFieldValues.filter(
(field) => !isEqual(omit(field, 'value'), action.payload)
);
state.isTouched = true;
},
workflowExposedFieldsReordered: (state, action: PayloadAction<FieldIdentifier[]>) => {
@ -82,15 +99,43 @@ export const workflowSlice = createSlice({
workflowIDChanged: (state, action: PayloadAction<string>) => {
state.id = action.payload;
},
workflowReset: () => cloneDeep(initialWorkflowState),
workflowSaved: (state) => {
state.isTouched = false;
},
},
extraReducers: (builder) => {
builder.addCase(workflowLoaded, (state, action) => {
const { nodes: _nodes, edges: _edges, ...workflowExtra } = action.payload;
return { ...initialWorkflowState, ...cloneDeep(workflowExtra) };
const { nodes, edges: _edges, ...workflowExtra } = action.payload;
const originalExposedFieldValues: FieldIdentifierWithValue[] = [];
workflowExtra.exposedFields.forEach((field) => {
const node = nodes.find((n) => n.id === field.nodeId);
if (!isInvocationNode(node)) {
return;
}
const input = node.data.inputs[field.fieldName];
if (!input) {
return;
}
const originalExposedFieldValue = {
nodeId: field.nodeId,
fieldName: field.fieldName,
value: input.value,
};
originalExposedFieldValues.push(originalExposedFieldValue);
});
return {
...cloneDeep(initialWorkflowState),
...cloneDeep(workflowExtra),
originalExposedFieldValues,
mode: state.mode,
};
});
builder.addCase(nodesDeleted, (state, action) => {
@ -101,6 +146,29 @@ export const workflowSlice = createSlice({
builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState));
builder.addCase(nodesChanged, (state, action) => {
// Not all changes to nodes should result in the workflow being marked touched
const filteredChanges = action.payload.filter((change) => {
// We always want to mark the workflow as touched if a node is added, removed, or reset
if (['add', 'remove', 'reset'].includes(change.type)) {
return true;
}
// Position changes can change the position and the dragging status of the node - ignore if the change doesn't
// affect the position
if (change.type === 'position' && (change.position || change.positionAbsolute)) {
return true;
}
// This change isn't relevant
return false;
});
if (filteredChanges.length > 0) {
state.isTouched = true;
}
});
builder.addMatcher(isAnyNodeOrEdgeMutation, (state) => {
state.isTouched = true;
});
@ -108,6 +176,7 @@ export const workflowSlice = createSlice({
});
export const {
workflowModeChanged,
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
workflowExposedFieldsReordered,
@ -120,7 +189,6 @@ export const {
workflowVersionChanged,
workflowContactChanged,
workflowIDChanged,
workflowReset,
workflowSaved,
} = workflowSlice.actions;

View File

@ -101,9 +101,11 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => {
const clearStorage = useClearStorage();
const handleOpenSettingsModel = useCallback(() => {
refetchIntermediatesCount();
if (shouldShowClearIntermediates) {
refetchIntermediatesCount();
}
_onSettingsModalOpen();
}, [_onSettingsModalOpen, refetchIntermediatesCount]);
}, [_onSettingsModalOpen, refetchIntermediatesCount, shouldShowClearIntermediates]);
const handleClickResetWebUI = useCallback(() => {
clearStorage();

View File

@ -1,13 +1,28 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
import NodeEditor from 'features/nodes/components/NodeEditor';
import { memo } from 'react';
import { ReactFlowProvider } from 'reactflow';
const NodesTab = () => {
return (
<ReactFlowProvider>
<NodeEditor />
</ReactFlowProvider>
);
const mode = useAppSelector((s) => s.workflow.mode);
if (mode === 'edit') {
return (
<ReactFlowProvider>
<NodeEditor />
</ReactFlowProvider>
);
} else {
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<Flex w="full" h="full">
<CurrentImageDisplay />
</Flex>
</Box>
);
}
};
export default memo(NodesTab);

View File

@ -0,0 +1,26 @@
import { IconButton } from '@invoke-ai/ui-library';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFilePlusBold } from 'react-icons/pi';
export const NewWorkflowButton = memo(() => {
const { t } = useTranslation();
const renderButton = useCallback(
(onClick: () => void) => (
<IconButton
aria-label={t('nodes.newWorkflow')}
tooltip={t('nodes.newWorkflow')}
icon={<PiFilePlusBold />}
onClick={onClick}
pointerEvents="auto"
/>
),
[t]
);
return <NewWorkflowConfirmationAlertDialog renderButton={renderButton} />;
});
NewWorkflowButton.displayName = 'NewWorkflowButton';

View File

@ -0,0 +1,63 @@
import { ConfirmationAlertDialog, Flex, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
renderButton: (onClick: () => void) => JSX.Element;
};
export const NewWorkflowConfirmationAlertDialog = memo((props: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const handleNewWorkflow = useCallback(() => {
dispatch(nodeEditorReset());
dispatch(workflowModeChanged('edit'));
dispatch(
addToast(
makeToast({
title: t('workflows.newWorkflowCreated'),
status: 'success',
})
)
);
onClose();
}, [dispatch, onClose, t]);
const onClick = useCallback(() => {
if (!isTouched) {
handleNewWorkflow();
return;
}
onOpen();
}, [handleNewWorkflow, isTouched, onOpen]);
return (
<>
{props.renderButton(onClick)}
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('nodes.newWorkflow')}
acceptCallback={handleNewWorkflow}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.newWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.newWorkflowDesc2')}</Text>
</Flex>
</ConfirmationAlertDialog>
</>
);
});
NewWorkflowConfirmationAlertDialog.displayName = 'NewWorkflowConfirmationAlertDialog';

View File

@ -2,7 +2,7 @@ import { IconButton, useDisclosure } from '@invoke-ai/ui-library';
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBooksBold } from 'react-icons/pi';
import { PiFolderOpenBold } from 'react-icons/pi';
import WorkflowLibraryModal from './WorkflowLibraryModal';
@ -15,7 +15,7 @@ const WorkflowLibraryButton = () => {
<IconButton
aria-label={t('workflows.workflowLibrary')}
tooltip={t('workflows.workflowLibrary')}
icon={<PiBooksBold />}
icon={<PiFolderOpenBold />}
onClick={disclosure.onOpen}
pointerEvents="auto"
/>

View File

@ -1,60 +1,22 @@
import { ConfirmationAlertDialog, Flex, MenuItem, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { MenuItem } from '@invoke-ai/ui-library';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFlowArrowBold } from 'react-icons/pi';
import { PiFilePlusBold } from 'react-icons/pi';
const NewWorkflowMenuItem = () => {
export const NewWorkflowMenuItem = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const handleNewWorkflow = useCallback(() => {
dispatch(nodeEditorReset());
dispatch(
addToast(
makeToast({
title: t('workflows.newWorkflowCreated'),
status: 'success',
})
)
);
onClose();
}, [dispatch, onClose, t]);
const onClick = useCallback(() => {
if (!isTouched) {
handleNewWorkflow();
return;
}
onOpen();
}, [handleNewWorkflow, isTouched, onOpen]);
return (
<>
<MenuItem as="button" icon={<PiFlowArrowBold />} onClick={onClick}>
const renderButton = useCallback(
(onClick: () => void) => (
<MenuItem as="button" icon={<PiFilePlusBold />} onClick={onClick}>
{t('nodes.newWorkflow')}
</MenuItem>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('nodes.newWorkflow')}
acceptCallback={handleNewWorkflow}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.newWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.newWorkflowDesc2')}</Text>
</Flex>
</ConfirmationAlertDialog>
</>
),
[t]
);
};
export default memo(NewWorkflowMenuItem);
return <NewWorkflowConfirmationAlertDialog renderButton={renderButton} />;
});
NewWorkflowMenuItem.displayName = 'NewWorkflowMenuItem';

View File

@ -8,7 +8,7 @@ import {
useGlobalMenuClose,
} from '@invoke-ai/ui-library';
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
import NewWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
import { NewWorkflowMenuItem } from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
import SaveWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem';
import SettingsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SettingsMenuItem';

View File

@ -33,7 +33,7 @@ classifiers = [
]
dependencies = [
# Core generation dependencies, pinned for reproducible builds.
"accelerate==0.26.1",
"accelerate==0.27.2",
"clip_anytorch==2.5.2", # replacing "clip @ https://github.com/openai/CLIP/archive/eaa22acb90a5876642d0507623e859909230a52d.zip",
"compel==2.0.2",
"controlnet-aux==0.0.7",