From e35537e60a0d1e5e8bd8a870fc56b0fa6955d475 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 7 Mar 2025 15:59:42 +1000
Subject: [PATCH 01/67] fix(mm): move flux_redux starter model to the flux
bundle, make siglip a dependency of it
---
invokeai/backend/model_manager/starter_models.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py
index 04a4109b26..255e54c262 100644
--- a/invokeai/backend/model_manager/starter_models.py
+++ b/invokeai/backend/model_manager/starter_models.py
@@ -610,6 +610,7 @@ flux_redux = StarterModel(
source="black-forest-labs/FLUX.1-Redux-dev::flux1-redux-dev.safetensors",
description="FLUX Redux model (for image variation).",
type=ModelType.FluxRedux,
+ dependencies=[siglip],
)
# endregion
@@ -717,7 +718,6 @@ sdxl_bundle: list[StarterModel] = [
scribble_sdxl,
tile_sdxl,
swinir,
- flux_redux,
]
flux_bundle: list[StarterModel] = [
@@ -730,7 +730,7 @@ flux_bundle: list[StarterModel] = [
ip_adapter_flux,
flux_canny_control_lora,
flux_depth_control_lora,
- siglip,
+ flux_redux,
]
STARTER_BUNDLES: dict[str, list[StarterModel]] = {
From 57533657f94d9634ec6ccf8eebf327b889898c0d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 7 Mar 2025 16:01:48 +1000
Subject: [PATCH 02/67] feat(nodes): remove siglip from flux_redux, dl it jit
when needed if we cannot find it
This follows the same pattern for IP Adapter w/ its CLIP Vision model. The SigLIP model is unlikely to ever change and we don't want to force the user to select it anywhere. Hardcoding it is safe and makes the UX much nicer.
The alternative is a model dropdown that will likely only ever have one valid choice in it.
---
invokeai/app/invocations/flux_redux.py | 46 ++++++++++++++++++++------
1 file changed, 35 insertions(+), 11 deletions(-)
diff --git a/invokeai/app/invocations/flux_redux.py b/invokeai/app/invocations/flux_redux.py
index 1b84814612..f6581bd73d 100644
--- a/invokeai/app/invocations/flux_redux.py
+++ b/invokeai/app/invocations/flux_redux.py
@@ -20,8 +20,11 @@ from invokeai.app.invocations.fields import (
)
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.primitives import ImageField
+from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.flux.redux.flux_redux_model import FluxReduxModel
+from invokeai.backend.model_manager.config import AnyModelConfig, BaseModelType, ModelType
+from invokeai.backend.model_manager.starter_models import siglip
from invokeai.backend.sig_lip.sig_lip_pipeline import SigLipPipeline
from invokeai.backend.util.devices import TorchDevice
@@ -35,16 +38,12 @@ class FluxReduxOutput(BaseInvocationOutput):
)
-SIGLIP_STARTER_MODEL_NAME = "SigLIP - google/siglip-so400m-patch14-384"
-FLUX_REDUX_STARTER_MODEL_NAME = "FLUX Redux"
-
-
@invocation(
"flux_redux",
title="FLUX Redux",
tags=["ip_adapter", "control"],
category="ip_adapter",
- version="1.0.0",
+ version="2.0.0",
classification=Classification.Prototype,
)
class FluxReduxInvocation(BaseInvocation):
@@ -61,11 +60,6 @@ class FluxReduxInvocation(BaseInvocation):
title="FLUX Redux Model",
ui_type=UIType.FluxReduxModel,
)
- siglip_model: ModelIdentifierField = InputField(
- description="The SigLIP model to use.",
- title="SigLIP Model",
- ui_type=UIType.SigLipModel,
- )
def invoke(self, context: InvocationContext) -> FluxReduxOutput:
image = context.images.get_pil(self.image.image_name, "RGB")
@@ -80,7 +74,8 @@ class FluxReduxInvocation(BaseInvocation):
@torch.no_grad()
def _siglip_encode(self, context: InvocationContext, image: Image.Image) -> torch.Tensor:
- with context.models.load(self.siglip_model).model_on_device() as (_, siglip_pipeline):
+ siglip_model_config = self._get_siglip_model(context)
+ with context.models.load(siglip_model_config.key).model_on_device() as (_, siglip_pipeline):
assert isinstance(siglip_pipeline, SigLipPipeline)
return siglip_pipeline.encode_image(
x=image, device=TorchDevice.choose_torch_device(), dtype=TorchDevice.choose_torch_dtype()
@@ -93,3 +88,32 @@ class FluxReduxInvocation(BaseInvocation):
dtype = next(flux_redux.parameters()).dtype
encoded_x = encoded_x.to(dtype=dtype)
return flux_redux(encoded_x)
+
+ def _get_siglip_model(self, context: InvocationContext) -> AnyModelConfig:
+ siglip_models = context.models.search_by_attrs(name=siglip.name, base=BaseModelType.Any, type=ModelType.SigLIP)
+
+ if not len(siglip_models) > 0:
+ context.logger.warning(
+ f"The SigLIP model required by FLUX Redux ({siglip.name}) is not installed. Downloading and installing now. This may take a while."
+ )
+
+ # TODO(psyche): Can the probe reliably determine the type of the model? Just hardcoding it bc I don't want to experiment now
+ config_overrides = ModelRecordChanges(name=siglip.name, type=ModelType.SigLIP)
+
+ # Queue the job
+ job = context._services.model_manager.install.heuristic_import(siglip.source, config=config_overrides)
+
+ # Wait for up to 10 minutes - model is ~3.5GB
+ context._services.model_manager.install.wait_for_job(job, timeout=600)
+
+ siglip_models = context.models.search_by_attrs(
+ name=siglip.name,
+ base=BaseModelType.Any,
+ type=ModelType.SigLIP,
+ )
+
+ if len(siglip_models) == 0:
+ context.logger.error("Error while fetching SigLIP for FLUX Redux")
+ assert len(siglip_models) == 1
+
+ return siglip_models[0]
From f62b9ad919ff05ca1ee3b8de4b13de609464f21b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 7 Mar 2025 16:02:12 +1000
Subject: [PATCH 03/67] chore(ui): typegen
---
invokeai/frontend/web/src/services/api/schema.ts | 6 ------
1 file changed, 6 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 59efc63471..486a31f107 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -8035,12 +8035,6 @@ export type components = {
* @default null
*/
redux_model?: components["schemas"]["ModelIdentifierField"];
- /**
- * SigLIP Model
- * @description The SigLIP model to use.
- * @default null
- */
- siglip_model?: components["schemas"]["ModelIdentifierField"];
/**
* type
* @default flux_redux
From c259899bf49c6b00c09adc65f6acd56dbffc78c0 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 7 Mar 2025 16:35:11 +1000
Subject: [PATCH 04/67] feat(ui): support for FLUX Redux in canvas
User facing:
When a FLUX main model is selected, users may now add Regional Reference Image layers.
When switching between FLUX Redux and FLUX IP Adapter, the settings will change to match the model type. (IP Adapter has weight, begin/end step, but Redux does not.) The image will be retained when switching between the two.
Otherwise it works the same way as IP Adapter - both in Global and Regional Reference Image layers.
---
Internal state handling:
Slightly awkward, but it was easiest to make FLUX Redux a second type of IP Adapter in redux state.
Global and regional reference images still have a single `ipAdapter` field, but it can have a type of `ip_adapter` or `flux_redux`.
Ideally, this field is called `config` or `settings` or something, but we are past that point. We _could_ do a migration to rename it, but I don't think it's worth the effort.
---
Other changes:
- Updated canvas layer validators to handle FLUX Redux.
- Updated model list loading logic to un-set FLUX Redux models in Canvas if they are not in the list (e.g. if the user deletes the model in the main app).
- Updated graph builders - new `addFLUXRedux` util & updated `addRegions` util.
- Updated the `buildModelsHook` util to return a hook that accepts a filter callback. This handles a discrepancy: FLUX IP Adapter does not support regional guidance, but FLUX Redux does. The Regional Guidance settings provide the filter to filter out FLUX IP Adapter models from the combined list of IP Adapter ahd Redux models.
---
.../listeners/modelsLoaded.ts | 53 ++++++++
.../components/CanvasAddEntityButtons.tsx | 5 +-
.../EntityListGlobalActionBarAddLayerMenu.tsx | 5 +-
.../components/IPAdapter/CLIPVisionModel.tsx | 61 ++++++++++
.../components/IPAdapter/IPAdapterModel.tsx | 101 +++++-----------
.../IPAdapter/IPAdapterSettings.tsx | 37 +++---
.../RegionalGuidanceIPAdapterSettings.tsx | 37 +++---
.../controlLayers/store/canvasSlice.ts | 114 +++++++++++++++---
.../src/features/controlLayers/store/types.ts | 17 ++-
.../src/features/controlLayers/store/util.ts | 6 +
.../controlLayers/store/validators.ts | 61 +++++-----
.../FluxReduxModelFieldInputComponent.tsx | 4 +-
.../util/graph/generation/addFLUXRedux.ts | 55 +++++++++
.../util/graph/generation/addIPAdapters.ts | 15 ++-
.../nodes/util/graph/generation/addRegions.ts | 69 +++++++----
.../util/graph/generation/buildFLUXGraph.ts | 23 ++++
.../util/graph/generation/buildSD1Graph.ts | 1 +
.../util/graph/generation/buildSDXLGraph.ts | 1 +
.../src/services/api/hooks/modelsByType.ts | 12 +-
.../frontend/web/src/services/api/types.ts | 6 +-
20 files changed, 494 insertions(+), 189 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/CLIPVisionModel.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
index 9b1af61854..fa142e85bc 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
@@ -31,6 +31,7 @@ import type { AnyModelConfig } from 'services/api/types';
import {
isCLIPEmbedModelConfig,
isControlLayerModelConfig,
+ isFluxReduxModelConfig,
isFluxVAEModelConfig,
isIPAdapterModelConfig,
isLoRAModelConfig,
@@ -77,6 +78,7 @@ export const addModelsLoadedListener = (startAppListening: AppStartListening) =>
handleT5EncoderModels(models, state, dispatch, log);
handleCLIPEmbedModels(models, state, dispatch, log);
handleFLUXVAEModels(models, state, dispatch, log);
+ handleFLUXReduxModels(models, state, dispatch, log);
},
});
};
@@ -209,6 +211,10 @@ const handleControlAdapterModels: ModelHandler = (models, state, dispatch, log)
const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
const ipaModels = models.filter(isIPAdapterModelConfig);
selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
+ if (entity.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
+
const selectedIPAdapterModel = entity.ipAdapter.model;
// `null` is a valid IP adapter model - no need to do anything.
if (!selectedIPAdapterModel) {
@@ -224,6 +230,10 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => {
entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => {
+ if (ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
+
const selectedIPAdapterModel = ipAdapter.model;
// `null` is a valid IP adapter model - no need to do anything.
if (!selectedIPAdapterModel) {
@@ -241,6 +251,49 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => {
});
};
+const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => {
+ const fluxReduxModels = models.filter(isFluxReduxModelConfig);
+
+ selectCanvasSlice(state).referenceImages.entities.forEach((entity) => {
+ if (entity.ipAdapter.type !== 'flux_redux') {
+ return;
+ }
+ const selectedFLUXReduxModel = entity.ipAdapter.model;
+ // `null` is a valid FLUX Redux model - no need to do anything.
+ if (!selectedFLUXReduxModel) {
+ return;
+ }
+ const isModelAvailable = fluxReduxModels.some((m) => m.key === selectedFLUXReduxModel.key);
+ if (isModelAvailable) {
+ return;
+ }
+ log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing');
+ dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null }));
+ });
+
+ selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => {
+ entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => {
+ if (ipAdapter.type !== 'flux_redux') {
+ return;
+ }
+
+ const selectedFLUXReduxModel = ipAdapter.model;
+ // `null` is a valid FLUX Redux model - no need to do anything.
+ if (!selectedFLUXReduxModel) {
+ return;
+ }
+ const isModelAvailable = fluxReduxModels.some((m) => m.key === selectedFLUXReduxModel.key);
+ if (isModelAvailable) {
+ return;
+ }
+ log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing');
+ dispatch(
+ rgIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null })
+ );
+ });
+ });
+};
+
const handlePostProcessingModel: ModelHandler = (models, state, dispatch, log) => {
const selectedPostProcessingModel = state.upscale.postProcessingModel;
const allSpandrelModels = models.filter(isSpandrelImageToImageModelConfig);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx
index c4462c0f4d..aadc643cf5 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx
@@ -9,7 +9,7 @@ import {
useAddRegionalGuidance,
useAddRegionalReferenceImage,
} from 'features/controlLayers/hooks/addLayerHooks';
-import { selectIsFLUX, selectIsSD3 } from 'features/controlLayers/store/paramsSlice';
+import { selectIsSD3 } from 'features/controlLayers/store/paramsSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
@@ -22,7 +22,6 @@ export const CanvasAddEntityButtons = memo(() => {
const addControlLayer = useAddControlLayer();
const addGlobalReferenceImage = useAddGlobalReferenceImage();
const addRegionalReferenceImage = useAddRegionalReferenceImage();
- const isFLUX = useAppSelector(selectIsFLUX);
const isSD3 = useAppSelector(selectIsSD3);
return (
@@ -75,7 +74,7 @@ export const CanvasAddEntityButtons = memo(() => {
justifyContent="flex-start"
leftIcon={}
onClick={addRegionalReferenceImage}
- isDisabled={isFLUX || isSD3}
+ isDisabled={isSD3}
>
{t('controlLayers.regionalReferenceImage')}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx
index 40c750bc52..58100f2fc7 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx
@@ -9,7 +9,7 @@ import {
useAddRegionalReferenceImage,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
-import { selectIsFLUX, selectIsSD3 } from 'features/controlLayers/store/paramsSlice';
+import { selectIsSD3 } from 'features/controlLayers/store/paramsSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
@@ -23,7 +23,6 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => {
const addRegionalReferenceImage = useAddRegionalReferenceImage();
const addRasterLayer = useAddRasterLayer();
const addControlLayer = useAddControlLayer();
- const isFLUX = useAppSelector(selectIsFLUX);
const isSD3 = useAppSelector(selectIsSD3);
return (
@@ -52,7 +51,7 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => {
} onClick={addRegionalGuidance} isDisabled={isSD3}>
{t('controlLayers.regionalGuidance')}
- } onClick={addRegionalReferenceImage} isDisabled={isFLUX || isSD3}>
+ } onClick={addRegionalReferenceImage} isDisabled={isSD3}>
{t('controlLayers.regionalReferenceImage')}
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/CLIPVisionModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/CLIPVisionModel.tsx
new file mode 100644
index 0000000000..6023fd579f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/CLIPVisionModel.tsx
@@ -0,0 +1,61 @@
+import type { ComboboxOnChange } from '@invoke-ai/ui-library';
+import { Combobox, FormControl } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
+import type { CLIPVisionModelV2 } from 'features/controlLayers/store/types';
+import { isCLIPVisionModelV2 } from 'features/controlLayers/store/types';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { assert } from 'tsafe';
+
+// at this time, ViT-L is the only supported clip model for FLUX IP adapter
+const FLUX_CLIP_VISION = 'ViT-L';
+
+const CLIP_VISION_OPTIONS = [
+ { label: 'ViT-H', value: 'ViT-H' },
+ { label: 'ViT-G', value: 'ViT-G' },
+ { label: FLUX_CLIP_VISION, value: FLUX_CLIP_VISION },
+];
+
+type Props = {
+ model: CLIPVisionModelV2;
+ onChange: (clipVisionModel: CLIPVisionModelV2) => void;
+};
+
+export const CLIPVisionModel = memo(({ model, onChange }: Props) => {
+ const { t } = useTranslation();
+
+ const _onChangeCLIPVisionModel = useCallback(
+ (v) => {
+ assert(isCLIPVisionModelV2(v?.value));
+ onChange(v.value);
+ },
+ [onChange]
+ );
+
+ const isFLUX = useAppSelector(selectIsFLUX);
+
+ const clipVisionOptions = useMemo(() => {
+ return CLIP_VISION_OPTIONS.map((option) => ({
+ ...option,
+ isDisabled: isFLUX && option.value !== FLUX_CLIP_VISION,
+ }));
+ }, [isFLUX]);
+
+ const clipVisionModelValue = useMemo(() => {
+ return CLIP_VISION_OPTIONS.find((o) => o.value === model);
+ }, [model]);
+
+ return (
+
+
+
+ );
+});
+
+CLIPVisionModel.displayName = 'CLIPVisionModel';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx
index 682c272f89..4e4d82f84d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx
@@ -1,40 +1,36 @@
-import type { ComboboxOnChange } from '@invoke-ai/ui-library';
-import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library';
+import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
-import { selectBase, selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
-import type { CLIPVisionModelV2 } from 'features/controlLayers/store/types';
-import { isCLIPVisionModelV2 } from 'features/controlLayers/store/types';
+import { selectBase } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { useIPAdapterModels } from 'services/api/hooks/modelsByType';
-import type { AnyModelConfig, IPAdapterModelConfig } from 'services/api/types';
-import { assert } from 'tsafe';
-
-// at this time, ViT-L is the only supported clip model for FLUX IP adapter
-const FLUX_CLIP_VISION = 'ViT-L';
-
-const CLIP_VISION_OPTIONS = [
- { label: 'ViT-H', value: 'ViT-H' },
- { label: 'ViT-G', value: 'ViT-G' },
- { label: FLUX_CLIP_VISION, value: FLUX_CLIP_VISION },
-];
+import { useIPAdapterOrFLUXReduxModels } from 'services/api/hooks/modelsByType';
+import type { AnyModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types';
type Props = {
+ isRegionalGuidance: boolean;
modelKey: string | null;
- onChangeModel: (modelConfig: IPAdapterModelConfig) => void;
- clipVisionModel: CLIPVisionModelV2;
- onChangeCLIPVisionModel: (clipVisionModel: CLIPVisionModelV2) => void;
+ onChangeModel: (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => void;
};
-export const IPAdapterModel = memo(({ modelKey, onChangeModel, clipVisionModel, onChangeCLIPVisionModel }: Props) => {
+export const IPAdapterModel = memo(({ isRegionalGuidance, modelKey, onChangeModel }: Props) => {
const { t } = useTranslation();
const currentBaseModel = useAppSelector(selectBase);
- const [modelConfigs, { isLoading }] = useIPAdapterModels();
+ const filter = useCallback(
+ (config: IPAdapterModelConfig | FLUXReduxModelConfig) => {
+ // FLUX supports regional guidance for FLUX Redux models only - not IP Adapter models.
+ if (isRegionalGuidance && config.base === 'flux' && config.type === 'ip_adapter') {
+ return false;
+ }
+ return true;
+ },
+ [isRegionalGuidance]
+ );
+ const [modelConfigs, { isLoading }] = useIPAdapterOrFLUXReduxModels(filter);
const selectedModel = useMemo(() => modelConfigs.find((m) => m.key === modelKey), [modelConfigs, modelKey]);
const _onChangeModel = useCallback(
- (modelConfig: IPAdapterModelConfig | null) => {
+ (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | null) => {
if (!modelConfig) {
return;
}
@@ -43,21 +39,11 @@ export const IPAdapterModel = memo(({ modelKey, onChangeModel, clipVisionModel,
[onChangeModel]
);
- const _onChangeCLIPVisionModel = useCallback(
- (v) => {
- assert(isCLIPVisionModelV2(v?.value));
- onChangeCLIPVisionModel(v.value);
- },
- [onChangeCLIPVisionModel]
- );
-
- const isFLUX = useAppSelector(selectIsFLUX);
-
const getIsDisabled = useCallback(
(model: AnyModelConfig): boolean => {
- const isCompatible = currentBaseModel === model.base;
const hasMainModel = Boolean(currentBaseModel);
- return !hasMainModel || !isCompatible;
+ const hasSameBase = currentBaseModel === model.base;
+ return !hasMainModel || !hasSameBase;
},
[currentBaseModel]
);
@@ -70,41 +56,18 @@ export const IPAdapterModel = memo(({ modelKey, onChangeModel, clipVisionModel,
isLoading,
});
- const clipVisionOptions = useMemo(() => {
- return CLIP_VISION_OPTIONS.map((option) => ({
- ...option,
- isDisabled: isFLUX && option.value !== FLUX_CLIP_VISION,
- }));
- }, [isFLUX]);
-
- const clipVisionModelValue = useMemo(() => {
- return CLIP_VISION_OPTIONS.find((o) => o.value === clipVisionModel);
- }, [clipVisionModel]);
-
return (
-
-
-
-
-
-
- {selectedModel?.format === 'checkpoint' && (
-
-
-
- )}
-
+
+
+
+
+
);
});
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx
index 0d92c1cf70..5a4ee8bd40 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx
@@ -1,9 +1,10 @@
-import { Box, Flex, IconButton } from '@invoke-ai/ui-library';
+import { Flex, IconButton } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper';
import { Weight } from 'features/controlLayers/components/common/Weight';
+import { CLIPVisionModel } from 'features/controlLayers/components/IPAdapter/CLIPVisionModel';
import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod';
import { IPAdapterSettingsEmptyState } from 'features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@@ -25,7 +26,7 @@ import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi';
-import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types';
+import type { FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { IPAdapterImagePreview } from './IPAdapterImagePreview';
import { IPAdapterModel } from './IPAdapterModel';
@@ -65,7 +66,7 @@ const IPAdapterSettingsContent = memo(() => {
);
const onChangeModel = useCallback(
- (modelConfig: IPAdapterModelConfig) => {
+ (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => {
dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier, modelConfig }));
},
[dispatch, entityIdentifier]
@@ -98,14 +99,14 @@ const IPAdapterSettingsContent = memo(() => {
-
-
-
+
+ {ipAdapter.type === 'ip_adapter' && (
+
+ )}
{
/>
-
- {!isFLUX && }
-
-
-
-
+ {ipAdapter.type === 'ip_adapter' && (
+
+ {!isFLUX && }
+
+
+
+ )}
+
{
+ (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => {
dispatch(rgIPAdapterModelChanged({ entityIdentifier, referenceImageId, modelConfig }));
},
[dispatch, entityIdentifier, referenceImageId]
@@ -125,14 +126,14 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
-
-
-
+
+ {ipAdapter.type === 'ip_adapter' && (
+
+ )}
-
-
-
-
-
-
+ {ipAdapter.type === 'ip_adapter' && (
+
+
+
+
+
+ )}
+
>
+ action: PayloadAction<
+ EntityIdentifierPayload<{ modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | null }, 'reference_image'>
+ >
) => {
const { entityIdentifier, modelConfig } = action.payload;
const entity = selectEntity(state, entityIdentifier);
@@ -631,12 +638,39 @@ export const canvasSlice = createSlice({
return;
}
entity.ipAdapter.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null;
- // Ensure that the IP Adapter model is compatible with the CLIP Vision model
- if (entity.ipAdapter.model?.base === 'flux') {
- entity.ipAdapter.clipVisionModel = 'ViT-L';
- } else if (entity.ipAdapter.clipVisionModel === 'ViT-L') {
- // Fall back to ViT-H (ViT-G would also work)
- entity.ipAdapter.clipVisionModel = 'ViT-H';
+
+ if (!entity.ipAdapter.model) {
+ return;
+ }
+
+ if (entity.ipAdapter.type === 'ip_adapter' && entity.ipAdapter.model.type === 'flux_redux') {
+ // Switching from ip_adapter to flux_redux
+ entity.ipAdapter = {
+ ...initialFLUXRedux,
+ image: entity.ipAdapter.image,
+ model: entity.ipAdapter.model,
+ };
+ return;
+ }
+
+ if (entity.ipAdapter.type === 'flux_redux' && entity.ipAdapter.model.type === 'ip_adapter') {
+ // Switching from flux_redux to ip_adapter
+ entity.ipAdapter = {
+ ...initialIPAdapter,
+ image: entity.ipAdapter.image,
+ model: entity.ipAdapter.model,
+ };
+ return;
+ }
+
+ if (entity.ipAdapter.type === 'ip_adapter') {
+ // Ensure that the IP Adapter model is compatible with the CLIP Vision model
+ if (entity.ipAdapter.model?.base === 'flux') {
+ entity.ipAdapter.clipVisionModel = 'ViT-L';
+ } else if (entity.ipAdapter.clipVisionModel === 'ViT-L') {
+ // Fall back to ViT-H (ViT-G would also work)
+ entity.ipAdapter.clipVisionModel = 'ViT-H';
+ }
}
},
referenceImageIPAdapterCLIPVisionModelChanged: (
@@ -648,6 +682,9 @@ export const canvasSlice = createSlice({
if (!entity) {
return;
}
+ if (entity.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
entity.ipAdapter.clipVisionModel = clipVisionModel;
},
referenceImageIPAdapterWeightChanged: (
@@ -659,6 +696,9 @@ export const canvasSlice = createSlice({
if (!entity) {
return;
}
+ if (entity.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
entity.ipAdapter.weight = weight;
},
referenceImageIPAdapterBeginEndStepPctChanged: (
@@ -670,6 +710,9 @@ export const canvasSlice = createSlice({
if (!entity) {
return;
}
+ if (entity.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
entity.ipAdapter.beginEndStepPct = beginEndStepPct;
},
//#region Regional Guidance
@@ -843,6 +886,10 @@ export const canvasSlice = createSlice({
if (!referenceImage) {
return;
}
+ if (referenceImage.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
+
referenceImage.ipAdapter.weight = weight;
},
rgIPAdapterBeginEndStepPctChanged: (
@@ -856,6 +903,10 @@ export const canvasSlice = createSlice({
if (!referenceImage) {
return;
}
+ if (referenceImage.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
+
referenceImage.ipAdapter.beginEndStepPct = beginEndStepPct;
},
rgIPAdapterMethodChanged: (
@@ -869,6 +920,10 @@ export const canvasSlice = createSlice({
if (!referenceImage) {
return;
}
+ if (referenceImage.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
+
referenceImage.ipAdapter.method = method;
},
rgIPAdapterModelChanged: (
@@ -877,7 +932,7 @@ export const canvasSlice = createSlice({
EntityIdentifierPayload<
{
referenceImageId: string;
- modelConfig: IPAdapterModelConfig | null;
+ modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | null;
},
'regional_guidance'
>
@@ -889,12 +944,39 @@ export const canvasSlice = createSlice({
return;
}
referenceImage.ipAdapter.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null;
- // Ensure that the IP Adapter model is compatible with the CLIP Vision model
- if (referenceImage.ipAdapter.model?.base === 'flux') {
- referenceImage.ipAdapter.clipVisionModel = 'ViT-L';
- } else if (referenceImage.ipAdapter.clipVisionModel === 'ViT-L') {
- // Fall back to ViT-H (ViT-G would also work)
- referenceImage.ipAdapter.clipVisionModel = 'ViT-H';
+
+ if (!referenceImage.ipAdapter.model) {
+ return;
+ }
+
+ if (referenceImage.ipAdapter.type === 'ip_adapter' && referenceImage.ipAdapter.model.type === 'flux_redux') {
+ // Switching from ip_adapter to flux_redux
+ referenceImage.ipAdapter = {
+ ...initialFLUXRedux,
+ image: referenceImage.ipAdapter.image,
+ model: referenceImage.ipAdapter.model,
+ };
+ return;
+ }
+
+ if (referenceImage.ipAdapter.type === 'flux_redux' && referenceImage.ipAdapter.model.type === 'ip_adapter') {
+ // Switching from flux_redux to ip_adapter
+ referenceImage.ipAdapter = {
+ ...initialIPAdapter,
+ image: referenceImage.ipAdapter.image,
+ model: referenceImage.ipAdapter.model,
+ };
+ return;
+ }
+
+ if (referenceImage.ipAdapter.type === 'ip_adapter') {
+ // Ensure that the IP Adapter model is compatible with the CLIP Vision model
+ if (referenceImage.ipAdapter.model?.base === 'flux') {
+ referenceImage.ipAdapter.clipVisionModel = 'ViT-L';
+ } else if (referenceImage.ipAdapter.clipVisionModel === 'ViT-L') {
+ // Fall back to ViT-H (ViT-G would also work)
+ referenceImage.ipAdapter.clipVisionModel = 'ViT-H';
+ }
}
},
rgIPAdapterCLIPVisionModelChanged: (
@@ -908,6 +990,10 @@ export const canvasSlice = createSlice({
if (!referenceImage) {
return;
}
+ if (referenceImage.ipAdapter.type !== 'ip_adapter') {
+ return;
+ }
+
referenceImage.ipAdapter.clipVisionModel = clipVisionModel;
},
//#region Inpaint mask
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index d7eea9bb17..651f4e6d26 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -233,6 +233,13 @@ const zIPAdapterConfig = z.object({
});
export type IPAdapterConfig = z.infer;
+const zFLUXReduxConfig = z.object({
+ type: z.literal('flux_redux'),
+ image: zImageWithDims.nullable(),
+ model: zServerValidatedModelIdentifierField.nullable(),
+});
+export type FLUXReduxConfig = z.infer;
+
const zCanvasEntityBase = z.object({
id: zId,
name: zName,
@@ -242,10 +249,16 @@ const zCanvasEntityBase = z.object({
const zCanvasReferenceImageState = zCanvasEntityBase.extend({
type: z.literal('reference_image'),
- ipAdapter: zIPAdapterConfig,
+ ipAdapter: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig]),
});
export type CanvasReferenceImageState = z.infer;
+export const isIPAdapterConfig = (config: IPAdapterConfig | FLUXReduxConfig): config is IPAdapterConfig =>
+ config.type === 'ip_adapter';
+
+export const isFLUXReduxConfig = (config: IPAdapterConfig | FLUXReduxConfig): config is FLUXReduxConfig =>
+ config.type === 'flux_redux';
+
const zFillStyle = z.enum(['solid', 'grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical']);
export type FillStyle = z.infer;
export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success;
@@ -253,7 +266,7 @@ const zFill = z.object({ style: zFillStyle, color: zRgbColor });
const zRegionalGuidanceReferenceImageState = z.object({
id: zId,
- ipAdapter: zIPAdapterConfig,
+ ipAdapter: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig]),
});
export type RegionalGuidanceReferenceImageState = z.infer;
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
index d12fe837b5..37419dc217 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
@@ -9,6 +9,7 @@ import type {
CanvasRegionalGuidanceState,
ControlLoRAConfig,
ControlNetConfig,
+ FLUXReduxConfig,
ImageWithDims,
IPAdapterConfig,
RgbColor,
@@ -70,6 +71,11 @@ export const initialIPAdapter: IPAdapterConfig = {
clipVisionModel: 'ViT-H',
weight: 1,
};
+export const initialFLUXRedux: FLUXReduxConfig = {
+ type: 'flux_redux',
+ image: null,
+ model: null,
+};
export const initialT2IAdapter: T2IAdapterConfig = {
type: 't2i_adapter',
model: null,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts
index 8db533c16f..6ec86e000c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts
@@ -44,33 +44,33 @@ export const getRegionalGuidanceWarnings = (
if (model.base === 'sd-3' || model.base === 'sd-2') {
// Unsupported model architecture
warnings.push(WARNINGS.UNSUPPORTED_MODEL);
- } else if (model.base === 'flux') {
+ return warnings;
+ }
+
+ if (model.base === 'flux') {
// Some features are not supported for flux models
if (entity.negativePrompt !== null) {
warnings.push(WARNINGS.RG_NEGATIVE_PROMPT_NOT_SUPPORTED);
}
- if (entity.referenceImages.length > 0) {
- warnings.push(WARNINGS.RG_REFERENCE_IMAGES_NOT_SUPPORTED);
- }
if (entity.autoNegative) {
warnings.push(WARNINGS.RG_AUTO_NEGATIVE_NOT_SUPPORTED);
}
- } else {
- entity.referenceImages.forEach(({ ipAdapter }) => {
- if (!ipAdapter.model) {
- // No model selected
- warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED);
- } else if (ipAdapter.model.base !== model.base) {
- // Supported model architecture but doesn't match
- warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL);
- }
-
- if (!ipAdapter.image) {
- // No image selected
- warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED);
- }
- });
}
+
+ entity.referenceImages.forEach(({ ipAdapter }) => {
+ if (!ipAdapter.model) {
+ // No model selected
+ warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED);
+ } else if (ipAdapter.model.base !== model.base) {
+ // Supported model architecture but doesn't match
+ warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL);
+ }
+
+ if (!ipAdapter.image) {
+ // No image selected
+ warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED);
+ }
+ });
}
return warnings;
@@ -82,22 +82,27 @@ export const getGlobalReferenceImageWarnings = (
): WarningTKey[] => {
const warnings: WarningTKey[] = [];
- if (!entity.ipAdapter.model) {
- // No model selected
- warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED);
- } else if (model) {
+ if (model) {
if (model.base === 'sd-3' || model.base === 'sd-2') {
// Unsupported model architecture
warnings.push(WARNINGS.UNSUPPORTED_MODEL);
- } else if (entity.ipAdapter.model.base !== model.base) {
+ return warnings;
+ }
+
+ const { ipAdapter } = entity;
+
+ if (!ipAdapter.model) {
+ // No model selected
+ warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED);
+ } else if (ipAdapter.model.base !== model.base) {
// Supported model architecture but doesn't match
warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL);
}
- }
- if (!entity.ipAdapter.image) {
- // No image selected
- warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED);
+ if (!entity.ipAdapter.image) {
+ // No image selected
+ warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED);
+ }
}
return warnings;
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FluxReduxModelFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FluxReduxModelFieldInputComponent.tsx
index cffb704594..9e6cdfad94 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FluxReduxModelFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/FluxReduxModelFieldInputComponent.tsx
@@ -6,7 +6,7 @@ import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { FluxReduxModelFieldInputInstance, FluxReduxModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
import { useFluxReduxModels } from 'services/api/hooks/modelsByType';
-import type { FluxReduxModelConfig } from 'services/api/types';
+import type { FLUXReduxModelConfig } from 'services/api/types';
import type { FieldComponentProps } from './types';
@@ -19,7 +19,7 @@ const FluxReduxModelFieldInputComponent = (
const [modelConfigs, { isLoading }] = useFluxReduxModels();
const _onChange = useCallback(
- (value: FluxReduxModelConfig | null) => {
+ (value: FLUXReduxModelConfig | null) => {
if (!value) {
return;
}
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts
new file mode 100644
index 0000000000..2c53b3bd9f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts
@@ -0,0 +1,55 @@
+import type { CanvasReferenceImageState, FLUXReduxConfig } from 'features/controlLayers/store/types';
+import { isFLUXReduxConfig } from 'features/controlLayers/store/types';
+import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
+import type { Graph } from 'features/nodes/util/graph/generation/Graph';
+import type { ParameterModel } from 'features/parameters/types/parameterSchemas';
+import type { Invocation } from 'services/api/types';
+import { assert } from 'tsafe';
+
+type AddFLUXReduxResult = {
+ addedFLUXReduxes: number;
+};
+
+type AddFLUXReduxArg = {
+ entities: CanvasReferenceImageState[];
+ g: Graph;
+ collector: Invocation<'collect'>;
+ model: ParameterModel;
+};
+
+export const addFLUXReduxes = ({ entities, g, collector, model }: AddFLUXReduxArg): AddFLUXReduxResult => {
+ const validFLUXReduxes = entities
+ .filter((entity) => entity.isEnabled)
+ .filter((entity) => isFLUXReduxConfig(entity.ipAdapter))
+ .filter((entity) => getGlobalReferenceImageWarnings(entity, model).length === 0);
+
+ const result: AddFLUXReduxResult = {
+ addedFLUXReduxes: 0,
+ };
+
+ for (const { id, ipAdapter } of validFLUXReduxes) {
+ assert(isFLUXReduxConfig(ipAdapter), 'This should have been filtered out');
+ result.addedFLUXReduxes++;
+
+ addFLUXRedux(id, ipAdapter, g, collector);
+ }
+
+ return result;
+};
+
+const addFLUXRedux = (id: string, ipAdapter: FLUXReduxConfig, g: Graph, collector: Invocation<'collect'>) => {
+ const { model: fluxReduxModel, image } = ipAdapter;
+ assert(image, 'FLUX Redux image is required');
+ assert(fluxReduxModel, 'FLUX Redux model is required');
+
+ const node = g.addNode({
+ id: `flux_redux_${id}`,
+ type: 'flux_redux',
+ redux_model: fluxReduxModel,
+ image: {
+ image_name: image.image_name,
+ },
+ });
+
+ g.addEdge(node, 'redux_cond', collector, 'item');
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts
index 3d99e913b7..5e2d94996c 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts
@@ -1,4 +1,8 @@
-import type { CanvasReferenceImageState } from 'features/controlLayers/store/types';
+import {
+ type CanvasReferenceImageState,
+ type IPAdapterConfig,
+ isIPAdapterConfig,
+} from 'features/controlLayers/store/types';
import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
import type { ParameterModel } from 'features/parameters/types/parameterSchemas';
@@ -19,23 +23,24 @@ type AddIPAdaptersArg = {
export const addIPAdapters = ({ entities, g, collector, model }: AddIPAdaptersArg): AddIPAdaptersResult => {
const validIPAdapters = entities
.filter((entity) => entity.isEnabled)
+ .filter((entity) => isIPAdapterConfig(entity.ipAdapter))
.filter((entity) => getGlobalReferenceImageWarnings(entity, model).length === 0);
const result: AddIPAdaptersResult = {
addedIPAdapters: 0,
};
- for (const ipa of validIPAdapters) {
+ for (const { id, ipAdapter } of validIPAdapters) {
+ assert(isIPAdapterConfig(ipAdapter), 'This should have been filtered out');
result.addedIPAdapters++;
- addIPAdapter(ipa, g, collector);
+ addIPAdapter(id, ipAdapter, g, collector);
}
return result;
};
-const addIPAdapter = (entity: CanvasReferenceImageState, g: Graph, collector: Invocation<'collect'>) => {
- const { id, ipAdapter } = entity;
+const addIPAdapter = (id: string, ipAdapter: IPAdapterConfig, g: Graph, collector: Invocation<'collect'>) => {
const { weight, model, clipVisionModel, method, beginEndStepPct, image } = ipAdapter;
assert(image, 'IP Adapter image is required');
assert(model, 'IP Adapter model is required');
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts
index 2128faab0e..f804b78ab4 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts
@@ -18,6 +18,7 @@ type AddedRegionResult = {
addedNegativePrompt: boolean;
addedAutoNegativePositivePrompt: boolean;
addedIPAdapters: number;
+ addedFLUXReduxes: number;
};
type AddRegionsArg = {
@@ -31,6 +32,7 @@ type AddRegionsArg = {
posCondCollect: Invocation<'collect'>;
negCondCollect: Invocation<'collect'> | null;
ipAdapterCollect: Invocation<'collect'>;
+ fluxReduxCollect: Invocation<'collect'> | null;
};
/**
@@ -45,6 +47,7 @@ type AddRegionsArg = {
* @param posCondCollect The positive conditioning collector
* @param negCondCollect The negative conditioning collector
* @param ipAdapterCollect The IP adapter collector
+ * @param fluxReduxConnect The IP adapter collector
* @returns A promise that resolves to the regions that were successfully added to the graph
*/
@@ -59,6 +62,7 @@ export const addRegions = async ({
posCondCollect,
negCondCollect,
ipAdapterCollect,
+ fluxReduxCollect,
}: AddRegionsArg): Promise => {
const isSDXL = model.base === 'sdxl';
const isFLUX = model.base === 'flux';
@@ -75,6 +79,7 @@ export const addRegions = async ({
addedNegativePrompt: false,
addedAutoNegativePositivePrompt: false,
addedIPAdapters: 0,
+ addedFLUXReduxes: 0,
};
const getImageDTOResult = await withResultAsync(() => {
@@ -269,30 +274,52 @@ export const addRegions = async ({
}
for (const { id, ipAdapter } of region.referenceImages) {
- assert(!isFLUX, 'Regional IP adapters are not supported for FLUX.');
+ if (ipAdapter.type === 'ip_adapter') {
+ assert(!isFLUX, 'Regional IP adapters are not supported for FLUX.');
- result.addedIPAdapters++;
- const { weight, model, clipVisionModel, method, beginEndStepPct, image } = ipAdapter;
- assert(model, 'IP Adapter model is required');
- assert(image, 'IP Adapter image is required');
+ result.addedIPAdapters++;
+ const { weight, model, clipVisionModel, method, beginEndStepPct, image } = ipAdapter;
+ assert(model, 'IP Adapter model is required');
+ assert(image, 'IP Adapter image is required');
- const ipAdapterNode = g.addNode({
- id: `ip_adapter_${id}`,
- type: 'ip_adapter',
- weight,
- method,
- ip_adapter_model: model,
- clip_vision_model: clipVisionModel,
- begin_step_percent: beginEndStepPct[0],
- end_step_percent: beginEndStepPct[1],
- image: {
- image_name: image.image_name,
- },
- });
+ const ipAdapterNode = g.addNode({
+ id: `ip_adapter_${id}`,
+ type: 'ip_adapter',
+ weight,
+ method,
+ ip_adapter_model: model,
+ clip_vision_model: clipVisionModel,
+ begin_step_percent: beginEndStepPct[0],
+ end_step_percent: beginEndStepPct[1],
+ image: {
+ image_name: image.image_name,
+ },
+ });
- // Connect the mask to the conditioning
- g.addEdge(maskToTensor, 'mask', ipAdapterNode, 'mask');
- g.addEdge(ipAdapterNode, 'ip_adapter', ipAdapterCollect, 'item');
+ // Connect the mask to the conditioning
+ g.addEdge(maskToTensor, 'mask', ipAdapterNode, 'mask');
+ g.addEdge(ipAdapterNode, 'ip_adapter', ipAdapterCollect, 'item');
+ } else if (ipAdapter.type === 'flux_redux') {
+ assert(isFLUX, 'Regional FLUX Redux requires FLUX.');
+ assert(fluxReduxCollect !== null, 'FLUX Redux collector is required.');
+ result.addedFLUXReduxes++;
+ const { model: fluxReduxModel, image } = ipAdapter;
+ assert(fluxReduxModel, 'FLUX Redux model is required');
+ assert(image, 'FLUX Redux image is required');
+
+ const fluxReduxNode = g.addNode({
+ id: `flux_redux_${id}`,
+ type: 'flux_redux',
+ redux_model: fluxReduxModel,
+ image: {
+ image_name: image.image_name,
+ },
+ });
+
+ // Connect the mask to the conditioning
+ g.addEdge(maskToTensor, 'mask', fluxReduxNode, 'mask');
+ g.addEdge(fluxReduxNode, 'redux_cond', fluxReduxCollect, 'item');
+ }
}
results.push(result);
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts
index d6f0b5add0..4d6db95ad0 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts
@@ -7,6 +7,7 @@ import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors';
import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
import { addFLUXLoRAs } from 'features/nodes/util/graph/generation/addFLUXLoRAs';
+import { addFLUXReduxes } from 'features/nodes/util/graph/generation/addFLUXRedux';
import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage';
import { addInpaint } from 'features/nodes/util/graph/generation/addInpaint';
import { addNSFWChecker } from 'features/nodes/util/graph/generation/addNSFWChecker';
@@ -233,6 +234,17 @@ export const buildFLUXGraph = async (
model: modelConfig,
});
+ const fluxReduxCollect = g.addNode({
+ type: 'collect',
+ id: getPrefixedId('ip_adapter_collector'),
+ });
+ const fluxReduxResult = addFLUXReduxes({
+ entities: canvas.referenceImages.entities,
+ g,
+ collector: fluxReduxCollect,
+ model: modelConfig,
+ });
+
const regionsResult = await addRegions({
manager,
regions: canvas.regionalGuidance.entities,
@@ -244,6 +256,7 @@ export const buildFLUXGraph = async (
posCondCollect,
negCondCollect: null,
ipAdapterCollect,
+ fluxReduxCollect,
});
const totalIPAdaptersAdded =
@@ -254,6 +267,16 @@ export const buildFLUXGraph = async (
g.deleteNode(ipAdapterCollect.id);
}
+ const totalReduxesAdded =
+ fluxReduxResult.addedFLUXReduxes + regionsResult.reduce((acc, r) => acc + r.addedFLUXReduxes, 0);
+ if (totalReduxesAdded > 0) {
+ g.addEdge(fluxReduxCollect, 'collection', denoise, 'redux_conditioning');
+ } else {
+ g.deleteNode(fluxReduxCollect.id);
+ }
+
+ // TODO: Add FLUX Reduxes to denoise node like we do for ipa
+
if (state.system.shouldUseNSFWChecker) {
canvasOutput = addNSFWChecker(g, canvasOutput);
}
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts
index 7522227007..7cb3119162 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts
@@ -281,6 +281,7 @@ export const buildSD1Graph = async (
posCondCollect,
negCondCollect,
ipAdapterCollect,
+ fluxReduxCollect: null,
});
const totalIPAdaptersAdded =
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts
index 9357a291b4..02fb7e7035 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts
@@ -286,6 +286,7 @@ export const buildSDXLGraph = async (
posCondCollect,
negCondCollect,
ipAdapterCollect,
+ fluxReduxCollect: null,
});
const totalIPAdaptersAdded =
diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
index cdf10c2f52..7fa627f808 100644
--- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
+++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
@@ -19,7 +19,6 @@ import {
isFluxVAEModelConfig,
isIPAdapterModelConfig,
isLoRAModelConfig,
- isNonRefinerMainModelConfig,
isNonSDXLMainModelConfig,
isRefinerMainModelModelConfig,
isSD3MainModelModelConfig,
@@ -39,7 +38,7 @@ const buildModelsHook =
typeGuard: (config: AnyModelConfig, excludeSubmodels?: boolean) => config is T,
excludeSubmodels?: boolean
) =>
- () => {
+ (filter: (config: T) => boolean = () => true) => {
const result = useGetModelConfigsQuery(undefined);
const modelConfigs = useMemo(() => {
if (!result.data) {
@@ -48,13 +47,13 @@ const buildModelsHook =
return modelConfigsAdapterSelectors
.selectAll(result.data)
- .filter((config) => typeGuard(config, excludeSubmodels));
- }, [result]);
+ .filter((config) => typeGuard(config, excludeSubmodels))
+ .filter(filter);
+ }, [filter, result.data]);
return [modelConfigs, result] as const;
};
-export const useMainModels = buildModelsHook(isNonRefinerMainModelConfig);
export const useNonSDXLMainModels = buildModelsHook(isNonSDXLMainModelConfig);
export const useRefinerModels = buildModelsHook(isRefinerMainModelModelConfig);
export const useFluxModels = buildModelsHook(isFluxMainModelModelConfig);
@@ -78,6 +77,9 @@ export const useFluxVAEModels = (args?: ModelHookArgs) =>
export const useCLIPVisionModels = buildModelsHook(isCLIPVisionModelConfig);
export const useSigLipModels = buildModelsHook(isSigLipModelConfig);
export const useFluxReduxModels = buildModelsHook(isFluxReduxModelConfig);
+export const useIPAdapterOrFLUXReduxModels = buildModelsHook(
+ (config) => isIPAdapterModelConfig(config) || isFluxReduxModelConfig(config)
+);
// const buildModelsSelector =
// (typeGuard: (config: AnyModelConfig) => config is T): Selector =>
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index ac33115ba0..701df5255b 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -63,7 +63,7 @@ type DiffusersModelConfig = S['MainDiffusersConfig'];
export type CheckpointModelConfig = S['MainCheckpointConfig'];
type CLIPVisionDiffusersConfig = S['CLIPVisionDiffusersConfig'];
export type SigLipModelConfig = S['SigLIPConfig'];
-export type FluxReduxModelConfig = S['FluxReduxConfig'];
+export type FLUXReduxModelConfig = S['FluxReduxConfig'];
export type MainModelConfig = DiffusersModelConfig | CheckpointModelConfig;
export type AnyModelConfig =
| ControlLoRAModelConfig
@@ -80,7 +80,7 @@ export type AnyModelConfig =
| MainModelConfig
| CLIPVisionDiffusersConfig
| SigLipModelConfig
- | FluxReduxModelConfig;
+ | FLUXReduxModelConfig;
/**
* Checks if a list of submodels contains any that match a given variant or type
@@ -217,7 +217,7 @@ export const isSigLipModelConfig = (config: AnyModelConfig): config is SigLipMod
return config.type === 'siglip';
};
-export const isFluxReduxModelConfig = (config: AnyModelConfig): config is FluxReduxModelConfig => {
+export const isFluxReduxModelConfig = (config: AnyModelConfig): config is FLUXReduxModelConfig => {
return config.type === 'flux_redux';
};
From 731992c5ec019ed5549f5621676d542fab82a38d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 7 Mar 2025 16:58:33 +1000
Subject: [PATCH 05/67] fix(ui): restore accidentally deleted line
---
invokeai/frontend/web/src/services/api/hooks/modelsByType.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
index 7fa627f808..ee8d5c89d1 100644
--- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
+++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
@@ -19,6 +19,7 @@ import {
isFluxVAEModelConfig,
isIPAdapterModelConfig,
isLoRAModelConfig,
+ isNonRefinerMainModelConfig,
isNonSDXLMainModelConfig,
isRefinerMainModelModelConfig,
isSD3MainModelModelConfig,
@@ -54,6 +55,7 @@ const buildModelsHook =
return [modelConfigs, result] as const;
};
+export const useMainModels = buildModelsHook(isNonRefinerMainModelConfig);
export const useNonSDXLMainModels = buildModelsHook(isNonSDXLMainModelConfig);
export const useRefinerModels = buildModelsHook(isRefinerMainModelModelConfig);
export const useFluxModels = buildModelsHook(isFluxMainModelModelConfig);
From c77c12aa1d769d749a398c37f7b9758c1983d48b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 7 Mar 2025 17:06:11 +1000
Subject: [PATCH 06/67] fix(ui): missing builder translations
---
invokeai/frontend/web/public/locales/en.json | 2 ++
.../sidePanel/builder/FormElementEditModeHeader.tsx | 11 ++++++-----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index b71454ed2a..a4cad83fb6 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1741,6 +1741,8 @@
"row": "Row",
"column": "Column",
"container": "Container",
+ "containerRowLayout": "Container (row layout)",
+ "containerColumnLayout": "Container (column layout)",
"heading": "Heading",
"text": "Text",
"divider": "Divider",
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx
index 7113b8119e..890c904dd2 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx
@@ -9,7 +9,7 @@ import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
import type { FormElement, NodeFieldElement } from 'features/nodes/types/workflow';
import { isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
-import { startCase } from 'lodash-es';
+import { camelCase } from 'lodash-es';
import type { RefObject } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -103,15 +103,16 @@ const RemoveElementButton = memo(({ element }: { element: FormElement }) => {
RemoveElementButton.displayName = 'RemoveElementButton';
const Label = memo(({ element }: { element: FormElement }) => {
+ const { t } = useTranslation();
const label = useMemo(() => {
if (isContainerElement(element) && element.data.layout === 'column') {
- return `Container (column layout)`;
+ return t('workflows.builder.containerColumnLayout');
}
if (isContainerElement(element) && element.data.layout === 'row') {
- return `Container (row layout)`;
+ return t('workflows.builder.containerRowLayout');
}
- return startCase(element.type);
- }, [element]);
+ return t(`workflows.builder.${camelCase(element.type)}`);
+ }, [element, t]);
return (
From 80b3f44ae8debe4c93bed05a7247176567a4fd1a Mon Sep 17 00:00:00 2001
From: Hosted Weblate
Date: Fri, 7 Mar 2025 23:42:28 +0100
Subject: [PATCH 07/67] translationBot(ui): update translation files
Updated by "Cleanup translation files" hook in Weblate.
Co-authored-by: Hosted Weblate
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/
Translation: InvokeAI/Web UI
---
invokeai/frontend/web/public/locales/fr.json | 1 -
invokeai/frontend/web/public/locales/it.json | 1 -
invokeai/frontend/web/public/locales/ru.json | 1 -
invokeai/frontend/web/public/locales/vi.json | 1 -
invokeai/frontend/web/public/locales/zh_CN.json | 1 -
5 files changed, 5 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/fr.json b/invokeai/frontend/web/public/locales/fr.json
index 0097ac8ed0..b644f2bed3 100644
--- a/invokeai/frontend/web/public/locales/fr.json
+++ b/invokeai/frontend/web/public/locales/fr.json
@@ -1771,7 +1771,6 @@
"projectWorkflows": "Workflows du projet",
"copyShareLink": "Copier le lien de partage",
"chooseWorkflowFromLibrary": "Choisir le Workflow dans la Bibliothèque",
- "uploadAndSaveWorkflow": "Importer dans la bibliothèque",
"edit": "Modifer",
"deleteWorkflow2": "Êtes-vous sûr de vouloir supprimer ce Workflow ? Cette action ne peut pas être annulé.",
"download": "Télécharger",
diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json
index f16bc199de..a26c3effe4 100644
--- a/invokeai/frontend/web/public/locales/it.json
+++ b/invokeai/frontend/web/public/locales/it.json
@@ -1734,7 +1734,6 @@
"userWorkflows": "Flussi di lavoro utente",
"projectWorkflows": "Flussi di lavoro del progetto",
"defaultWorkflows": "Flussi di lavoro predefiniti",
- "uploadAndSaveWorkflow": "Carica nella libreria",
"chooseWorkflowFromLibrary": "Scegli il flusso di lavoro dalla libreria",
"deleteWorkflow2": "Vuoi davvero eliminare questo flusso di lavoro? Questa operazione non può essere annullata.",
"edit": "Modifica",
diff --git a/invokeai/frontend/web/public/locales/ru.json b/invokeai/frontend/web/public/locales/ru.json
index 5df8650112..a1c434ad68 100644
--- a/invokeai/frontend/web/public/locales/ru.json
+++ b/invokeai/frontend/web/public/locales/ru.json
@@ -1566,7 +1566,6 @@
"defaultWorkflows": "Стандартные рабочие процессы",
"deleteWorkflow2": "Вы уверены, что хотите удалить этот рабочий процесс? Это нельзя отменить.",
"chooseWorkflowFromLibrary": "Выбрать рабочий процесс из библиотеки",
- "uploadAndSaveWorkflow": "Загрузить в библиотеку",
"edit": "Редактировать",
"download": "Скачать",
"copyShareLink": "Скопировать ссылку на общий доступ",
diff --git a/invokeai/frontend/web/public/locales/vi.json b/invokeai/frontend/web/public/locales/vi.json
index 3cf999eb40..4c80135cde 100644
--- a/invokeai/frontend/web/public/locales/vi.json
+++ b/invokeai/frontend/web/public/locales/vi.json
@@ -2256,7 +2256,6 @@
"opened": "Ngày Mở",
"deleteWorkflow": "Xoá Workflow",
"workflowEditorMenu": "Menu Biên Tập Workflow",
- "uploadAndSaveWorkflow": "Tải Lên Thư Viện",
"openLibrary": "Mở Thư Viện",
"builder": {
"resetAllNodeFields": "Tải Lại Các Vùng Node",
diff --git a/invokeai/frontend/web/public/locales/zh_CN.json b/invokeai/frontend/web/public/locales/zh_CN.json
index d1cb520b7e..51a6cb2e08 100644
--- a/invokeai/frontend/web/public/locales/zh_CN.json
+++ b/invokeai/frontend/web/public/locales/zh_CN.json
@@ -1629,7 +1629,6 @@
"projectWorkflows": "项目工作流程",
"copyShareLink": "复制分享链接",
"chooseWorkflowFromLibrary": "从库中选择工作流程",
- "uploadAndSaveWorkflow": "上传到库",
"deleteWorkflow2": "您确定要删除此工作流程吗?此操作无法撤销。"
},
"accordions": {
From 5d6c4688339694a2b6e545fbe774de72b2381217 Mon Sep 17 00:00:00 2001
From: Riku
Date: Fri, 7 Mar 2025 23:42:30 +0100
Subject: [PATCH 08/67] translationBot(ui): update translation (German)
Currently translated at 67.2% (1221 of 1816 strings)
Co-authored-by: Riku
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/de/
Translation: InvokeAI/Web UI
---
invokeai/frontend/web/public/locales/de.json | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/de.json b/invokeai/frontend/web/public/locales/de.json
index 22733fb098..976abe2d64 100644
--- a/invokeai/frontend/web/public/locales/de.json
+++ b/invokeai/frontend/web/public/locales/de.json
@@ -113,7 +113,8 @@
"end": "Ende",
"layout": "Layout",
"board": "Ordner",
- "combinatorial": "Kombinatorisch"
+ "combinatorial": "Kombinatorisch",
+ "saveChanges": "Änderungen speichern"
},
"gallery": {
"galleryImageSize": "Bildgröße",
@@ -761,7 +762,16 @@
"workflowDeleted": "Arbeitsablauf gelöscht",
"errorCopied": "Fehler kopiert",
"layerCopiedToClipboard": "Ebene in die Zwischenablage kopiert",
- "sentToCanvas": "An Leinwand gesendet"
+ "sentToCanvas": "An Leinwand gesendet",
+ "problemDeletingWorkflow": "Problem beim Löschen des Arbeitsablaufs",
+ "uploadFailedInvalidUploadDesc_withCount_one": "Es darf maximal 1 PNG- oder JPEG-Bild sein.",
+ "uploadFailedInvalidUploadDesc_withCount_other": "Es dürfen maximal {{count}} PNG- oder JPEG-Bilder sein.",
+ "problemRetrievingWorkflow": "Problem beim Abrufen des Arbeitsablaufs",
+ "uploadFailedInvalidUploadDesc": "Müssen PNG- oder JPEG-Bilder sein.",
+ "pasteSuccess": "Eingefügt in {{destination}}",
+ "pasteFailed": "Einfügen fehlgeschlagen",
+ "unableToCopy": "Kopieren nicht möglich",
+ "unableToCopyDesc_theseSteps": "diese Schritte"
},
"accessibility": {
"uploadImage": "Bild hochladen",
@@ -1314,7 +1324,8 @@
"nodeName": "Knotenname",
"description": "Beschreibung",
"loadWorkflowDesc": "Arbeitsablauf laden?",
- "loadWorkflowDesc2": "Ihr aktueller Arbeitsablauf enthält nicht gespeicherte Änderungen."
+ "loadWorkflowDesc2": "Ihr aktueller Arbeitsablauf enthält nicht gespeicherte Änderungen.",
+ "loadingTemplates": "Lade {{name}}"
},
"hrf": {
"enableHrf": "Korrektur für hohe Auflösungen",
From 1cf8749754f30b869f6809d1a341f647f5f60a3c Mon Sep 17 00:00:00 2001
From: Linos
Date: Fri, 7 Mar 2025 23:42:32 +0100
Subject: [PATCH 09/67] translationBot(ui): update translation (Vietnamese)
Currently translated at 100.0% (1816 of 1816 strings)
translationBot(ui): update translation (Vietnamese)
Currently translated at 99.9% (1815 of 1816 strings)
Co-authored-by: Linos
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/vi/
Translation: InvokeAI/Web UI
---
invokeai/frontend/web/public/locales/vi.json | 35 ++++++++++++++------
1 file changed, 25 insertions(+), 10 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/vi.json b/invokeai/frontend/web/public/locales/vi.json
index 4c80135cde..7510767572 100644
--- a/invokeai/frontend/web/public/locales/vi.json
+++ b/invokeai/frontend/web/public/locales/vi.json
@@ -235,7 +235,8 @@
"column": "Cột",
"layout": "Bố Cục",
"row": "Hàng",
- "board": "Bảng"
+ "board": "Bảng",
+ "saveChanges": "Lưu Thay Đổi"
},
"prompt": {
"addPromptTrigger": "Thêm Prompt Trigger",
@@ -766,7 +767,9 @@
"urlUnauthorizedErrorMessage2": "Tìm hiểu thêm.",
"urlForbidden": "Bạn không có quyền truy cập vào model này",
"urlForbiddenErrorMessage": "Bạn có thể cần yêu cầu quyền truy cập từ trang web đang cung cấp model.",
- "urlUnauthorizedErrorMessage": "Bạn có thể cần thiếp lập một token API để dùng được model này."
+ "urlUnauthorizedErrorMessage": "Bạn có thể cần thiếp lập một token API để dùng được model này.",
+ "fluxRedux": "FLUX Redux",
+ "sigLip": "SigLIP"
},
"metadata": {
"guidance": "Hướng Dẫn",
@@ -979,7 +982,7 @@
"unknownInput": "Đầu Vào Không Rõ: {{name}}",
"validateConnections": "Xác Thực Kết Nối Và Đồ Thị",
"workflowNotes": "Ghi Chú",
- "workflowTags": "Thẻ Tên",
+ "workflowTags": "Nhãn",
"editMode": "Chỉnh sửa trong Trình Biên Tập Workflow",
"edit": "Chỉnh Sửa",
"executionStateInProgress": "Đang Xử Lý",
@@ -2021,7 +2024,7 @@
},
"mergingLayers": "Đang gộp layer",
"controlLayerEmptyState": "Tải lên ảnh, kéo thả ảnh từ thư viện vào layer này, hoặc vẽ trên canvas để bắt đầu.",
- "referenceImageEmptyState": "Tải lên ảnh hoặc kéo thả ảnh từ thư viện vào layer này để bắt đầu.",
+ "referenceImageEmptyState": "Tải lên hình ảnh, kéo ảnh từ thư viện ảnh vào layer này, hoặc kéo hộp giới hạn vào layer này để bắt đầu.",
"useImage": "Dùng Hình Ảnh",
"resetCanvasLayers": "Khởi Động Lại Layer Canvas",
"asRasterLayer": "Như $t(controlLayers.rasterLayer)",
@@ -2137,7 +2140,7 @@
"toast": {
"imageUploadFailed": "Tải Lên Ảnh Thất Bại",
"layerCopiedToClipboard": "Sao Chép Layer Vào Clipboard",
- "uploadFailedInvalidUploadDesc_withCount_other": "Tối đa là {{count}} ảnh PNG hoặc JPEG.",
+ "uploadFailedInvalidUploadDesc_withCount_other": "Tối đa là {{count}} ảnh PNG, JPEG hoặc WEBP.",
"imageCopied": "Ảnh Đã Được Sao Chép",
"sentToUpscale": "Chuyển Vào Upscale",
"unableToLoadImage": "Không Thể Tải Hình Ảnh",
@@ -2149,7 +2152,7 @@
"unableToLoadImageMetadata": "Không Thể Tải Metadata Của Ảnh",
"workflowLoaded": "Workflow Đã Tải",
"uploadFailed": "Tải Lên Thất Bại",
- "uploadFailedInvalidUploadDesc": "Phải là ảnh PNG hoặc JPEG.",
+ "uploadFailedInvalidUploadDesc": "Phải là ảnh PNG, JPEG hoặc WEBP.",
"serverError": "Lỗi Server",
"addedToBoard": "Thêm vào tài nguyên của bảng {{name}}",
"sessionRef": "Phiên: {{sessionId}}",
@@ -2252,7 +2255,7 @@
"convertGraph": "Chuyển Đổi Đồ Thị",
"saveWorkflowToProject": "Lưu Workflow Vào Dự Án",
"workflowName": "Tên Workflow",
- "workflowLibrary": "Thư Viện",
+ "workflowLibrary": "Thư Viện Workflow",
"opened": "Ngày Mở",
"deleteWorkflow": "Xoá Workflow",
"workflowEditorMenu": "Menu Biên Tập Workflow",
@@ -2286,7 +2289,19 @@
"heading": "Đầu Dòng",
"text": "Văn Bản",
"divider": "Gạch Chia"
- }
+ },
+ "yourWorkflows": "Workflow Của Bạn",
+ "browseWorkflows": "Khám Phá Workflow",
+ "workflowThumbnail": "Ảnh Minh Họa Workflow",
+ "saveChanges": "Lưu Thay Đổi",
+ "allLoaded": "Đã Tải Tất Cả Workflow",
+ "shared": "Nhóm",
+ "searchPlaceholder": "Tìm theo tên, mô tả, hoặc nhãn",
+ "filterByTags": "Lọc Theo Nhãn",
+ "recentlyOpened": "Mở Gần Đây",
+ "private": "Cá Nhân",
+ "resetTags": "Khởi Động Lại Nhãn",
+ "loadMore": "Tải Thêm"
},
"upscaling": {
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",
@@ -2321,8 +2336,8 @@
"watchRecentReleaseVideos": "Xem Video Phát Hành Mới Nhất",
"watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng",
"items": [
- "Trình Biên Tập Workflow: trình tạo vùng nhập dưới dạng kéo thả nhằm tạo dựng workflow dễ dàng hơn.",
- "Các nâng cấp khác: Xếp hàng tạo sinh theo nhóm nhanh hơn, upscale tốt hơn, trình chọn màu được cải thiện, và node chứa metadata."
+ "Trình Quản Lý Bộ Nhớ: Thiết lập mới cho người dùng với GPU Nvidia để giảm lượng VRAM sử dụng.",
+ "Hiệu suất: Các cải thiện tiếp theo nhằm gói gọn hiệu suất và khả năng phản hồi của ứng dụng."
]
},
"upsell": {
From 76c09301f9c0737cc8a66618261c70803cfeacfb Mon Sep 17 00:00:00 2001
From: Riccardo Giovanetti
Date: Fri, 7 Mar 2025 23:42:34 +0100
Subject: [PATCH 10/67] translationBot(ui): update translation (Italian)
Currently translated at 98.7% (1794 of 1816 strings)
Co-authored-by: Riccardo Giovanetti
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
---
invokeai/frontend/web/public/locales/it.json | 31 ++++++++++++++------
1 file changed, 22 insertions(+), 9 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json
index a26c3effe4..e035c6cbc8 100644
--- a/invokeai/frontend/web/public/locales/it.json
+++ b/invokeai/frontend/web/public/locales/it.json
@@ -109,7 +109,8 @@
"board": "Bacheca",
"layout": "Schema",
"row": "Riga",
- "column": "Colonna"
+ "column": "Colonna",
+ "saveChanges": "Salva modifiche"
},
"gallery": {
"galleryImageSize": "Dimensione dell'immagine",
@@ -784,7 +785,7 @@
"serverError": "Errore del Server",
"connected": "Connesso al server",
"canceled": "Elaborazione annullata",
- "uploadFailedInvalidUploadDesc": "Devono essere immagini PNG o JPEG.",
+ "uploadFailedInvalidUploadDesc": "Devono essere immagini PNG, JPEG o WEBP.",
"parameterSet": "Parametro richiamato",
"parameterNotSet": "Parametro non richiamato",
"problemCopyingImage": "Impossibile copiare l'immagine",
@@ -835,9 +836,9 @@
"linkCopied": "Collegamento copiato",
"addedToUncategorized": "Aggiunto alle risorse della bacheca $t(boards.uncategorized)",
"imagesWillBeAddedTo": "Le immagini caricate verranno aggiunte alle risorse della bacheca {{boardName}}.",
- "uploadFailedInvalidUploadDesc_withCount_one": "Devi caricare al massimo 1 immagine PNG o JPEG.",
- "uploadFailedInvalidUploadDesc_withCount_many": "Devi caricare al massimo {{count}} immagini PNG o JPEG.",
- "uploadFailedInvalidUploadDesc_withCount_other": "Devi caricare al massimo {{count}} immagini PNG o JPEG.",
+ "uploadFailedInvalidUploadDesc_withCount_one": "Devi caricare al massimo 1 immagine PNG, JPEG o WEBP.",
+ "uploadFailedInvalidUploadDesc_withCount_many": "Devi caricare al massimo {{count}} immagini PNG, JPEG o WEBP.",
+ "uploadFailedInvalidUploadDesc_withCount_other": "Devi caricare al massimo {{count}} immagini PNG, JPEG o WEBP.",
"outOfMemoryErrorDescLocal": "Segui la nostra guida per bassa VRAM per ridurre gli OOM.",
"pasteFailed": "Incolla non riuscita",
"pasteSuccess": "Incollato su {{destination}}",
@@ -1704,7 +1705,7 @@
"saveWorkflow": "Salva flusso di lavoro",
"openWorkflow": "Apri flusso di lavoro",
"clearWorkflowSearchFilter": "Cancella il filtro di ricerca del flusso di lavoro",
- "workflowLibrary": "Libreria",
+ "workflowLibrary": "Libreria flussi di lavoro",
"workflowSaved": "Flusso di lavoro salvato",
"unnamedWorkflow": "Flusso di lavoro senza nome",
"savingWorkflow": "Salvataggio del flusso di lavoro...",
@@ -1771,7 +1772,19 @@
"container": "Contenitore",
"text": "Testo",
"numberInput": "Ingresso numerico"
- }
+ },
+ "loadMore": "Carica altro",
+ "searchPlaceholder": "Cerca per nome, descrizione o etichetta",
+ "filterByTags": "Filtra per etichetta",
+ "shared": "Condiviso",
+ "browseWorkflows": "Sfoglia i flussi di lavoro",
+ "resetTags": "Reimposta le etichette",
+ "allLoaded": "Tutti i flussi di lavoro caricati",
+ "saveChanges": "Salva modifiche",
+ "yourWorkflows": "I tuoi flussi di lavoro",
+ "recentlyOpened": "Aperto di recente",
+ "workflowThumbnail": "Miniatura del flusso di lavoro",
+ "private": "Privato"
},
"accordions": {
"compositing": {
@@ -2329,8 +2342,8 @@
"watchRecentReleaseVideos": "Guarda i video su questa versione",
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
"items": [
- "Editor del flusso di lavoro: nuovo generatore di moduli trascina-e-rilascia per una creazione più facile del flusso di lavoro.",
- "Altri miglioramenti: messa in coda dei lotti più rapida, migliore ampliamento, selettore colore migliorato e nodi metadati."
+ "Gestione della memoria: nuova impostazione per gli utenti con GPU Nvidia per ridurre l'utilizzo della VRAM.",
+ "Prestazioni: continui miglioramenti alle prestazioni e alla reattività complessive dell'applicazione."
]
},
"system": {
From 9ec4d968aa52a02665abb18eb199a91520e288d5 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 08:03:15 +1100
Subject: [PATCH 11/67] chore: bump version to v5.8.0a2
---
invokeai/version/invokeai_version.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/version/invokeai_version.py b/invokeai/version/invokeai_version.py
index 6cf6162ab9..aa440cbbae 100644
--- a/invokeai/version/invokeai_version.py
+++ b/invokeai/version/invokeai_version.py
@@ -1 +1 @@
-__version__ = "5.8.0a1"
+__version__ = "5.8.0a2"
From 1756d885f63ad91307f3f8a201783d68d342ec0a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:07:48 +1000
Subject: [PATCH 12/67] refactor(ui): split workflow library state into
separate slice
Has no business being in the workflow state slice.
---
.../web/src/app/components/InvokeAIUI.tsx | 25 ++++-
.../store/nanostores/workflowCategories.ts | 4 -
invokeai/frontend/web/src/app/store/store.ts | 3 +
.../WorkflowLibrarySideNav.tsx | 91 ++++++++---------
.../workflow/WorkflowLibrary/WorkflowList.tsx | 51 +++++-----
.../WorkflowLibrary/WorkflowSearch.tsx | 11 ++-
.../WorkflowLibrary/WorkflowSortControl.tsx | 18 ++--
.../web/src/features/nodes/store/types.ts | 16 +--
.../nodes/store/workflowLibrarySlice.ts | 98 +++++++++++++++++++
.../src/features/nodes/store/workflowSlice.ts | 61 ++++--------
.../components/SaveWorkflowAsDialog.tsx | 4 +-
11 files changed, 234 insertions(+), 148 deletions(-)
delete mode 100644 invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
index 3f66ae4a9e..d08e46ec99 100644
--- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
+++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
@@ -16,10 +16,17 @@ import { $openAPISchemaUrl } from 'app/store/nanostores/openAPISchemaUrl';
import { $projectId, $projectName, $projectUrl } from 'app/store/nanostores/projectId';
import { $queueId, DEFAULT_QUEUE_ID } from 'app/store/nanostores/queueId';
import { $store } from 'app/store/nanostores/store';
-import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { createStore } from 'app/store/store';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
+import type {
+ WorkflowTagCategory} from 'features/nodes/store/workflowLibrarySlice';
+import {
+ $workflowLibraryCategoriesOptions,
+ $workflowLibraryTagCategoriesOptions,
+ DEFAULT_WORKFLOW_LIBRARY_CATEGORIES,
+ DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES
+} from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { PropsWithChildren, ReactNode } from 'react';
import React, { lazy, memo, useEffect, useLayoutEffect, useMemo } from 'react';
@@ -48,6 +55,7 @@ interface Props extends PropsWithChildren {
isDebugging?: boolean;
logo?: ReactNode;
workflowCategories?: WorkflowCategory[];
+ workflowTagCategories?: WorkflowTagCategory[];
loggingOverrides?: LoggingOverrides;
}
@@ -68,6 +76,7 @@ const InvokeAIUI = ({
isDebugging = false,
logo,
workflowCategories,
+ workflowTagCategories,
loggingOverrides,
}: Props) => {
useLayoutEffect(() => {
@@ -195,14 +204,24 @@ const InvokeAIUI = ({
useEffect(() => {
if (workflowCategories) {
- $workflowCategories.set(workflowCategories);
+ $workflowLibraryCategoriesOptions.set(workflowCategories);
}
return () => {
- $workflowCategories.set([]);
+ $workflowLibraryCategoriesOptions.set(DEFAULT_WORKFLOW_LIBRARY_CATEGORIES);
};
}, [workflowCategories]);
+ useEffect(() => {
+ if (workflowTagCategories) {
+ $workflowLibraryTagCategoriesOptions.set(workflowTagCategories);
+ }
+
+ return () => {
+ $workflowLibraryTagCategoriesOptions.set(DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES);
+ };
+ }, [workflowTagCategories]);
+
useEffect(() => {
if (socketOptions) {
$socketOptions.set(socketOptions);
diff --git a/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts b/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts
deleted file mode 100644
index e0d6107129..0000000000
--- a/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import type { WorkflowCategory } from 'features/nodes/types/workflow';
-import { atom } from 'nanostores';
-
-export const $workflowCategories = atom(['user', 'default']);
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index a36300cca9..a2028b49e1 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -19,6 +19,7 @@ import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/galle
import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice';
import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { nodesPersistConfig, nodesSlice, nodesUndoableConfig } from 'features/nodes/store/nodesSlice';
+import { workflowLibraryPersistConfig, workflowLibrarySlice } from 'features/nodes/store/workflowLibrarySlice';
import { workflowSettingsPersistConfig, workflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
import { workflowPersistConfig, workflowSlice } from 'features/nodes/store/workflowSlice';
import { upscalePersistConfig, upscaleSlice } from 'features/parameters/store/upscaleSlice';
@@ -68,6 +69,7 @@ const allReducers = {
[canvasSettingsSlice.name]: canvasSettingsSlice.reducer,
[canvasStagingAreaSlice.name]: canvasStagingAreaSlice.reducer,
[lorasSlice.name]: lorasSlice.reducer,
+ [workflowLibrarySlice.name]: workflowLibrarySlice.reducer,
};
const rootReducer = combineReducers(allReducers);
@@ -113,6 +115,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[canvasSettingsPersistConfig.name]: canvasSettingsPersistConfig,
[canvasStagingAreaPersistConfig.name]: canvasStagingAreaPersistConfig,
[lorasPersistConfig.name]: lorasPersistConfig,
+ [workflowLibraryPersistConfig.name]: workflowLibraryPersistConfig,
};
const unserialize: UnserializeFunction = (data, key) => {
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 0f2f9d6692..9abeebe9cf 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -1,16 +1,17 @@
import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library';
import { Button, Checkbox, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
-import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { WORKFLOW_TAGS, type WorkflowTag } from 'features/nodes/store/types';
+import type { WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import {
- selectWorkflowLibrarySelectedTags,
- selectWorkflowSelectedCategories,
- workflowSelectedCategoriesChanged,
- workflowSelectedTagsRese,
- workflowSelectedTagToggled,
-} from 'features/nodes/store/workflowSlice';
+ $workflowLibraryCategoriesOptions,
+ $workflowLibraryTagCategoriesOptions,
+ selectWorkflowLibraryCategories,
+ selectWorkflowLibraryTags,
+ workflowLibraryCategoriesChanged,
+ workflowLibraryTagsReset,
+ workflowLibraryTagToggled,
+} from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton';
@@ -24,28 +25,29 @@ import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
- const categories = useAppSelector(selectWorkflowSelectedCategories);
- const categoryOptions = useStore($workflowCategories);
- const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const categories = useAppSelector(selectWorkflowLibraryCategories);
+ const categoryOptions = useStore($workflowLibraryCategoriesOptions);
+ const tags = useAppSelector(selectWorkflowLibraryTags);
+ const tagCategoryOptions = useStore($workflowLibraryTagCategoriesOptions);
const selectYourWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user']));
+ dispatch(workflowLibraryCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user']));
}, [categoryOptions, dispatch]);
const selectPrivateWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(['user']));
+ dispatch(workflowLibraryCategoriesChanged(['user']));
}, [dispatch]);
const selectSharedWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(['project']));
+ dispatch(workflowLibraryCategoriesChanged(['project']));
}, [dispatch]);
const selectDefaultWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(['default']));
+ dispatch(workflowLibraryCategoriesChanged(['default']));
}, [dispatch]);
const resetTags = useCallback(() => {
- dispatch(workflowSelectedTagsRese());
+ dispatch(workflowLibraryTagsReset());
}, [dispatch]);
const isYourWorkflowsSelected = useMemo(() => {
@@ -116,7 +118,7 @@ export const WorkflowLibrarySideNav = () => {
- {WORKFLOW_TAGS.map((tagCategory) => (
+ {tagCategoryOptions.map((tagCategory) => (
@@ -218,40 +220,39 @@ const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected
});
CategoryButton.displayName = 'NavButton';
-const TagCategory = memo(
- ({ tagCategory, isDisabled }: { tagCategory: (typeof WORKFLOW_TAGS)[number]; isDisabled: boolean }) => {
- const { count } = useGetCountsQuery(
- { tags: [...tagCategory.tags], categories: ['default'] },
- { selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
- );
+const TagCategory = memo(({ tagCategory, isDisabled }: { tagCategory: WorkflowTagCategory; isDisabled: boolean }) => {
+ const { t } = useTranslation();
+ const { count } = useGetCountsQuery(
+ { tags: [...tagCategory.tags], categories: ['default'] },
+ { selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
+ );
- if (count === 0) {
- return null;
- }
-
- return (
-
-
- {tagCategory.category}
-
-
- {tagCategory.tags.map((tag) => (
-
- ))}
-
-
- );
+ if (count === 0) {
+ return null;
}
-);
+
+ return (
+
+
+ {t(tagCategory.categoryTKey)}
+
+
+ {tagCategory.tags.map((tag) => (
+
+ ))}
+
+
+ );
+});
TagCategory.displayName = 'TagCategory';
-const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: WorkflowTag }) => {
+const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) => {
const dispatch = useAppDispatch();
- const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const selectedTags = useAppSelector(selectWorkflowLibraryTags);
const isSelected = selectedTags.includes(tag);
const onChange = useCallback(() => {
- dispatch(workflowSelectedTagToggled(tag));
+ dispatch(workflowLibraryTagToggled(tag));
}, [dispatch, tag]);
const { count } = useGetCountsQuery(
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 99c578679c..049bb0b8c8 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -3,15 +3,15 @@ import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import {
- selectWorkflowLibrarySelectedTags,
- selectWorkflowOrderBy,
- selectWorkflowOrderDirection,
- selectWorkflowSearchTerm,
- selectWorkflowSelectedCategories,
-} from 'features/nodes/store/workflowSlice';
+ selectWorkflowLibraryCategories,
+ selectWorkflowLibraryDirection,
+ selectWorkflowLibraryHasSearchTerm,
+ selectWorkflowLibraryOrderBy,
+ selectWorkflowLibrarySearchTerm,
+ selectWorkflowLibraryTags,
+} from 'features/nodes/store/workflowLibrarySlice';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import type { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
import { useDebounce } from 'use-debounce';
@@ -21,11 +21,11 @@ import { WorkflowListItem } from './WorkflowListItem';
const PER_PAGE = 30;
const useInfiniteQueryAry = () => {
- const categories = useAppSelector(selectWorkflowSelectedCategories);
- const orderBy = useAppSelector(selectWorkflowOrderBy);
- const direction = useAppSelector(selectWorkflowOrderDirection);
- const query = useAppSelector(selectWorkflowSearchTerm);
- const tags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const categories = useAppSelector(selectWorkflowLibraryCategories);
+ const orderBy = useAppSelector(selectWorkflowLibraryOrderBy);
+ const direction = useAppSelector(selectWorkflowLibraryDirection);
+ const query = useAppSelector(selectWorkflowLibrarySearchTerm);
+ const tags = useAppSelector(selectWorkflowLibraryTags);
const [debouncedQuery] = useDebounce(query, 500);
const queryArg = useMemo(() => {
@@ -37,7 +37,7 @@ const useInfiniteQueryAry = () => {
categories,
query: debouncedQuery,
tags: categories.length === 1 && categories.includes('default') ? tags : [],
- } satisfies Parameters[0];
+ } satisfies Parameters[0];
}, [orderBy, direction, categories, debouncedQuery, tags]);
return queryArg;
@@ -53,8 +53,6 @@ const queryOptions = {
} satisfies Parameters[1];
export const WorkflowList = () => {
- const searchTerm = useAppSelector(selectWorkflowSearchTerm);
- const { t } = useTranslation();
const queryArg = useInfiniteQueryAry();
const { items, isFetching, isLoading, fetchNextPage, hasNextPage } = useListWorkflowsInfiniteInfiniteQuery(
queryArg,
@@ -70,14 +68,7 @@ export const WorkflowList = () => {
}
if (items.length === 0) {
- return (
-
- );
+ return ;
}
return (
@@ -90,6 +81,20 @@ export const WorkflowList = () => {
);
};
+const NoItems = memo(() => {
+ const { t } = useTranslation();
+ const hasSearchTerm = useAppSelector(selectWorkflowLibraryHasSearchTerm);
+
+ return (
+
+ );
+});
+NoItems.displayName = 'NoItems';
const WorkflowListContent = memo(
({
items,
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx
index 599f7e1163..058fc12daa 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx
@@ -1,6 +1,9 @@
import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { selectWorkflowSearchTerm, workflowSearchTermChanged } from 'features/nodes/store/workflowSlice';
+import {
+ selectWorkflowLibrarySearchTerm,
+ workflowLibrarySearchTermChanged,
+} from 'features/nodes/store/workflowLibrarySlice';
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,18 +11,18 @@ import { PiXBold } from 'react-icons/pi';
export const WorkflowSearch = memo(({ searchInputRef }: { searchInputRef: RefObject }) => {
const dispatch = useAppDispatch();
- const searchTerm = useAppSelector(selectWorkflowSearchTerm);
+ const searchTerm = useAppSelector(selectWorkflowLibrarySearchTerm);
const { t } = useTranslation();
const handleWorkflowSearch = useCallback(
(newSearchTerm: string) => {
- dispatch(workflowSearchTermChanged(newSearchTerm));
+ dispatch(workflowLibrarySearchTermChanged(newSearchTerm));
},
[dispatch]
);
const clearWorkflowSearch = useCallback(() => {
- dispatch(workflowSearchTermChanged(''));
+ dispatch(workflowLibrarySearchTermChanged(''));
}, [dispatch]);
const handleKeydown = useCallback(
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx
index 9a1606aa8f..1249b43991 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx
@@ -1,11 +1,11 @@
import { Flex, FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
- selectWorkflowOrderBy,
- selectWorkflowOrderDirection,
- workflowOrderByChanged,
- workflowOrderDirectionChanged,
-} from 'features/nodes/store/workflowSlice';
+ selectWorkflowLibraryDirection,
+ selectWorkflowLibraryOrderBy,
+ workflowLibraryDirectionChanged,
+ workflowLibraryOrderByChanged,
+} from 'features/nodes/store/workflowLibrarySlice';
import type { ChangeEvent } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -22,8 +22,8 @@ const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).succ
export const WorkflowSortControl = () => {
const { t } = useTranslation();
- const orderBy = useAppSelector(selectWorkflowOrderBy);
- const direction = useAppSelector(selectWorkflowOrderDirection);
+ const orderBy = useAppSelector(selectWorkflowLibraryOrderBy);
+ const direction = useAppSelector(selectWorkflowLibraryDirection);
const ORDER_BY_LABELS = useMemo(
() => ({
@@ -50,7 +50,7 @@ export const WorkflowSortControl = () => {
if (!isOrderBy(e.target.value)) {
return;
}
- dispatch(workflowOrderByChanged(e.target.value));
+ dispatch(workflowLibraryOrderByChanged(e.target.value));
},
[dispatch]
);
@@ -60,7 +60,7 @@ export const WorkflowSortControl = () => {
if (!isDirection(e.target.value)) {
return;
}
- dispatch(workflowOrderDirectionChanged(e.target.value));
+ dispatch(workflowLibraryDirectionChanged(e.target.value));
},
[dispatch]
);
diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts
index bbafe0eebb..0d22306cef 100644
--- a/invokeai/frontend/web/src/features/nodes/store/types.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/types.ts
@@ -1,8 +1,7 @@
import type { HandleType } from '@xyflow/react';
import type { FieldInputTemplate, FieldOutputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { AnyEdge, AnyNode, InvocationTemplate, NodeExecutionState } from 'features/nodes/types/invocation';
-import type { WorkflowCategory, WorkflowV3 } from 'features/nodes/types/workflow';
-import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
+import type { WorkflowV3 } from 'features/nodes/types/workflow';
export type Templates = Record;
export type NodeExecutionStates = Record;
@@ -22,22 +21,9 @@ export type NodesState = {
export type WorkflowMode = 'edit' | 'view';
-export const WORKFLOW_TAGS = [
- { category: 'Industry', tags: ['Architecture', 'Fashion', 'Game Dev', 'Food'] },
- { category: 'Common Tasks', tags: ['Upscaling', 'Text to Image', 'Image to Image'] },
- { category: 'Model Architecture', tags: ['SD1.5', 'SDXL', 'Bria', 'FLUX'] },
- { category: 'Tech Showcase', tags: ['Control', 'Reference Image'] },
-] as const;
-export type WorkflowTag = (typeof WORKFLOW_TAGS)[number]['tags'][number];
-
export type WorkflowsState = Omit & {
_version: 1;
isTouched: boolean;
mode: WorkflowMode;
- selectedTags: WorkflowTag[];
- selectedCategories: WorkflowCategory[];
- searchTerm: string;
- orderBy?: WorkflowRecordOrderBy;
- orderDirection: SQLiteDirection;
formFieldInitialValues: Record;
};
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
new file mode 100644
index 0000000000..838895da90
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -0,0 +1,98 @@
+import type { PayloadAction, Selector } from '@reduxjs/toolkit';
+import { createSelector, createSlice } from '@reduxjs/toolkit';
+import type { PersistConfig, RootState } from 'app/store/store';
+import type { WorkflowCategory } from 'features/nodes/types/workflow';
+import { atom } from 'nanostores';
+import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
+
+type WorkflowLibraryState = {
+ searchTerm: string;
+ orderBy: WorkflowRecordOrderBy;
+ direction: SQLiteDirection;
+ tags: string[];
+ categories: WorkflowCategory[];
+};
+
+const initialWorkflowLibraryState: WorkflowLibraryState = {
+ searchTerm: '',
+ orderBy: 'opened_at',
+ direction: 'DESC',
+ tags: [],
+ categories: ['user'],
+};
+
+export const workflowLibrarySlice = createSlice({
+ name: 'workflowLibrary',
+ initialState: initialWorkflowLibraryState,
+ reducers: {
+ workflowLibrarySearchTermChanged: (state, action: PayloadAction) => {
+ state.searchTerm = action.payload;
+ },
+ workflowLibraryOrderByChanged: (state, action: PayloadAction) => {
+ state.orderBy = action.payload;
+ },
+ workflowLibraryDirectionChanged: (state, action: PayloadAction) => {
+ state.direction = action.payload;
+ },
+ workflowLibraryCategoriesChanged: (state, action: PayloadAction) => {
+ state.categories = action.payload;
+ state.searchTerm = '';
+ },
+ workflowLibraryTagToggled: (state, action: PayloadAction) => {
+ const tag = action.payload;
+ const tags = state.tags;
+ if (tags.includes(tag)) {
+ state.tags = tags.filter((t) => t !== tag);
+ } else {
+ state.tags = [...tags, tag];
+ }
+ },
+ workflowLibraryTagsReset: (state) => {
+ state.tags = [];
+ },
+ },
+});
+
+export const {
+ workflowLibrarySearchTermChanged,
+ workflowLibraryOrderByChanged,
+ workflowLibraryDirectionChanged,
+ workflowLibraryCategoriesChanged,
+ workflowLibraryTagToggled,
+ workflowLibraryTagsReset,
+} = workflowLibrarySlice.actions;
+
+/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+const migrateWorkflowLibraryState = (state: any): any => state;
+
+export const workflowLibraryPersistConfig: PersistConfig = {
+ name: workflowLibrarySlice.name,
+ initialState: initialWorkflowLibraryState,
+ migrate: migrateWorkflowLibraryState,
+ persistDenylist: [],
+};
+
+export const selectWorkflowLibrarySlice = (state: RootState) => state.workflowLibrary;
+const createWorkflowLibrarySelector = (selector: Selector) =>
+ createSelector(selectWorkflowLibrarySlice, selector);
+
+export const selectWorkflowLibrarySearchTerm = createWorkflowLibrarySelector(({ searchTerm }) => searchTerm);
+export const selectWorkflowLibraryHasSearchTerm = createWorkflowLibrarySelector(({ searchTerm }) => !!searchTerm);
+export const selectWorkflowLibraryOrderBy = createWorkflowLibrarySelector(({ orderBy }) => orderBy);
+export const selectWorkflowLibraryDirection = createWorkflowLibrarySelector(({ direction }) => direction);
+export const selectWorkflowLibraryTags = createWorkflowLibrarySelector(({ tags }) => tags);
+export const selectWorkflowLibraryCategories = createWorkflowLibrarySelector(({ categories }) => categories);
+
+export const DEFAULT_WORKFLOW_LIBRARY_CATEGORIES = ['user', 'default'] satisfies WorkflowCategory[];
+export const $workflowLibraryCategoriesOptions = atom(DEFAULT_WORKFLOW_LIBRARY_CATEGORIES);
+
+export type WorkflowTagCategory = { categoryTKey: string; tags: string[] };
+export const DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES: WorkflowTagCategory[] = [
+ { categoryTKey: 'Industry', tags: ['Architecture', 'Fashion', 'Game Dev', 'Food'] },
+ { categoryTKey: 'Common Tasks', tags: ['Upscaling', 'Text to Image', 'Image to Image'] },
+ { categoryTKey: 'Model Architecture', tags: ['SD1.5', 'SDXL', 'Bria', 'FLUX'] },
+ { categoryTKey: 'Tech Showcase', tags: ['Control', 'Reference Image'] },
+];
+export const $workflowLibraryTagCategoriesOptions = atom(
+ DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES
+);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
index fda9049124..04c88fd30b 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -15,7 +15,6 @@ import type {
NodesState,
WorkflowMode,
WorkflowsState as WorkflowState,
- WorkflowTag,
} from 'features/nodes/store/types';
import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
@@ -39,8 +38,9 @@ import {
isTextElement,
} from 'features/nodes/types/workflow';
import { isEqual } from 'lodash-es';
+import { atom } from 'nanostores';
import { useMemo } from 'react';
-import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
+import type { WorkflowRecordOrderBy } from 'services/api/types';
import { selectNodesSlice } from './selectors';
@@ -83,11 +83,6 @@ const initialWorkflowState: WorkflowState = {
isTouched: false,
mode: 'view',
formFieldInitialValues: {},
- searchTerm: '',
- orderBy: 'opened_at', // initial value is decided in component
- orderDirection: 'DESC',
- selectedTags: [],
- selectedCategories: ['user'],
...getBlankWorkflow(),
};
@@ -98,19 +93,6 @@ export const workflowSlice = createSlice({
workflowModeChanged: (state, action: PayloadAction) => {
state.mode = action.payload;
},
- workflowSearchTermChanged: (state, action: PayloadAction) => {
- state.searchTerm = action.payload;
- },
- workflowOrderByChanged: (state, action: PayloadAction) => {
- state.orderBy = action.payload;
- },
- workflowOrderDirectionChanged: (state, action: PayloadAction) => {
- state.orderDirection = action.payload;
- },
- workflowSelectedCategoriesChanged: (state, action: PayloadAction) => {
- state.selectedCategories = action.payload;
- state.searchTerm = '';
- },
workflowNameChanged: (state, action: PayloadAction) => {
state.name = action.payload;
state.isTouched = true;
@@ -150,18 +132,6 @@ export const workflowSlice = createSlice({
workflowSaved: (state) => {
state.isTouched = false;
},
- workflowSelectedTagToggled: (state, action: PayloadAction) => {
- const tag = action.payload;
- const tags = state.selectedTags;
- if (tags.includes(tag)) {
- state.selectedTags = tags.filter((t) => t !== tag);
- } else {
- state.selectedTags = [...tags, tag];
- }
- },
- workflowSelectedTagsRese: (state) => {
- state.selectedTags = [];
- },
formReset: (state) => {
const rootElement = buildContainer('column', []);
state.form = {
@@ -314,12 +284,6 @@ export const {
workflowContactChanged,
workflowIDChanged,
workflowSaved,
- workflowSearchTermChanged,
- workflowOrderByChanged,
- workflowOrderDirectionChanged,
- workflowSelectedCategoriesChanged,
- workflowSelectedTagToggled,
- workflowSelectedTagsRese,
formReset,
formElementAdded,
formElementRemoved,
@@ -382,12 +346,7 @@ export const selectWorkflowName = createWorkflowSelector((workflow) => workflow.
export const selectWorkflowId = createWorkflowSelector((workflow) => workflow.id);
export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.mode);
export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched);
-export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm);
-export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy);
-export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection);
-export const selectWorkflowSelectedCategories = createWorkflowSelector((workflow) => workflow.selectedCategories);
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
-export const selectWorkflowLibrarySelectedTags = createWorkflowSelector((workflow) => workflow.selectedTags);
export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form);
export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => {
@@ -420,3 +379,19 @@ export const useElement = (id: string): FormElement | undefined => {
const element = useAppSelector(selector);
return element;
};
+
+export const DEFAULT_WORKFLOW_CATEGORIES = ['user', 'default'] satisfies WorkflowCategory[];
+export const $workflowCategories = atom(DEFAULT_WORKFLOW_CATEGORIES);
+export const $selectedWorkflowCategories = atom(['user']);
+
+export const DEFAULT_WORKFLOW_TAG_CATEGORIES = {
+ Industry: ['Architecture', 'Fashion', 'Game Dev', 'Food'],
+ 'Common Tasks': ['Upscaling', 'Text to Image', 'Image to Image'],
+ 'Model Architecture': ['SD1.5', 'SDXL', 'Bria', 'FLUX'],
+ 'Tech Showcase': ['Control', 'Reference Image'],
+} satisfies Record;
+export const $workflowTagCategories = atom>(DEFAULT_WORKFLOW_TAG_CATEGORIES);
+export const $selectedWorkflowTags = atom([]);
+
+export const $workflowLibarySearchTerm = atom('');
+export const $workflowLibraryOrderBy = atom('opened_at');
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
index 7e57a00264..1bdee73d7d 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
@@ -12,9 +12,9 @@ import {
Input,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
-import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { deepClone } from 'common/util/deepClone';
+import { $workflowLibraryCategoriesOptions } from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { isDraftWorkflow, useCreateLibraryWorkflow } from 'features/workflowLibrary/hooks/useCreateNewWorkflow';
import { t } from 'i18next';
@@ -83,7 +83,7 @@ export const SaveWorkflowAsDialog = () => {
};
const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef: RefObject }) => {
- const workflowCategories = useStore($workflowCategories);
+ const workflowCategories = useStore($workflowLibraryCategoriesOptions);
const [name, setName] = useState(() => {
if (workflow) {
return getInitialName(workflow);
From 7988bc1a59230887ffc8aeb7c6ff1aefbd9e9252 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:10:58 +1000
Subject: [PATCH 13/67] chore(ui): remove unused WorkflowsRecent RTKQ tag
This didn't actually do anything. Will be implementing the actual functionality that you'd _think_ this tag would do in a future change.
---
.../src/services/api/endpoints/workflows.ts | 26 +++----------------
.../frontend/web/src/services/api/index.ts | 1 -
2 files changed, 3 insertions(+), 24 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 4e7f57d54b..56dff73254 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -19,15 +19,6 @@ export const workflowsApi = api.injectEndpoints({
>({
query: (workflow_id) => buildWorkflowsUrl(`i/${workflow_id}`),
providesTags: (result, error, workflow_id) => [{ type: 'Workflow', id: workflow_id }, 'FetchOnReconnect'],
- onQueryStarted: async (arg, api) => {
- const { dispatch, queryFulfilled } = api;
- try {
- await queryFulfilled;
- dispatch(workflowsApi.util.invalidateTags([{ type: 'WorkflowsRecent', id: LIST_TAG }]));
- } catch {
- // no-op
- }
- },
}),
deleteWorkflow: build.mutation({
query: (workflow_id) => ({
@@ -37,7 +28,6 @@ export const workflowsApi = api.injectEndpoints({
invalidatesTags: (result, error, workflow_id) => [
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow_id },
- { type: 'WorkflowsRecent', id: LIST_TAG },
],
}),
createWorkflow: build.mutation<
@@ -49,10 +39,7 @@ export const workflowsApi = api.injectEndpoints({
method: 'POST',
body: { workflow },
}),
- invalidatesTags: [
- { type: 'Workflow', id: LIST_TAG },
- { type: 'WorkflowsRecent', id: LIST_TAG },
- ],
+ invalidatesTags: [{ type: 'Workflow', id: LIST_TAG }],
}),
updateWorkflow: build.mutation<
paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'],
@@ -64,7 +51,6 @@ export const workflowsApi = api.injectEndpoints({
body: { workflow },
}),
invalidatesTags: (response, error, workflow) => [
- { type: 'WorkflowsRecent', id: LIST_TAG },
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow.id },
],
@@ -126,20 +112,14 @@ export const workflowsApi = api.injectEndpoints({
body: formData,
};
},
- invalidatesTags: (result, error, { workflow_id }) => [
- { type: 'Workflow', id: workflow_id },
- { type: 'WorkflowsRecent', id: LIST_TAG },
- ],
+ invalidatesTags: (result, error, { workflow_id }) => [{ type: 'Workflow', id: workflow_id }],
}),
deleteWorkflowThumbnail: build.mutation({
query: (workflow_id) => ({
url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`),
method: 'DELETE',
}),
- invalidatesTags: (result, error, workflow_id) => [
- { type: 'Workflow', id: workflow_id },
- { type: 'WorkflowsRecent', id: LIST_TAG },
- ],
+ invalidatesTags: (result, error, workflow_id) => [{ type: 'Workflow', id: workflow_id }],
}),
}),
});
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 890c041d1f..18d85e4cfc 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -44,7 +44,6 @@ const tagTypes = [
'LoRAModel',
'SDXLRefinerModel',
'Workflow',
- 'WorkflowsRecent',
'StylePreset',
'Schema',
'QueueCountsByDestination',
From 4cc70d9f16315f6981dd9f61dc22f31635d7bc76 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:14:22 +1000
Subject: [PATCH 14/67] feat(ui): add cache tags for workflow library's
infinite query
---
.../frontend/web/src/services/api/endpoints/workflows.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 56dff73254..af796d8474 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -1,6 +1,7 @@
import queryString from 'query-string';
import type { paths } from 'services/api/schema';
+import type { ApiTagDescription } from '..';
import { api, buildV1Url, LIST_TAG } from '..';
/**
@@ -94,6 +95,13 @@ export const workflowsApi = api.injectEndpoints({
return firstPageParam > -1 ? firstPageParam - 1 : undefined;
},
},
+ providesTags: (result) => {
+ const tags: ApiTagDescription[] = ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }];
+ if (result) {
+ tags.push(...result.items.map((workflow) => ({ type: 'Workflow', id: workflow.workflow_id }) as const));
+ }
+ return tags;
+ },
}),
updateOpenedAt: build.mutation({
query: ({ workflow_id }) => ({
From 7d3434da62f2a92ade107d9d814a2a53652351b4 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:16:51 +1000
Subject: [PATCH 15/67] fix(ui): updating workflow opened at invalidates
infinite query cache
---
.../frontend/web/src/services/api/endpoints/workflows.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index af796d8474..85aa7ffea9 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -108,7 +108,11 @@ export const workflowsApi = api.injectEndpoints({
url: buildWorkflowsUrl(`i/${workflow_id}/opened_at`),
method: 'PUT',
}),
- invalidatesTags: (result, error, { workflow_id }) => [{ type: 'Workflow', id: workflow_id }],
+ invalidatesTags: (result, error, { workflow_id }) => [
+ { type: 'Workflow', id: workflow_id },
+ // Because this may change the order of the list, we need to invalidate the whole list
+ { type: 'Workflow', id: LIST_TAG },
+ ],
}),
setWorkflowThumbnail: build.mutation({
query: ({ workflow_id, image }) => {
From e5da808b2f18730323663a1ed9ff425458d01fcc Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:18:20 +1000
Subject: [PATCH 16/67] fix(ui): updating workflow content should not
invalidate the infinite query cache
---
.../frontend/web/src/services/api/endpoints/workflows.ts | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 85aa7ffea9..75e245a534 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -51,10 +51,7 @@ export const workflowsApi = api.injectEndpoints({
method: 'PATCH',
body: { workflow },
}),
- invalidatesTags: (response, error, workflow) => [
- { type: 'Workflow', id: LIST_TAG },
- { type: 'Workflow', id: workflow.id },
- ],
+ invalidatesTags: (response, error, workflow) => [{ type: 'Workflow', id: workflow.id }],
}),
listWorkflows: build.query<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
From 6c8dc32d5cfb9b821203d99be2ac5d8b4cb2146c Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:18:41 +1000
Subject: [PATCH 17/67] docs(ui): add comments to workflow library cache
invalidation
---
.../frontend/web/src/services/api/endpoints/workflows.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 75e245a534..89174e9e42 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -27,6 +27,7 @@ export const workflowsApi = api.injectEndpoints({
method: 'DELETE',
}),
invalidatesTags: (result, error, workflow_id) => [
+ // Because this may change the order of the list, we need to invalidate the whole list
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow_id },
],
@@ -40,7 +41,10 @@ export const workflowsApi = api.injectEndpoints({
method: 'POST',
body: { workflow },
}),
- invalidatesTags: [{ type: 'Workflow', id: LIST_TAG }],
+ invalidatesTags: [
+ // Because this may change the order of the list, we need to invalidate the whole list
+ { type: 'Workflow', id: LIST_TAG },
+ ],
}),
updateWorkflow: build.mutation<
paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'],
From 4feff5a185e2d9be47606ba0d0c14a55a29ed32b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:22:19 +1000
Subject: [PATCH 18/67] chore(ui): bump @reduxjs/toolkit from 1.6.0 to 1.6.1
This brings in some fixes for the new infinite query support.
---
invokeai/frontend/web/package.json | 2 +-
invokeai/frontend/web/pnpm-lock.yaml | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index d12a55bb81..6d1efdce31 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -60,7 +60,7 @@
"@fontsource-variable/inter": "^5.1.0",
"@invoke-ai/ui-library": "^0.0.46",
"@nanostores/react": "^0.7.3",
- "@reduxjs/toolkit": "2.6.0",
+ "@reduxjs/toolkit": "2.6.1",
"@roarr/browser-log-writer": "^1.3.0",
"@xyflow/react": "^12.4.2",
"async-mutex": "^0.5.0",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index 17c3cde116..2cceb3bcfa 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -30,8 +30,8 @@ dependencies:
specifier: ^0.7.3
version: 0.7.3(nanostores@0.11.3)(react@18.3.1)
'@reduxjs/toolkit':
- specifier: 2.6.0
- version: 2.6.0(react-redux@9.1.2)(react@18.3.1)
+ specifier: 2.6.1
+ version: 2.6.1(react-redux@9.1.2)(react@18.3.1)
'@roarr/browser-log-writer':
specifier: ^1.3.0
version: 1.3.0
@@ -2311,8 +2311,8 @@ packages:
- supports-color
dev: true
- /@reduxjs/toolkit@2.6.0(react-redux@9.1.2)(react@18.3.1):
- resolution: {integrity: sha512-mWJCYpewLRyTuuzRSEC/IwIBBkYg2dKtQas8mty5MaV2iXzcmicS3gW554FDeOvLnY3x13NIk8MB1e8wHO7rqQ==}
+ /@reduxjs/toolkit@2.6.1(react-redux@9.1.2)(react@18.3.1):
+ resolution: {integrity: sha512-SSlIqZNYhqm/oMkXbtofwZSt9lrncblzo6YcZ9zoX+zLngRBrCOjK4lNLdkNucJF58RHOWrD9txT3bT3piH7Zw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
From ef95b37ace65aa2735e5fd07326730abbcd03be4 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 19:25:46 +1000
Subject: [PATCH 19/67] fix(ui): workflow library infinite query providesTags
---
.../frontend/web/src/services/api/endpoints/workflows.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 89174e9e42..10d919896d 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -99,7 +99,12 @@ export const workflowsApi = api.injectEndpoints({
providesTags: (result) => {
const tags: ApiTagDescription[] = ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }];
if (result) {
- tags.push(...result.items.map((workflow) => ({ type: 'Workflow', id: workflow.workflow_id }) as const));
+ tags.push(
+ ...result.pages
+ .map(({ items }) => items)
+ .flat()
+ .map((workflow) => ({ type: 'Workflow', id: workflow.workflow_id }) as const)
+ );
}
return tags;
},
From b733d3897ee76585c55c35bd152ad5d3262c872f Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 21:12:48 +1000
Subject: [PATCH 20/67] feat(app): revised workflow library filtering by tag
- Replace `get_counts` method with `get_tag_counts_with_filter` which gets the counts for a list of tags, filtering by a list of selected tags
- Update `get_many` logic to apply tag filtering with AND logic, to match the new `get_tag_counts_with_filter` method
- Update workflow library router
---
invokeai/app/api/routers/workflows.py | 15 ++--
.../workflow_records/workflow_records_base.py | 18 +++--
.../workflow_records_sqlite.py | 79 ++++++++++---------
3 files changed, 64 insertions(+), 48 deletions(-)
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 2ec071ac76..8ef6cb9b3e 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -221,14 +221,17 @@ async def get_workflow_thumbnail(
raise HTTPException(status_code=404)
-@workflows_router.get("/counts", operation_id="get_counts")
-async def get_counts(
- tags: Optional[list[str]] = Query(default=None, description="The tags to include"),
+@workflows_router.get("/tag_counts_with_filter", operation_id="get_tag_counts_with_filter")
+async def get_tag_counts_with_filter(
+ tags_to_count: list[str] = Query(description="The tags to get counts for"),
+ selected_tags: Optional[list[str]] = Query(default=None, description="The tags to include"),
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
-) -> int:
- """Gets a the count of workflows that include the specified tags and categories"""
+) -> dict[str, int]:
+ """Gets tag counts with a filter"""
- return ApiDependencies.invoker.services.workflow_records.get_counts(tags=tags, categories=categories)
+ return ApiDependencies.invoker.services.workflow_records.get_tag_counts_with_filter(
+ tags_to_count=tags_to_count, categories=categories, selected_tags=selected_tags
+ )
@workflows_router.put(
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index de25ea876d..ebc858d943 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -51,12 +51,20 @@ class WorkflowRecordsStorageBase(ABC):
pass
@abstractmethod
- def get_counts(
+ def get_tag_counts_with_filter(
self,
- tags: Optional[list[str]],
- categories: Optional[list[WorkflowCategory]],
- ) -> int:
- """Gets the count of workflows for the given tags and categories."""
+ tags_to_count: list[str],
+ selected_tags: Optional[list[str]] = None,
+ categories: Optional[list[WorkflowCategory]] = None,
+ ) -> dict[str, int]:
+ """
+ For each tag in tags_to_count, count workflows matching:
+ - All selected_tags (AND logic filter)
+ - AND the specific tag being counted
+ - Filtered by categories if provided
+
+ Returns a dictionary of tag -> count.
+ """
pass
@abstractmethod
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 9425653eff..d4f16be15e 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -166,7 +166,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
# Construct a list of conditions for each tag
tags_conditions = ["tags LIKE ?" for _ in tags]
- tags_conditions_joined = " OR ".join(tags_conditions)
+ tags_conditions_joined = " AND ".join(tags_conditions)
tags_condition = f"({tags_conditions_joined})"
# And the params for the tags, case-insensitive
@@ -230,54 +230,59 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
total=total,
)
- def get_counts(
+ def get_tag_counts_with_filter(
self,
- tags: Optional[list[str]],
- categories: Optional[list[WorkflowCategory]],
- ) -> int:
+ tags_to_count: list[str],
+ selected_tags: Optional[list[str]] = None,
+ categories: Optional[list[WorkflowCategory]] = None,
+ ) -> dict[str, int]:
+ if not tags_to_count:
+ return {}
+
cursor = self._conn.cursor()
+ result: dict[str, int] = {}
+ selected_tags = selected_tags or []
- # Start with an empty list of conditions and params
- conditions: list[str] = []
- params: list[str | int] = []
-
- if tags:
- # Construct a list of conditions for each tag
- tags_conditions = ["tags LIKE ?" for _ in tags]
- tags_conditions_joined = " OR ".join(tags_conditions)
- tags_condition = f"({tags_conditions_joined})"
-
- # And the params for the tags, case-insensitive
- tags_params = [f"%{t.strip()}%" for t in tags]
-
- conditions.append(tags_condition)
- params.extend(tags_params)
+ # Base conditions for categories and selected tags
+ base_conditions: list[str] = []
+ base_params: list[str | int] = []
+ # Add category conditions
if categories:
- # Ensure all categories are valid (is this necessary?)
assert all(c in WorkflowCategory for c in categories)
-
- # Construct a placeholder string for the number of categories
placeholders = ", ".join("?" for _ in categories)
+ base_conditions.append(f"category IN ({placeholders})")
+ base_params.extend([category.value for category in categories])
- # Construct the condition string & params
- conditions.append(f"category IN ({placeholders})")
- params.extend([category.value for category in categories])
+ # Add selected tags conditions (AND logic)
+ for tag in selected_tags:
+ base_conditions.append("tags LIKE ?")
+ base_params.append(f"%{tag.strip()}%")
- stmt = """--sql
- SELECT COUNT(*)
- FROM workflow_library
- """
+ # For each tag to count, run a separate query
+ for tag in tags_to_count:
+ # Start with the base conditions
+ conditions = base_conditions.copy()
+ params = base_params.copy()
- if conditions:
- # If there are conditions, add a WHERE clause and then join the conditions
- stmt += " WHERE "
+ # Add this specific tag condition
+ conditions.append("tags LIKE ?")
+ params.append(f"%{tag.strip()}%")
- all_conditions = " AND ".join(conditions)
- stmt += all_conditions
+ # Construct the full query
+ stmt = """--sql
+ SELECT COUNT(*)
+ FROM workflow_library
+ """
- cursor.execute(stmt, tuple(params))
- return cursor.fetchone()[0]
+ if conditions:
+ stmt += " WHERE " + " AND ".join(conditions)
+
+ cursor.execute(stmt, params)
+ count = cursor.fetchone()[0]
+ result[tag] = count
+
+ return result
def update_opened_at(self, workflow_id: str) -> None:
try:
From a8023cbcb6253f5ed537e62df947292c90b9fd7a Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 21:13:36 +1000
Subject: [PATCH 21/67] chore(ui): typegen
---
.../frontend/web/src/services/api/schema.ts | 20 +++++++++++--------
1 file changed, 12 insertions(+), 8 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 486a31f107..7439d8f708 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -1438,7 +1438,7 @@ export type paths = {
patch?: never;
trace?: never;
};
- "/api/v1/workflows/counts": {
+ "/api/v1/workflows/tag_counts_with_filter": {
parameters: {
query?: never;
header?: never;
@@ -1446,10 +1446,10 @@ export type paths = {
cookie?: never;
};
/**
- * Get Counts
- * @description Gets a the count of workflows that include the specified tags and categories
+ * Get Tag Counts With Filter
+ * @description Gets tag counts with a filter
*/
- get: operations["get_counts"];
+ get: operations["get_tag_counts_with_filter"];
put?: never;
post?: never;
delete?: never;
@@ -24474,11 +24474,13 @@ export interface operations {
};
};
};
- get_counts: {
+ get_tag_counts_with_filter: {
parameters: {
- query?: {
+ query: {
+ /** @description The tags to get counts for */
+ tags_to_count: string[];
/** @description The tags to include */
- tags?: string[] | null;
+ selected_tags?: string[] | null;
/** @description The categories to include */
categories?: components["schemas"]["WorkflowCategory"][] | null;
};
@@ -24494,7 +24496,9 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": number;
+ "application/json": {
+ [key: string]: number;
+ };
};
};
/** @description Validation Error */
From 124ca23f8bed4d023ace54e3aaa54c290e5334a5 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 21:16:02 +1000
Subject: [PATCH 22/67] feat(ui): use new tag filtering for workflow library
---
.../WorkflowLibrarySideNav.tsx | 46 +++++++++++--------
.../nodes/store/workflowLibrarySlice.ts | 5 +-
.../src/services/api/endpoints/workflows.ts | 18 +++++---
.../frontend/web/src/services/api/index.ts | 1 +
4 files changed, 45 insertions(+), 25 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 9abeebe9cf..4c2e2e69c9 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -6,6 +6,7 @@ import type { WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySl
import {
$workflowLibraryCategoriesOptions,
$workflowLibraryTagCategoriesOptions,
+ $workflowLibraryTagOptions,
selectWorkflowLibraryCategories,
selectWorkflowLibraryTags,
workflowLibraryCategoriesChanged,
@@ -19,7 +20,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiUsersBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
-import { useGetCountsQuery, useListWorkflowsQuery } from 'services/api/endpoints/workflows';
+import { useGetTagCountsWithFilterQuery, useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
@@ -179,6 +180,31 @@ const RecentWorkflows = memo(() => {
});
RecentWorkflows.displayName = 'RecentWorkflows';
+const useCountForIndividualTag = (tag: string) => {
+ const allTags = useStore($workflowLibraryTagOptions);
+ const tags = useAppSelector(selectWorkflowLibraryTags);
+ const queryArg = useMemo[0]>(
+ () => ({
+ tags_to_count: allTags,
+ selected_tags: tags,
+ categories: ['default'], // We only allow filtering by tag for default workflows
+ }),
+ [allTags, tags]
+ );
+ const queryOptions = useMemo[1]>(
+ () => ({
+ selectFromResult: ({ data }) => ({
+ count: data?.[tag] ?? 0,
+ }),
+ }),
+ [tag]
+ );
+
+ const { count } = useGetTagCountsWithFilterQuery(queryArg, queryOptions);
+
+ return count;
+};
+
const RecentWorkflowButton = memo(({ workflow }: { workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }) => {
const loadWorkflow = useLoadWorkflow();
const load = useCallback(() => {
@@ -222,14 +248,6 @@ CategoryButton.displayName = 'NavButton';
const TagCategory = memo(({ tagCategory, isDisabled }: { tagCategory: WorkflowTagCategory; isDisabled: boolean }) => {
const { t } = useTranslation();
- const { count } = useGetCountsQuery(
- { tags: [...tagCategory.tags], categories: ['default'] },
- { selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
- );
-
- if (count === 0) {
- return null;
- }
return (
@@ -250,20 +268,12 @@ const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) =>
const dispatch = useAppDispatch();
const selectedTags = useAppSelector(selectWorkflowLibraryTags);
const isSelected = selectedTags.includes(tag);
+ const count = useCountForIndividualTag(tag);
const onChange = useCallback(() => {
dispatch(workflowLibraryTagToggled(tag));
}, [dispatch, tag]);
- const { count } = useGetCountsQuery(
- { tags: [tag], categories: ['default'] },
- { selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
- );
-
- if (count === 0) {
- return null;
- }
-
return (
{`${tag} (${count})`}
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index 838895da90..b7b64757c8 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -2,7 +2,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
-import { atom } from 'nanostores';
+import { atom, computed } from 'nanostores';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
type WorkflowLibraryState = {
@@ -96,3 +96,6 @@ export const DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES: WorkflowTagCategory[] = [
export const $workflowLibraryTagCategoriesOptions = atom(
DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES
);
+export const $workflowLibraryTagOptions = computed($workflowLibraryTagCategoriesOptions, (tagCategories) =>
+ tagCategories.flatMap(({ tags }) => tags)
+);
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 10d919896d..32af8fbf6d 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -30,6 +30,7 @@ export const workflowsApi = api.injectEndpoints({
// Because this may change the order of the list, we need to invalidate the whole list
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow_id },
+ 'WorkflowTagCountsWithFilter',
],
}),
createWorkflow: build.mutation<
@@ -44,6 +45,7 @@ export const workflowsApi = api.injectEndpoints({
invalidatesTags: [
// Because this may change the order of the list, we need to invalidate the whole list
{ type: 'Workflow', id: LIST_TAG },
+ 'WorkflowTagCountsWithFilter',
],
}),
updateWorkflow: build.mutation<
@@ -55,7 +57,10 @@ export const workflowsApi = api.injectEndpoints({
method: 'PATCH',
body: { workflow },
}),
- invalidatesTags: (response, error, workflow) => [{ type: 'Workflow', id: workflow.id }],
+ invalidatesTags: (response, error, workflow) => [
+ { type: 'Workflow', id: workflow.id },
+ 'WorkflowTagCountsWithFilter',
+ ],
}),
listWorkflows: build.query<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
@@ -66,13 +71,14 @@ export const workflowsApi = api.injectEndpoints({
}),
providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }],
}),
- getCounts: build.query<
- paths['/api/v1/workflows/counts']['get']['responses']['200']['content']['application/json'],
- NonNullable
+ getTagCountsWithFilter: build.query<
+ paths['/api/v1/workflows/tag_counts_with_filter']['get']['responses']['200']['content']['application/json'],
+ NonNullable
>({
query: (params) => ({
- url: `${buildWorkflowsUrl('counts')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
+ url: `${buildWorkflowsUrl('counts_by_tag_for_categories')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
}),
+ providesTags: ['WorkflowTagCountsWithFilter'],
}),
listWorkflowsInfinite: build.infiniteQuery<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
@@ -144,7 +150,7 @@ export const workflowsApi = api.injectEndpoints({
export const {
useUpdateOpenedAtMutation,
- useGetCountsQuery,
+ useGetTagCountsWithFilterQuery,
useLazyGetWorkflowQuery,
useGetWorkflowQuery,
useCreateWorkflowMutation,
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 18d85e4cfc..3bda78533e 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -44,6 +44,7 @@ const tagTypes = [
'LoRAModel',
'SDXLRefinerModel',
'Workflow',
+ 'WorkflowTagCountsWithFilter',
'StylePreset',
'Schema',
'QueueCountsByDestination',
From c493e223cf69f705e3b2b2efa15c8e78a96dab78 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 21:32:19 +1000
Subject: [PATCH 23/67] feat(ui): "Reset Tags" -> "Reset Filters"
---
invokeai/frontend/web/public/locales/en.json | 2 +-
.../workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index a4cad83fb6..bccab9c661 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1695,7 +1695,7 @@
"private": "Private",
"shared": "Shared",
"browseWorkflows": "Browse Workflows",
- "resetTags": "Reset Tags",
+ "resetFilters": "Reset Filters",
"opened": "Opened",
"openWorkflow": "Open Workflow",
"updated": "Updated",
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 4c2e2e69c9..0b1c1e8b1a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -130,7 +130,7 @@ export const WorkflowLibrarySideNav = () => {
leftIcon={}
h={8}
>
- {t('workflows.resetTags')}
+ {t('workflows.resetFilters')}
{tagCategoryOptions.map((tagCategory) => (
From 155daa3137577768c6c190c2742ac59baa4501be Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 21:32:43 +1000
Subject: [PATCH 24/67] feat(ui): hide filters with no workflows
---
.../WorkflowLibrarySideNav.tsx | 67 +++++++++++++++----
1 file changed, 55 insertions(+), 12 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 0b1c1e8b1a..52130634f6 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -183,20 +183,22 @@ RecentWorkflows.displayName = 'RecentWorkflows';
const useCountForIndividualTag = (tag: string) => {
const allTags = useStore($workflowLibraryTagOptions);
const tags = useAppSelector(selectWorkflowLibraryTags);
- const queryArg = useMemo[0]>(
- () => ({
- tags_to_count: allTags,
- selected_tags: tags,
- categories: ['default'], // We only allow filtering by tag for default workflows
- }),
+ const queryArg = useMemo(
+ () =>
+ ({
+ tags_to_count: allTags,
+ selected_tags: tags,
+ categories: ['default'], // We only allow filtering by tag for default workflows
+ }) satisfies Parameters[0],
[allTags, tags]
);
- const queryOptions = useMemo[1]>(
- () => ({
- selectFromResult: ({ data }) => ({
- count: data?.[tag] ?? 0,
- }),
- }),
+ const queryOptions = useMemo(
+ () =>
+ ({
+ selectFromResult: ({ data }) => ({
+ count: data?.[tag] ?? 0,
+ }),
+ }) satisfies Parameters[1],
[tag]
);
@@ -205,6 +207,38 @@ const useCountForIndividualTag = (tag: string) => {
return count;
};
+const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => {
+ const allTags = useStore($workflowLibraryTagOptions);
+ const tags = useAppSelector(selectWorkflowLibraryTags);
+ const queryArg = useMemo(
+ () =>
+ ({
+ tags_to_count: allTags,
+ selected_tags: tags,
+ categories: ['default'], // We only allow filtering by tag for default workflows
+ }) satisfies Parameters[0],
+ [allTags, tags]
+ );
+ const queryOptions = useMemo(
+ () =>
+ ({
+ selectFromResult: ({ data }) => {
+ if (!data) {
+ return { count: 0 };
+ }
+ return {
+ count: tagCategory.tags.reduce((acc, tag) => acc + (data[tag] ?? 0), 0),
+ };
+ },
+ }) satisfies Parameters[1],
+ [tagCategory]
+ );
+
+ const { count } = useGetTagCountsWithFilterQuery(queryArg, queryOptions);
+
+ return count;
+};
+
const RecentWorkflowButton = memo(({ workflow }: { workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }) => {
const loadWorkflow = useLoadWorkflow();
const load = useCallback(() => {
@@ -248,6 +282,11 @@ CategoryButton.displayName = 'NavButton';
const TagCategory = memo(({ tagCategory, isDisabled }: { tagCategory: WorkflowTagCategory; isDisabled: boolean }) => {
const { t } = useTranslation();
+ const count = useCountForTagCategory(tagCategory);
+
+ if (count === 0) {
+ return null;
+ }
return (
@@ -274,6 +313,10 @@ const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) =>
dispatch(workflowLibraryTagToggled(tag));
}, [dispatch, tag]);
+ if (count === 0) {
+ return null;
+ }
+
return (
{`${tag} (${count})`}
From 099011000f84554ec9a0b236f5d7a8146a03726f Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Tue, 11 Mar 2025 21:37:10 +1000
Subject: [PATCH 25/67] chore(ui): lint
---
.../web/src/app/components/InvokeAIUI.tsx | 5 ++--
.../nodes/store/workflowLibrarySlice.ts | 2 +-
.../src/features/nodes/store/workflowSlice.ts | 24 +------------------
3 files changed, 4 insertions(+), 27 deletions(-)
diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
index d08e46ec99..c3bbb3fa58 100644
--- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
+++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
@@ -19,13 +19,12 @@ import { $store } from 'app/store/nanostores/store';
import { createStore } from 'app/store/store';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
-import type {
- WorkflowTagCategory} from 'features/nodes/store/workflowLibrarySlice';
+import type { WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import {
$workflowLibraryCategoriesOptions,
$workflowLibraryTagCategoriesOptions,
DEFAULT_WORKFLOW_LIBRARY_CATEGORIES,
- DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES
+ DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES,
} from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { PropsWithChildren, ReactNode } from 'react';
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index b7b64757c8..7fad3b2c70 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -72,7 +72,7 @@ export const workflowLibraryPersistConfig: PersistConfig =
persistDenylist: [],
};
-export const selectWorkflowLibrarySlice = (state: RootState) => state.workflowLibrary;
+const selectWorkflowLibrarySlice = (state: RootState) => state.workflowLibrary;
const createWorkflowLibrarySelector = (selector: Selector) =>
createSelector(selectWorkflowLibrarySlice, selector);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
index 04c88fd30b..3bc3ce7b87 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -11,11 +11,7 @@ import {
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { workflowLoaded } from 'features/nodes/store/actions';
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged } from 'features/nodes/store/nodesSlice';
-import type {
- NodesState,
- WorkflowMode,
- WorkflowsState as WorkflowState,
-} from 'features/nodes/store/types';
+import type { NodesState, WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type {
@@ -38,9 +34,7 @@ import {
isTextElement,
} from 'features/nodes/types/workflow';
import { isEqual } from 'lodash-es';
-import { atom } from 'nanostores';
import { useMemo } from 'react';
-import type { WorkflowRecordOrderBy } from 'services/api/types';
import { selectNodesSlice } from './selectors';
@@ -379,19 +373,3 @@ export const useElement = (id: string): FormElement | undefined => {
const element = useAppSelector(selector);
return element;
};
-
-export const DEFAULT_WORKFLOW_CATEGORIES = ['user', 'default'] satisfies WorkflowCategory[];
-export const $workflowCategories = atom(DEFAULT_WORKFLOW_CATEGORIES);
-export const $selectedWorkflowCategories = atom(['user']);
-
-export const DEFAULT_WORKFLOW_TAG_CATEGORIES = {
- Industry: ['Architecture', 'Fashion', 'Game Dev', 'Food'],
- 'Common Tasks': ['Upscaling', 'Text to Image', 'Image to Image'],
- 'Model Architecture': ['SD1.5', 'SDXL', 'Bria', 'FLUX'],
- 'Tech Showcase': ['Control', 'Reference Image'],
-} satisfies Record;
-export const $workflowTagCategories = atom>(DEFAULT_WORKFLOW_TAG_CATEGORIES);
-export const $selectedWorkflowTags = atom([]);
-
-export const $workflowLibarySearchTerm = atom('');
-export const $workflowLibraryOrderBy = atom('opened_at');
From 3b0fecafb0146c276096bef9c1da1d898b73d402 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 05:36:59 +1000
Subject: [PATCH 26/67] fix(ui): URL mismatch for tag_counts_with_filter
---
invokeai/frontend/web/src/services/api/endpoints/workflows.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 32af8fbf6d..1b70c25d24 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -76,7 +76,7 @@ export const workflowsApi = api.injectEndpoints({
NonNullable
>({
query: (params) => ({
- url: `${buildWorkflowsUrl('counts_by_tag_for_categories')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
+ url: `${buildWorkflowsUrl('tag_counts_with_filter')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
}),
providesTags: ['WorkflowTagCountsWithFilter'],
}),
From 3ff529c71812fa500214468feef6ff0633729c06 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 06:28:02 +1000
Subject: [PATCH 27/67] revert(app): use OR logic for workflow library
filtering
---
invokeai/app/api/routers/workflows.py | 11 ++---
.../workflow_records/workflow_records_base.py | 14 ++----
.../workflow_records_sqlite.py | 48 ++++++++++++++++++-
3 files changed, 54 insertions(+), 19 deletions(-)
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 8ef6cb9b3e..4e32307cad 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -221,17 +221,14 @@ async def get_workflow_thumbnail(
raise HTTPException(status_code=404)
-@workflows_router.get("/tag_counts_with_filter", operation_id="get_tag_counts_with_filter")
-async def get_tag_counts_with_filter(
- tags_to_count: list[str] = Query(description="The tags to get counts for"),
- selected_tags: Optional[list[str]] = Query(default=None, description="The tags to include"),
+@workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag")
+async def get_counts_by_tag(
+ tags: list[str] = Query(description="The tags to get counts for"),
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
) -> dict[str, int]:
"""Gets tag counts with a filter"""
- return ApiDependencies.invoker.services.workflow_records.get_tag_counts_with_filter(
- tags_to_count=tags_to_count, categories=categories, selected_tags=selected_tags
- )
+ return ApiDependencies.invoker.services.workflow_records.counts_by_tag(tags=tags, categories=categories)
@workflows_router.put(
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index ebc858d943..aea13f55c8 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -51,20 +51,12 @@ class WorkflowRecordsStorageBase(ABC):
pass
@abstractmethod
- def get_tag_counts_with_filter(
+ def counts_by_tag(
self,
- tags_to_count: list[str],
- selected_tags: Optional[list[str]] = None,
+ tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
) -> dict[str, int]:
- """
- For each tag in tags_to_count, count workflows matching:
- - All selected_tags (AND logic filter)
- - AND the specific tag being counted
- - Filtered by categories if provided
-
- Returns a dictionary of tag -> count.
- """
+ """Gets a dictionary of counts for each of the provided tags."""
pass
@abstractmethod
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index d4f16be15e..6e3cb1fdbd 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -166,7 +166,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
# Construct a list of conditions for each tag
tags_conditions = ["tags LIKE ?" for _ in tags]
- tags_conditions_joined = " AND ".join(tags_conditions)
+ tags_conditions_joined = " OR ".join(tags_conditions)
tags_condition = f"({tags_conditions_joined})"
# And the params for the tags, case-insensitive
@@ -230,6 +230,52 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
total=total,
)
+ def counts_by_tag(
+ self,
+ tags: list[str],
+ categories: Optional[list[WorkflowCategory]] = None,
+ ) -> dict[str, int]:
+ if not tags:
+ return {}
+
+ cursor = self._conn.cursor()
+ result: dict[str, int] = {}
+ # Base conditions for categories and selected tags
+ base_conditions: list[str] = []
+ base_params: list[str | int] = []
+
+ # Add category conditions
+ if categories:
+ assert all(c in WorkflowCategory for c in categories)
+ placeholders = ", ".join("?" for _ in categories)
+ base_conditions.append(f"category IN ({placeholders})")
+ base_params.extend([category.value for category in categories])
+
+ # For each tag to count, run a separate query
+ for tag in tags:
+ # Start with the base conditions
+ conditions = base_conditions.copy()
+ params = base_params.copy()
+
+ # Add this specific tag condition
+ conditions.append("tags LIKE ?")
+ params.append(f"%{tag.strip()}%")
+
+ # Construct the full query
+ stmt = """--sql
+ SELECT COUNT(*)
+ FROM workflow_library
+ """
+
+ if conditions:
+ stmt += " WHERE " + " AND ".join(conditions)
+
+ cursor.execute(stmt, params)
+ count = cursor.fetchone()[0]
+ result[tag] = count
+
+ return result
+
def get_tag_counts_with_filter(
self,
tags_to_count: list[str],
From a8759ea0a6ae72df4ea458819906b9f3722fef4d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 06:28:08 +1000
Subject: [PATCH 28/67] chore(ui): typegen
---
invokeai/frontend/web/src/services/api/schema.ts | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 7439d8f708..60bd15dba1 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -1438,7 +1438,7 @@ export type paths = {
patch?: never;
trace?: never;
};
- "/api/v1/workflows/tag_counts_with_filter": {
+ "/api/v1/workflows/counts_by_tag": {
parameters: {
query?: never;
header?: never;
@@ -1446,10 +1446,10 @@ export type paths = {
cookie?: never;
};
/**
- * Get Tag Counts With Filter
+ * Get Counts By Tag
* @description Gets tag counts with a filter
*/
- get: operations["get_tag_counts_with_filter"];
+ get: operations["get_counts_by_tag"];
put?: never;
post?: never;
delete?: never;
@@ -24474,13 +24474,11 @@ export interface operations {
};
};
};
- get_tag_counts_with_filter: {
+ get_counts_by_tag: {
parameters: {
query: {
/** @description The tags to get counts for */
- tags_to_count: string[];
- /** @description The tags to include */
- selected_tags?: string[] | null;
+ tags: string[];
/** @description The categories to include */
categories?: components["schemas"]["WorkflowCategory"][] | null;
};
From dd5f353465cbafab28aec621db327fd0f4fedd81 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 06:28:34 +1000
Subject: [PATCH 29/67] revert(ui): use reverted API for workflow library
---
.../WorkflowLibrarySideNav.tsx | 32 ++++++++-----------
.../src/services/api/endpoints/workflows.ts | 10 +++---
2 files changed, 19 insertions(+), 23 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 52130634f6..944309bf18 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -20,7 +20,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiUsersBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
-import { useGetTagCountsWithFilterQuery, useListWorkflowsQuery } from 'services/api/endpoints/workflows';
+import { useGetCountsByTagQuery, useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
@@ -182,15 +182,13 @@ RecentWorkflows.displayName = 'RecentWorkflows';
const useCountForIndividualTag = (tag: string) => {
const allTags = useStore($workflowLibraryTagOptions);
- const tags = useAppSelector(selectWorkflowLibraryTags);
const queryArg = useMemo(
() =>
({
- tags_to_count: allTags,
- selected_tags: tags,
- categories: ['default'], // We only allow filtering by tag for default workflows
- }) satisfies Parameters[0],
- [allTags, tags]
+ tags: allTags,
+ categories: ['default'],
+ }) satisfies Parameters[0],
+ [allTags]
);
const queryOptions = useMemo(
() =>
@@ -198,26 +196,24 @@ const useCountForIndividualTag = (tag: string) => {
selectFromResult: ({ data }) => ({
count: data?.[tag] ?? 0,
}),
- }) satisfies Parameters[1],
+ }) satisfies Parameters[1],
[tag]
);
- const { count } = useGetTagCountsWithFilterQuery(queryArg, queryOptions);
+ const { count } = useGetCountsByTagQuery(queryArg, queryOptions);
return count;
};
const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => {
const allTags = useStore($workflowLibraryTagOptions);
- const tags = useAppSelector(selectWorkflowLibraryTags);
const queryArg = useMemo(
() =>
({
- tags_to_count: allTags,
- selected_tags: tags,
+ tags: allTags,
categories: ['default'], // We only allow filtering by tag for default workflows
- }) satisfies Parameters[0],
- [allTags, tags]
+ }) satisfies Parameters[0],
+ [allTags]
);
const queryOptions = useMemo(
() =>
@@ -230,11 +226,11 @@ const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => {
count: tagCategory.tags.reduce((acc, tag) => acc + (data[tag] ?? 0), 0),
};
},
- }) satisfies Parameters[1],
+ }) satisfies Parameters[1],
[tagCategory]
);
- const { count } = useGetTagCountsWithFilterQuery(queryArg, queryOptions);
+ const { count } = useGetCountsByTagQuery(queryArg, queryOptions);
return count;
};
@@ -306,7 +302,7 @@ TagCategory.displayName = 'TagCategory';
const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) => {
const dispatch = useAppDispatch();
const selectedTags = useAppSelector(selectWorkflowLibraryTags);
- const isSelected = selectedTags.includes(tag);
+ const isChecked = selectedTags.includes(tag);
const count = useCountForIndividualTag(tag);
const onChange = useCallback(() => {
@@ -318,7 +314,7 @@ const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) =>
}
return (
-
+
{`${tag} (${count})`}
);
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 1b70c25d24..60a08c2618 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -71,12 +71,12 @@ export const workflowsApi = api.injectEndpoints({
}),
providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }],
}),
- getTagCountsWithFilter: build.query<
- paths['/api/v1/workflows/tag_counts_with_filter']['get']['responses']['200']['content']['application/json'],
- NonNullable
+ getCountsByTag: build.query<
+ paths['/api/v1/workflows/counts_by_tag']['get']['responses']['200']['content']['application/json'],
+ NonNullable
>({
query: (params) => ({
- url: `${buildWorkflowsUrl('tag_counts_with_filter')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
+ url: `${buildWorkflowsUrl('counts_by_tag')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
}),
providesTags: ['WorkflowTagCountsWithFilter'],
}),
@@ -150,7 +150,7 @@ export const workflowsApi = api.injectEndpoints({
export const {
useUpdateOpenedAtMutation,
- useGetTagCountsWithFilterQuery,
+ useGetCountsByTagQuery,
useLazyGetWorkflowQuery,
useGetWorkflowQuery,
useCreateWorkflowMutation,
From deecb7f3c368204006895bdea77165ae766de769 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 06:39:07 +1000
Subject: [PATCH 30/67] feat(ui): "Reset Filters" -> "Deselect All"
---
invokeai/frontend/web/public/locales/en.json | 2 +-
.../workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index bccab9c661..ab8920d5ee 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1695,7 +1695,7 @@
"private": "Private",
"shared": "Shared",
"browseWorkflows": "Browse Workflows",
- "resetFilters": "Reset Filters",
+ "deselectAll": "Deselect All",
"opened": "Opened",
"openWorkflow": "Open Workflow",
"updated": "Updated",
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 944309bf18..d4f47b8bf6 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -130,7 +130,7 @@ export const WorkflowLibrarySideNav = () => {
leftIcon={}
h={8}
>
- {t('workflows.resetFilters')}
+ {t('workflows.deselectAll')}
{tagCategoryOptions.map((tagCategory) => (
From df305c0b99747e902d658534edfa39c8cd8886ae Mon Sep 17 00:00:00 2001
From: Mary Hipp
Date: Tue, 11 Mar 2025 12:22:23 -0400
Subject: [PATCH 31/67] allow opened_at to be nullable for workflows that the
user has never opened
---
invokeai/app/api/routers/workflows.py | 2 ++
.../app/services/shared/sqlite/sqlite_util.py | 2 ++
.../migrations/migration_18.py | 34 +++++++++++++++++++
.../workflow_records/workflow_records_base.py | 1 +
.../workflow_records_common.py | 4 +--
.../workflow_records_sqlite.py | 18 ++++++----
.../WorkflowLibrarySideNav.tsx | 1 +
.../frontend/web/src/services/api/schema.ts | 8 +++--
8 files changed, 59 insertions(+), 11 deletions(-)
create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 4e32307cad..b99d45cf96 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -105,6 +105,7 @@ async def list_workflows(
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"),
tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
+ is_recent: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
@@ -116,6 +117,7 @@ async def list_workflows(
query=query,
categories=categories,
tags=tags,
+ is_recent=is_recent,
)
for workflow in workflows.items:
workflows_with_thumbnails.append(
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index 0dfa9b94cc..3f31b81ad2 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -20,6 +20,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_14 import
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_15 import build_migration_15
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_16 import build_migration_16
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_17 import build_migration_17
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_18 import build_migration_18
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -57,6 +58,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_15())
migrator.register_migration(build_migration_16())
migrator.register_migration(build_migration_17())
+ migrator.register_migration(build_migration_18())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
new file mode 100644
index 0000000000..5d11b70ba7
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
@@ -0,0 +1,34 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration18Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._make_workflow_opened_at_nullable(cursor)
+
+ def _make_workflow_opened_at_nullable(self, cursor: sqlite3.Cursor) -> None:
+ """
+ - Makes the `opened_at` column on workflow library table nullable by adding a new column
+ and deprecating the old one.
+ """
+ # Rename existing column to deprecated
+ cursor.execute("ALTER TABLE workflow_library RENAME COLUMN opened_at TO opened_at_deprecated;")
+ # Add new nullable column
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN opened_at DATETIME;")
+
+
+def build_migration_18() -> Migration:
+ """
+ Build the migration from database version 17 to 18.
+
+ This migration does the following:
+ - Makes the `opened_at` column on workflow library table nullable.
+ """
+ migration_18 = Migration(
+ from_version=17,
+ to_version=18,
+ callback=Migration18Callback(),
+ )
+
+ return migration_18
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index aea13f55c8..f0589ffc94 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -46,6 +46,7 @@ class WorkflowRecordsStorageBase(ABC):
per_page: Optional[int],
query: Optional[str],
tags: Optional[list[str]],
+ is_recent: Optional[bool],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index 698a90cf91..da773a9e8b 100644
--- a/invokeai/app/services/workflow_records/workflow_records_common.py
+++ b/invokeai/app/services/workflow_records/workflow_records_common.py
@@ -1,6 +1,6 @@
import datetime
from enum import Enum
-from typing import Any, Union
+from typing import Any, Optional, Union
import semver
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, field_validator
@@ -98,7 +98,7 @@ class WorkflowRecordDTOBase(BaseModel):
name: str = Field(description="The name of the workflow.")
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.")
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.")
- opened_at: Union[datetime.datetime, str] = Field(description="The opened timestamp of the workflow.")
+ opened_at: Optional[Union[datetime.datetime, str]] = Field(description="The opened timestamp of the workflow.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 6e3cb1fdbd..6417d7ec20 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -118,6 +118,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
per_page: Optional[int] = None,
query: Optional[str] = None,
tags: Optional[list[str]] = None,
+ is_recent: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
# sanitize!
assert order_by in WorkflowRecordOrderBy
@@ -175,6 +176,11 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
conditions.append(tags_condition)
params.extend(tags_params)
+ if is_recent:
+ conditions.append("opened_at IS NOT NULL")
+ elif is_recent is False:
+ conditions.append("opened_at IS NULL")
+
# Ignore whitespace in the query
stripped_query = query.strip() if query else None
if stripped_query:
@@ -370,13 +376,13 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
bytes_ = path.read_bytes()
workflow_from_file = WorkflowValidator.validate_json(bytes_)
- assert workflow_from_file.id.startswith("default_"), (
- f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}'
- )
+ assert workflow_from_file.id.startswith(
+ "default_"
+ ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}'
- assert workflow_from_file.meta.category is WorkflowCategory.Default, (
- f"Invalid default workflow category: {workflow_from_file.meta.category}"
- )
+ assert (
+ workflow_from_file.meta.category is WorkflowCategory.Default
+ ), f"Invalid default workflow category: {workflow_from_file.meta.category}"
workflows_from_file.append(workflow_from_file)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index d4f47b8bf6..898ae9185b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -156,6 +156,7 @@ const recentWorkflowsQueryArg = {
per_page: 5,
order_by: 'opened_at',
direction: 'DESC',
+ is_recent: true,
} satisfies Parameters[0];
const RecentWorkflows = memo(() => {
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 60bd15dba1..469471f170 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -21157,7 +21157,7 @@ export type components = {
* Opened At
* @description The opened timestamp of the workflow.
*/
- opened_at: string;
+ opened_at: string | null;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
};
@@ -21187,7 +21187,7 @@ export type components = {
* Opened At
* @description The opened timestamp of the workflow.
*/
- opened_at: string;
+ opened_at: string | null;
/**
* Description
* @description The description of the workflow.
@@ -21238,7 +21238,7 @@ export type components = {
* Opened At
* @description The opened timestamp of the workflow.
*/
- opened_at: string;
+ opened_at: string | null;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
/**
@@ -24300,6 +24300,8 @@ export interface operations {
tags?: string[] | null;
/** @description The text to query by (matches name and description) */
query?: string | null;
+ /** @description Whether to include/exclude recent workflows */
+ is_recent?: boolean | null;
};
header?: never;
path?: never;
From afd894fd040e33b855f52060c15de55a5a40d02c Mon Sep 17 00:00:00 2001
From: Mary Hipp
Date: Tue, 11 Mar 2025 12:55:35 -0400
Subject: [PATCH 32/67] update recent workflows UI
---
invokeai/frontend/web/public/locales/en.json | 1 +
.../SaveWorkflow.tsx | 32 ---------
.../ViewWorkflow.tsx | 4 +-
.../WorkflowLibrarySideNav.tsx | 67 ++++++++++---------
.../workflow/WorkflowLibrary/WorkflowList.tsx | 5 +-
.../WorkflowLibrary/WorkflowListItem.tsx | 44 ++++++------
.../nodes/store/workflowLibrarySlice.ts | 7 ++
7 files changed, 74 insertions(+), 86 deletions(-)
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index ab8920d5ee..0956da39fe 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1726,6 +1726,7 @@
"loadWorkflow": "$t(common.load) Workflow",
"autoLayout": "Auto Layout",
"edit": "Edit",
+ "view": "View",
"download": "Download",
"copyShareLink": "Copy Share Link",
"copyShareLinkForWorkflow": "Copy Share Link for Workflow",
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx
deleted file mode 100644
index 679412b911..0000000000
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { IconButton, Tooltip } from '@invoke-ai/ui-library';
-import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
-import type { MouseEvent } from 'react';
-import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiFloppyDiskBold } from 'react-icons/pi';
-
-// needs to clone and save workflow to account without taking over editor
-export const SaveWorkflow = ({ workflowId }: { workflowId: string }) => {
- const loadWorkflow = useLoadWorkflow();
- const { t } = useTranslation();
-
- const handleClickSave = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- loadWorkflow.loadWithDialog(workflowId, 'view');
- },
- [loadWorkflow, workflowId]
- );
-
- return (
-
- }
- />
-
- );
-};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx
index c0d8ed57c1..8453763551 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx
@@ -18,11 +18,11 @@ export const ViewWorkflow = ({ workflowId }: { workflowId: string }) => {
);
return (
-
+
}
/>
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 898ae9185b..2d7fc419d8 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -8,8 +8,10 @@ import {
$workflowLibraryTagCategoriesOptions,
$workflowLibraryTagOptions,
selectWorkflowLibraryCategories,
+ selectWorkflowLibraryShowOpenedWorkflowsOnly,
selectWorkflowLibraryTags,
workflowLibraryCategoriesChanged,
+ workflowLibraryShowOpenedWorkflowsOnlyChanged,
workflowLibraryTagsReset,
workflowLibraryTagToggled,
} from 'features/nodes/store/workflowLibrarySlice';
@@ -24,27 +26,36 @@ import { useGetCountsByTagQuery, useListWorkflowsQuery } from 'services/api/endp
import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
- const { t } = useTranslation();
const dispatch = useDispatch();
const categories = useAppSelector(selectWorkflowLibraryCategories);
const categoryOptions = useStore($workflowLibraryCategoriesOptions);
const tags = useAppSelector(selectWorkflowLibraryTags);
+ const showOpenedWorkflowsOnly = useAppSelector(selectWorkflowLibraryShowOpenedWorkflowsOnly);
const tagCategoryOptions = useStore($workflowLibraryTagCategoriesOptions);
const selectYourWorkflows = useCallback(() => {
dispatch(workflowLibraryCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user']));
+ dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
}, [categoryOptions, dispatch]);
const selectPrivateWorkflows = useCallback(() => {
dispatch(workflowLibraryCategoriesChanged(['user']));
+ dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
}, [dispatch]);
const selectSharedWorkflows = useCallback(() => {
dispatch(workflowLibraryCategoriesChanged(['project']));
+ dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
}, [dispatch]);
const selectDefaultWorkflows = useCallback(() => {
dispatch(workflowLibraryCategoriesChanged(['default']));
+ dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
+ }, [dispatch]);
+
+ const selectRecentWorkflows = useCallback(() => {
+ dispatch(workflowLibraryCategoriesChanged(['default', 'user', 'project']));
+ dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(true));
}, [dispatch]);
const resetTags = useCallback(() => {
@@ -53,38 +64,35 @@ export const WorkflowLibrarySideNav = () => {
const isYourWorkflowsSelected = useMemo(() => {
if (categoryOptions.includes('project')) {
- return categories.includes('user') && categories.includes('project');
+ return categories.includes('user') && categories.includes('project') && !showOpenedWorkflowsOnly;
} else {
- return categories.includes('user');
+ return categories.includes('user') && !showOpenedWorkflowsOnly;
}
- }, [categoryOptions, categories]);
+ }, [categoryOptions, categories, showOpenedWorkflowsOnly]);
const isPrivateWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('user');
- }, [categories]);
+ return categories.length === 1 && categories.includes('user') && !showOpenedWorkflowsOnly;
+ }, [categories, showOpenedWorkflowsOnly]);
const isSharedWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('project');
- }, [categories]);
+ return categories.length === 1 && categories.includes('project') && !showOpenedWorkflowsOnly;
+ }, [categories, showOpenedWorkflowsOnly]);
const isDefaultWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('default');
- }, [categories]);
+ return categories.length === 1 && categories.includes('default') && !showOpenedWorkflowsOnly;
+ }, [categories, showOpenedWorkflowsOnly]);
+
+ const isRecentWorkflowsSelected = useMemo(() => {
+ return categories.length === 3 && showOpenedWorkflowsOnly;
+ }, [categories, showOpenedWorkflowsOnly]);
return (
-
- {t('workflows.recentlyOpened')}
-
-
-
-
+
-
- {t('workflows.yourWorkflows')}
-
+
{categoryOptions.includes('project') && (
{
size="sm"
onClick={selectPrivateWorkflows}
isSelected={isPrivateWorkflowsExclusivelySelected}
- >
- {t('workflows.private')}
-
+ >
}
onClick={selectSharedWorkflows}
isSelected={isSharedWorkflowsExclusivelySelected}
>
- {t('workflows.shared')}
@@ -113,9 +118,10 @@ export const WorkflowLibrarySideNav = () => {
)}
-
- {t('workflows.browseWorkflows')}
-
+
+ >
{tagCategoryOptions.map((tagCategory) => (
{
const { t } = useTranslation();
const { data, isLoading } = useListWorkflowsQuery(recentWorkflowsQueryArg);
- if (isLoading) {
+ if (isLoading || !data) {
return {t('common.loading')};
}
- if (!data) {
+ if (data.items.length === 0) {
return {t('workflows.noRecentWorkflows')};
}
@@ -212,7 +216,6 @@ const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => {
() =>
({
tags: allTags,
- categories: ['default'], // We only allow filtering by tag for default workflows
}) satisfies Parameters[0],
[allTags]
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 049bb0b8c8..84b8887e5b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -8,6 +8,7 @@ import {
selectWorkflowLibraryHasSearchTerm,
selectWorkflowLibraryOrderBy,
selectWorkflowLibrarySearchTerm,
+ selectWorkflowLibraryShowOpenedWorkflowsOnly,
selectWorkflowLibraryTags,
} from 'features/nodes/store/workflowLibrarySlice';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -26,6 +27,7 @@ const useInfiniteQueryAry = () => {
const direction = useAppSelector(selectWorkflowLibraryDirection);
const query = useAppSelector(selectWorkflowLibrarySearchTerm);
const tags = useAppSelector(selectWorkflowLibraryTags);
+ const showOpenedWorkflowsOnly = useAppSelector(selectWorkflowLibraryShowOpenedWorkflowsOnly);
const [debouncedQuery] = useDebounce(query, 500);
const queryArg = useMemo(() => {
@@ -37,8 +39,9 @@ const useInfiniteQueryAry = () => {
categories,
query: debouncedQuery,
tags: categories.length === 1 && categories.includes('default') ? tags : [],
+ is_recent: showOpenedWorkflowsOnly,
} satisfies Parameters[0];
- }, [orderBy, direction, categories, debouncedQuery, tags]);
+ }, [orderBy, direction, categories, debouncedQuery, tags, showOpenedWorkflowsOnly]);
return queryArg;
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
index c73e7f951b..91dd26c907 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
@@ -13,7 +13,6 @@ import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'
import { DeleteWorkflow } from './WorkflowLibraryListItemActions/DeleteWorkflow';
import { DownloadWorkflow } from './WorkflowLibraryListItemActions/DownloadWorkflow';
import { EditWorkflow } from './WorkflowLibraryListItemActions/EditWorkflow';
-import { SaveWorkflow } from './WorkflowLibraryListItemActions/SaveWorkflow';
import { ViewWorkflow } from './WorkflowLibraryListItemActions/ViewWorkflow';
const IMAGE_THUMBNAIL_SIZE = '80px';
@@ -81,19 +80,32 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
minWidth={IMAGE_THUMBNAIL_SIZE}
borderRadius="base"
/>
-
-
- {workflow.name}
+
+
+
+ {workflow.name}
- {isActive && (
-
- {t('workflows.opened')}
-
- )}
+ {isActive && (
+
+ {t('workflows.opened')}
+
+ )}
+
+
+ {workflow.description}
+
-
- {workflow.description}
-
+ {workflow.opened_at && (
+
+ {t('workflows.opened')}: {new Date(workflow.opened_at).toLocaleString()}
+
+ )}
@@ -114,13 +126,7 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
right={0}
bottom={0}
>
- {workflow.category === 'default' && (
- <>
- {/* need to consider what is useful here and which icons show that. idea is to "try it out"/"view" or "clone for your own changes" */}
-
-
- >
- )}
+ {workflow.category === 'default' && }
{workflow.category !== 'default' && (
<>
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index 7fad3b2c70..4b3ed772cc 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -11,6 +11,7 @@ type WorkflowLibraryState = {
direction: SQLiteDirection;
tags: string[];
categories: WorkflowCategory[];
+ showOpenedWorkflowsOnly: boolean;
};
const initialWorkflowLibraryState: WorkflowLibraryState = {
@@ -19,6 +20,7 @@ const initialWorkflowLibraryState: WorkflowLibraryState = {
direction: 'DESC',
tags: [],
categories: ['user'],
+ showOpenedWorkflowsOnly: false,
};
export const workflowLibrarySlice = createSlice({
@@ -38,6 +40,9 @@ export const workflowLibrarySlice = createSlice({
state.categories = action.payload;
state.searchTerm = '';
},
+ workflowLibraryShowOpenedWorkflowsOnlyChanged: (state, action: PayloadAction) => {
+ state.showOpenedWorkflowsOnly = action.payload;
+ },
workflowLibraryTagToggled: (state, action: PayloadAction) => {
const tag = action.payload;
const tags = state.tags;
@@ -58,6 +63,7 @@ export const {
workflowLibraryOrderByChanged,
workflowLibraryDirectionChanged,
workflowLibraryCategoriesChanged,
+ workflowLibraryShowOpenedWorkflowsOnlyChanged,
workflowLibraryTagToggled,
workflowLibraryTagsReset,
} = workflowLibrarySlice.actions;
@@ -82,6 +88,7 @@ export const selectWorkflowLibraryOrderBy = createWorkflowLibrarySelector(({ ord
export const selectWorkflowLibraryDirection = createWorkflowLibrarySelector(({ direction }) => direction);
export const selectWorkflowLibraryTags = createWorkflowLibrarySelector(({ tags }) => tags);
export const selectWorkflowLibraryCategories = createWorkflowLibrarySelector(({ categories }) => categories);
+export const selectWorkflowLibraryShowOpenedWorkflowsOnly = createWorkflowLibrarySelector(({ showOpenedWorkflowsOnly }) => showOpenedWorkflowsOnly);
export const DEFAULT_WORKFLOW_LIBRARY_CATEGORIES = ['user', 'default'] satisfies WorkflowCategory[];
export const $workflowLibraryCategoriesOptions = atom(DEFAULT_WORKFLOW_LIBRARY_CATEGORIES);
From 87438bcad7d015ee8c54e8a6c6ad44622ee8ceb1 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 07:07:58 +1000
Subject: [PATCH 33/67] fix(ui): rebase broke things
---
invokeai/frontend/web/public/locales/en.json | 1 +
.../WorkflowLibrarySideNav.tsx | 31 +++++++++++++------
2 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 0956da39fe..e23de0b159 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1692,6 +1692,7 @@
"filterByTags": "Filter by Tags",
"yourWorkflows": "Your Workflows",
"recentlyOpened": "Recently Opened",
+ "noRecentWorkflows": "No Recent Workflows",
"private": "Private",
"shared": "Shared",
"browseWorkflows": "Browse Workflows",
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 2d7fc419d8..d5d5a38210 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -26,6 +26,7 @@ import { useGetCountsByTagQuery, useListWorkflowsQuery } from 'services/api/endp
import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
+ const { t } = useTranslation();
const dispatch = useDispatch();
const categories = useAppSelector(selectWorkflowLibraryCategories);
const categoryOptions = useStore($workflowLibraryCategoriesOptions);
@@ -89,10 +90,17 @@ export const WorkflowLibrarySideNav = () => {
return (
-
+
+ {t('workflows.recentlyOpened')}
+
+
+
+
-
+
+ {t('workflows.yourWorkflows')}
+
{categoryOptions.includes('project') && (
{
size="sm"
onClick={selectPrivateWorkflows}
isSelected={isPrivateWorkflowsExclusivelySelected}
- >
+ >
+ {t('workflows.private')}
+
}
onClick={selectSharedWorkflows}
isSelected={isSharedWorkflowsExclusivelySelected}
>
+ {t('workflows.shared')}
@@ -118,10 +129,9 @@ export const WorkflowLibrarySideNav = () => {
)}
-
+
+ {t('workflows.browseWorkflows')}
+
+ >
+ {t('workflows.deselectAll')}
+
{tagCategoryOptions.map((tagCategory) => (
{
}
if (data.items.length === 0) {
- return {t('workflows.noRecentWorkflows')};
+ return {t('workflows.noRecentWorkflows')};
}
return (
@@ -216,6 +228,7 @@ const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => {
() =>
({
tags: allTags,
+ categories: ['default'], // We only allow filtering by tag for default workflows
}) satisfies Parameters[0],
[allTags]
);
From dc3f1184b23408708678c9e86c0d52e76217a8eb Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 07:10:49 +1000
Subject: [PATCH 34/67] fix(ui): other stuff borked by rebase
---
.../WorkflowLibrarySideNav.tsx | 35 +------------------
1 file changed, 1 insertion(+), 34 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index d5d5a38210..4b7f81f2f9 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -22,7 +22,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiUsersBold } from 'react-icons/pi';
import { useDispatch } from 'react-redux';
-import { useGetCountsByTagQuery, useListWorkflowsQuery } from 'services/api/endpoints/workflows';
+import { useGetCountsByTagQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
@@ -93,9 +93,6 @@ export const WorkflowLibrarySideNav = () => {
{t('workflows.recentlyOpened')}
-
-
-
@@ -167,36 +164,6 @@ export const WorkflowLibrarySideNav = () => {
);
};
-const recentWorkflowsQueryArg = {
- page: 0,
- per_page: 5,
- order_by: 'opened_at',
- direction: 'DESC',
- is_recent: true,
-} satisfies Parameters[0];
-
-const RecentWorkflows = memo(() => {
- const { t } = useTranslation();
- const { data, isLoading } = useListWorkflowsQuery(recentWorkflowsQueryArg);
-
- if (isLoading || !data) {
- return {t('common.loading')};
- }
-
- if (data.items.length === 0) {
- return {t('workflows.noRecentWorkflows')};
- }
-
- return (
- <>
- {data.items.map((workflow) => {
- return ;
- })}
- >
- );
-});
-RecentWorkflows.displayName = 'RecentWorkflows';
-
const useCountForIndividualTag = (tag: string) => {
const allTags = useStore($workflowLibraryTagOptions);
const queryArg = useMemo(
From 5b84d4593297a60276364d5af03b14f52cbae536 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 07:19:16 +1000
Subject: [PATCH 35/67] perf(ui): memoize workflow library components
---
.../sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx | 7 ++++---
.../workflow/WorkflowLibrary/WorkflowListItem.tsx | 7 ++++---
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 84b8887e5b..99a1746072 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -39,7 +39,7 @@ const useInfiniteQueryAry = () => {
categories,
query: debouncedQuery,
tags: categories.length === 1 && categories.includes('default') ? tags : [],
- is_recent: showOpenedWorkflowsOnly,
+ is_recent: showOpenedWorkflowsOnly || undefined,
} satisfies Parameters[0];
}, [orderBy, direction, categories, debouncedQuery, tags, showOpenedWorkflowsOnly]);
@@ -55,7 +55,7 @@ const queryOptions = {
},
} satisfies Parameters[1];
-export const WorkflowList = () => {
+export const WorkflowList = memo(() => {
const queryArg = useInfiniteQueryAry();
const { items, isFetching, isLoading, fetchNextPage, hasNextPage } = useListWorkflowsInfiniteInfiniteQuery(
queryArg,
@@ -82,7 +82,8 @@ export const WorkflowList = () => {
isFetching={isFetching}
/>
);
-};
+});
+WorkflowList.displayName = 'WorkflowList';
const NoItems = memo(() => {
const { t } = useTranslation();
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
index 91dd26c907..0f4f87ee69 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
@@ -5,7 +5,7 @@ import { ShareWorkflowButton } from 'features/nodes/components/sidePanel/workflo
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
-import { useCallback, useMemo } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold, PiUsersBold } from 'react-icons/pi';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
@@ -29,7 +29,7 @@ const sx: SystemStyleObject = {
},
};
-export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
+export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
const { t } = useTranslation();
const workflowId = useAppSelector(selectWorkflowId);
@@ -139,4 +139,5 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
);
-};
+});
+WorkflowListItem.displayName = 'WorkflowListItem';
From 1e388e9ca4fb36171a655c27829da4199e63b856 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 07:39:02 +1000
Subject: [PATCH 36/67] tweak(ui): align new and upload workflow buttons
---
.../workflowLibrary/components/NewWorkflowButton.tsx | 2 +-
.../workflowLibrary/components/UploadWorkflowButton.tsx | 8 +++++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx
index 366dd97d4f..7ad7d008ca 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowButton.tsx
@@ -20,7 +20,7 @@ export const NewWorkflowButton = memo(() => {
);
return (
- }>
+ } justifyContent="flex-start">
{t('nodes.newWorkflow')}
);
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx
index f319bf0320..d575c6c847 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx
@@ -38,7 +38,13 @@ export const UploadWorkflowButton = memo(() => {
});
return (
<>
- } {...getRootProps()} pointerEvents="auto" variant="ghost">
+ }
+ {...getRootProps()}
+ pointerEvents="auto"
+ variant="ghost"
+ justifyContent="flex-start"
+ >
{t('workflows.uploadWorkflow')}
From 918e9c8ccc84f1ad6e316d875ae57cbefb91f21b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 07:54:32 +1000
Subject: [PATCH 37/67] feat(app): drop and recreate index on opened_at
Not sure if this is strictly required but doing it anyways.
---
.../migrations/migration_18.py | 21 +++++++++++++++----
1 file changed, 17 insertions(+), 4 deletions(-)
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
index 5d11b70ba7..426a3c4165 100644
--- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
@@ -9,13 +9,22 @@ class Migration18Callback:
def _make_workflow_opened_at_nullable(self, cursor: sqlite3.Cursor) -> None:
"""
- - Makes the `opened_at` column on workflow library table nullable by adding a new column
- and deprecating the old one.
+ Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
+ - Renaming the existing column to `opened_at_deprecated`
+ - Drop the existing `idx_workflow_library_opened_at` index
+ - Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
+ - Recreate the `idx_workflow_library_opened_at` index on the `opened_at` column
"""
# Rename existing column to deprecated
cursor.execute("ALTER TABLE workflow_library RENAME COLUMN opened_at TO opened_at_deprecated;")
- # Add new nullable column
+ # For index renaming in SQLite, we need to drop and recreate
+ cursor.execute("DROP INDEX IF EXISTS idx_workflow_library_opened_at;")
+ # Add new nullable column - all values will be NULL - no migration of data needed
cursor.execute("ALTER TABLE workflow_library ADD COLUMN opened_at DATETIME;")
+ # Create new index on the new column
+ cursor.execute(
+ "CREATE INDEX idx_workflow_library_opened_at ON workflow_library(opened_at);",
+ )
def build_migration_18() -> Migration:
@@ -23,7 +32,11 @@ def build_migration_18() -> Migration:
Build the migration from database version 17 to 18.
This migration does the following:
- - Makes the `opened_at` column on workflow library table nullable.
+ - Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
+ - Renaming the existing column to `opened_at_deprecated`
+ - Drop the existing `idx_workflow_library_opened_at` index
+ - Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
+ - Recreate the `idx_workflow_library_opened_at` index on the `opened_at` column
"""
migration_18 = Migration(
from_version=17,
From 73a0d2c06ce7336a52588fac5780e7806c7689df Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 08:01:39 +1000
Subject: [PATCH 38/67] fix(ui): memo WorkflowLibraryModal
---
.../workflow/WorkflowLibrary/WorkflowLibraryModal.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx
index 084f862992..802d990d60 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx
@@ -9,13 +9,14 @@ import {
ModalOverlay,
} from '@invoke-ai/ui-library';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
+import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { WorkflowLibrarySideNav } from './WorkflowLibrarySideNav';
import { WorkflowLibraryTopNav } from './WorkflowLibraryTopNav';
import { WorkflowList } from './WorkflowList';
-export const WorkflowLibraryModal = () => {
+export const WorkflowLibraryModal = memo(() => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
return (
@@ -42,4 +43,6 @@ export const WorkflowLibraryModal = () => {
);
-};
+});
+
+WorkflowLibraryModal.displayName = 'WorkflowLibraryModal';
From bad5023238a4addfc64c3f985f0b99b15c6e4278 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 08:07:14 +1000
Subject: [PATCH 39/67] tweak(app): 'is_recent' -> 'has_been_opened'
---
invokeai/app/api/routers/workflows.py | 4 ++--
.../workflow_records/workflow_records_base.py | 2 +-
.../workflow_records_sqlite.py | 18 +++++++++---------
3 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index b99d45cf96..e4960d8c18 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -105,7 +105,7 @@ async def list_workflows(
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"),
tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
- is_recent: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
@@ -117,7 +117,7 @@ async def list_workflows(
query=query,
categories=categories,
tags=tags,
- is_recent=is_recent,
+ has_been_opened=has_been_opened,
)
for workflow in workflows.items:
workflows_with_thumbnails.append(
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index f0589ffc94..a1d497da55 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -46,7 +46,7 @@ class WorkflowRecordsStorageBase(ABC):
per_page: Optional[int],
query: Optional[str],
tags: Optional[list[str]],
- is_recent: Optional[bool],
+ has_been_opened: Optional[bool],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 6417d7ec20..25427b8116 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -118,7 +118,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
per_page: Optional[int] = None,
query: Optional[str] = None,
tags: Optional[list[str]] = None,
- is_recent: Optional[bool] = None,
+ has_been_opened: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
# sanitize!
assert order_by in WorkflowRecordOrderBy
@@ -176,9 +176,9 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
conditions.append(tags_condition)
params.extend(tags_params)
- if is_recent:
+ if has_been_opened:
conditions.append("opened_at IS NOT NULL")
- elif is_recent is False:
+ elif has_been_opened is False:
conditions.append("opened_at IS NULL")
# Ignore whitespace in the query
@@ -376,13 +376,13 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
bytes_ = path.read_bytes()
workflow_from_file = WorkflowValidator.validate_json(bytes_)
- assert workflow_from_file.id.startswith(
- "default_"
- ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}'
+ assert workflow_from_file.id.startswith("default_"), (
+ f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}'
+ )
- assert (
- workflow_from_file.meta.category is WorkflowCategory.Default
- ), f"Invalid default workflow category: {workflow_from_file.meta.category}"
+ assert workflow_from_file.meta.category is WorkflowCategory.Default, (
+ f"Invalid default workflow category: {workflow_from_file.meta.category}"
+ )
workflows_from_file.append(workflow_from_file)
From 07313e429dce6b86a87052d389dc801d4af21373 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 08:07:29 +1000
Subject: [PATCH 40/67] chore(ui): typegen
---
invokeai/frontend/web/src/services/api/schema.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 469471f170..f78de09951 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -24301,7 +24301,7 @@ export interface operations {
/** @description The text to query by (matches name and description) */
query?: string | null;
/** @description Whether to include/exclude recent workflows */
- is_recent?: boolean | null;
+ has_been_opened?: boolean | null;
};
header?: never;
path?: never;
From aa71d0c8174f6f01318daa020bd906daa80b3689 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 08:08:34 +1000
Subject: [PATCH 41/67] tweak(ui): 'is_recent' -> 'has_been_opened'
---
.../sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 99a1746072..30050c5b9d 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -39,7 +39,7 @@ const useInfiniteQueryAry = () => {
categories,
query: debouncedQuery,
tags: categories.length === 1 && categories.includes('default') ? tags : [],
- is_recent: showOpenedWorkflowsOnly || undefined,
+ has_been_opened: showOpenedWorkflowsOnly || undefined,
} satisfies Parameters[0];
}, [orderBy, direction, categories, debouncedQuery, tags, showOpenedWorkflowsOnly]);
From 54e781d5bba76c81a8a755ca9daed2593c3c80c6 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 08:09:22 +1000
Subject: [PATCH 42/67] tidy(app): remove unused method in workflow records
service
---
.../workflow_records_sqlite.py | 54 -------------------
1 file changed, 54 deletions(-)
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 25427b8116..27c73f62c0 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -282,60 +282,6 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
return result
- def get_tag_counts_with_filter(
- self,
- tags_to_count: list[str],
- selected_tags: Optional[list[str]] = None,
- categories: Optional[list[WorkflowCategory]] = None,
- ) -> dict[str, int]:
- if not tags_to_count:
- return {}
-
- cursor = self._conn.cursor()
- result: dict[str, int] = {}
- selected_tags = selected_tags or []
-
- # Base conditions for categories and selected tags
- base_conditions: list[str] = []
- base_params: list[str | int] = []
-
- # Add category conditions
- if categories:
- assert all(c in WorkflowCategory for c in categories)
- placeholders = ", ".join("?" for _ in categories)
- base_conditions.append(f"category IN ({placeholders})")
- base_params.extend([category.value for category in categories])
-
- # Add selected tags conditions (AND logic)
- for tag in selected_tags:
- base_conditions.append("tags LIKE ?")
- base_params.append(f"%{tag.strip()}%")
-
- # For each tag to count, run a separate query
- for tag in tags_to_count:
- # Start with the base conditions
- conditions = base_conditions.copy()
- params = base_params.copy()
-
- # Add this specific tag condition
- conditions.append("tags LIKE ?")
- params.append(f"%{tag.strip()}%")
-
- # Construct the full query
- stmt = """--sql
- SELECT COUNT(*)
- FROM workflow_library
- """
-
- if conditions:
- stmt += " WHERE " + " AND ".join(conditions)
-
- cursor.execute(stmt, params)
- count = cursor.fetchone()[0]
- result[tag] = count
-
- return result
-
def update_opened_at(self, workflow_id: str) -> None:
try:
cursor = self._conn.cursor()
From 0a836d6fc1af280932d8eec38f41ef5aacd01e6f Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 09:57:38 +1000
Subject: [PATCH 43/67] feat(app): add method and route to get workflow library
counts by category
---
invokeai/app/api/routers/workflows.py | 19 ++++++-
.../workflow_records/workflow_records_base.py | 10 ++++
.../workflow_records_sqlite.py | 54 +++++++++++++++++++
3 files changed, 81 insertions(+), 2 deletions(-)
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index e4960d8c18..5a37a75dcf 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -227,10 +227,25 @@ async def get_workflow_thumbnail(
async def get_counts_by_tag(
tags: list[str] = Query(description="The tags to get counts for"),
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
) -> dict[str, int]:
- """Gets tag counts with a filter"""
+ """Counts workflows by tag"""
- return ApiDependencies.invoker.services.workflow_records.counts_by_tag(tags=tags, categories=categories)
+ return ApiDependencies.invoker.services.workflow_records.counts_by_tag(
+ tags=tags, categories=categories, has_been_opened=has_been_opened
+ )
+
+
+@workflows_router.get("/counts_by_category", operation_id="counts_by_category")
+async def counts_by_category(
+ categories: list[WorkflowCategory] = Query(description="The categories to include"),
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+) -> dict[str, int]:
+ """Counts workflows by category"""
+
+ return ApiDependencies.invoker.services.workflow_records.counts_by_category(
+ categories=categories, has_been_opened=has_been_opened
+ )
@workflows_router.put(
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index a1d497da55..5bf42ed253 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -51,11 +51,21 @@ class WorkflowRecordsStorageBase(ABC):
"""Gets many workflows."""
pass
+ @abstractmethod
+ def counts_by_category(
+ self,
+ categories: list[WorkflowCategory],
+ has_been_opened: Optional[bool] = None,
+ ) -> dict[str, int]:
+ """Gets a dictionary of counts for each of the provided categories."""
+ pass
+
@abstractmethod
def counts_by_tag(
self,
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
+ has_been_opened: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided tags."""
pass
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 27c73f62c0..ad67837a06 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -240,6 +240,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self,
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
+ has_been_opened: Optional[bool] = None,
) -> dict[str, int]:
if not tags:
return {}
@@ -257,6 +258,11 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
base_conditions.append(f"category IN ({placeholders})")
base_params.extend([category.value for category in categories])
+ if has_been_opened:
+ base_conditions.append("opened_at IS NOT NULL")
+ elif has_been_opened is False:
+ base_conditions.append("opened_at IS NULL")
+
# For each tag to count, run a separate query
for tag in tags:
# Start with the base conditions
@@ -282,6 +288,54 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
return result
+ def counts_by_category(
+ self,
+ categories: list[WorkflowCategory],
+ has_been_opened: Optional[bool] = None,
+ ) -> dict[str, int]:
+ cursor = self._conn.cursor()
+ result: dict[str, int] = {}
+ # Base conditions for categories
+ base_conditions: list[str] = []
+ base_params: list[str | int] = []
+
+ # Add category conditions
+ if categories:
+ assert all(c in WorkflowCategory for c in categories)
+ placeholders = ", ".join("?" for _ in categories)
+ base_conditions.append(f"category IN ({placeholders})")
+ base_params.extend([category.value for category in categories])
+
+ if has_been_opened:
+ base_conditions.append("opened_at IS NOT NULL")
+ elif has_been_opened is False:
+ base_conditions.append("opened_at IS NULL")
+
+ # For each category to count, run a separate query
+ for category in categories:
+ # Start with the base conditions
+ conditions = base_conditions.copy()
+ params = base_params.copy()
+
+ # Add this specific category condition
+ conditions.append("category = ?")
+ params.append(category.value)
+
+ # Construct the full query
+ stmt = """--sql
+ SELECT COUNT(*)
+ FROM workflow_library
+ """
+
+ if conditions:
+ stmt += " WHERE " + " AND ".join(conditions)
+
+ cursor.execute(stmt, params)
+ count = cursor.fetchone()[0]
+ result[category.value] = count
+
+ return result
+
def update_opened_at(self, workflow_id: str) -> None:
try:
cursor = self._conn.cursor()
From 7f14cee17e54c234d88df50250b777ee82a7bea8 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 09:57:47 +1000
Subject: [PATCH 44/67] chore(ui): typegen
---
.../frontend/web/src/services/api/schema.ts | 60 ++++++++++++++++++-
1 file changed, 59 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index f78de09951..eff0b6573d 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -1447,7 +1447,7 @@ export type paths = {
};
/**
* Get Counts By Tag
- * @description Gets tag counts with a filter
+ * @description Counts workflows by tag
*/
get: operations["get_counts_by_tag"];
put?: never;
@@ -1458,6 +1458,26 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/api/v1/workflows/counts_by_category": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Counts By Category
+ * @description Counts workflows by category
+ */
+ get: operations["counts_by_category"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/workflows/i/{workflow_id}/opened_at": {
parameters: {
query?: never;
@@ -24483,6 +24503,44 @@ export interface operations {
tags: string[];
/** @description The categories to include */
categories?: components["schemas"]["WorkflowCategory"][] | null;
+ /** @description Whether to include/exclude recent workflows */
+ has_been_opened?: boolean | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: number;
+ };
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ counts_by_category: {
+ parameters: {
+ query: {
+ /** @description The categories to include */
+ categories: components["schemas"]["WorkflowCategory"][];
+ /** @description Whether to include/exclude recent workflows */
+ has_been_opened?: boolean | null;
};
header?: never;
path?: never;
From 97593f95f694c90659226600fc6c5a6bfbe0775d Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 09:59:44 +1000
Subject: [PATCH 45/67] feat(ui): on first load, if the selected library view
has no workflows, switch to the first view that has workflows
---
.../WorkflowLibrary/WorkflowLibraryModal.tsx | 110 +++++++++-
.../WorkflowLibrarySideNav.tsx | 194 ++++++------------
.../workflow/WorkflowLibrary/WorkflowList.tsx | 52 +++--
.../nodes/store/workflowLibrarySlice.ts | 39 ++--
.../src/services/api/endpoints/workflows.ts | 21 +-
.../frontend/web/src/services/api/index.ts | 3 +-
6 files changed, 245 insertions(+), 174 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx
index 802d990d60..18dc640e90 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx
@@ -8,9 +8,18 @@ import {
ModalHeader,
ModalOverlay,
} from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
-import { memo } from 'react';
+import {
+ $workflowLibraryCategoriesOptions,
+ selectWorkflowLibraryView,
+ workflowLibraryViewChanged,
+} from 'features/nodes/store/workflowLibrarySlice';
+import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useGetCountsByCategoryQuery } from 'services/api/endpoints/workflows';
import { WorkflowLibrarySideNav } from './WorkflowLibrarySideNav';
import { WorkflowLibraryTopNav } from './WorkflowLibraryTopNav';
@@ -19,6 +28,7 @@ import { WorkflowList } from './WorkflowList';
export const WorkflowLibraryModal = memo(() => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
+ const didSync = useSyncInitialWorkflowLibraryCategories();
return (
@@ -31,14 +41,17 @@ export const WorkflowLibraryModal = memo(() => {
{t('workflows.workflowLibrary')}
-
-
-
-
-
-
+ {didSync && (
+
+
+
+
+
+
+
-
+ )}
+ {!didSync && }
@@ -46,3 +59,84 @@ export const WorkflowLibraryModal = memo(() => {
});
WorkflowLibraryModal.displayName = 'WorkflowLibraryModal';
+
+/**
+ * On first app load, if the user's selected view has no workflows, switches to the next available view.
+ */
+const useSyncInitialWorkflowLibraryCategories = () => {
+ const dispatch = useAppDispatch();
+ const view = useAppSelector(selectWorkflowLibraryView);
+ const categoryOptions = useStore($workflowLibraryCategoriesOptions);
+ const [didSync, setDidSync] = useState(false);
+ const recentWorkflowsCountQueryArg = useMemo(
+ () =>
+ ({
+ categories: ['user', 'project', 'default'],
+ has_been_opened: true,
+ }) satisfies Parameters[0],
+ []
+ );
+ const yourWorkflowsCountQueryArg = useMemo(
+ () =>
+ ({
+ categories: ['user', 'project'],
+ }) satisfies Parameters[0],
+ []
+ );
+ const queryOptions = useMemo(
+ () =>
+ ({
+ selectFromResult: ({ data, isLoading }) => {
+ if (!data) {
+ return { count: 0, isLoading: true };
+ }
+ return {
+ count: Object.values(data).reduce((acc, count) => acc + count, 0),
+ isLoading,
+ };
+ },
+ }) satisfies Parameters[1],
+ []
+ );
+
+ const { count: recentWorkflowsCount, isLoading: isLoadingRecentWorkflowsCount } = useGetCountsByCategoryQuery(
+ recentWorkflowsCountQueryArg,
+ queryOptions
+ );
+ const { count: yourWorkflowsCount, isLoading: isLoadingYourWorkflowsCount } = useGetCountsByCategoryQuery(
+ yourWorkflowsCountQueryArg,
+ queryOptions
+ );
+
+ useEffect(() => {
+ if (didSync || isLoadingRecentWorkflowsCount || isLoadingYourWorkflowsCount) {
+ return;
+ }
+ // If the user's selected view has no workflows, switch to the next available view
+ if (recentWorkflowsCount === 0 && view === 'recent') {
+ if (yourWorkflowsCount > 0) {
+ dispatch(workflowLibraryViewChanged('yours'));
+ } else {
+ dispatch(workflowLibraryViewChanged('defaults'));
+ }
+ } else if (yourWorkflowsCount === 0 && (view === 'yours' || view === 'shared' || view === 'private')) {
+ if (recentWorkflowsCount > 0) {
+ dispatch(workflowLibraryViewChanged('recent'));
+ } else {
+ dispatch(workflowLibraryViewChanged('defaults'));
+ }
+ }
+ setDidSync(true);
+ }, [
+ categoryOptions,
+ didSync,
+ dispatch,
+ isLoadingRecentWorkflowsCount,
+ isLoadingYourWorkflowsCount,
+ recentWorkflowsCount,
+ view,
+ yourWorkflowsCount,
+ ]);
+
+ return didSync;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 4b7f81f2f9..af505a1872 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -2,18 +2,16 @@ import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library';
import { Button, Checkbox, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import type { WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
+import type { WorkflowLibraryView, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import {
$workflowLibraryCategoriesOptions,
$workflowLibraryTagCategoriesOptions,
$workflowLibraryTagOptions,
- selectWorkflowLibraryCategories,
- selectWorkflowLibraryShowOpenedWorkflowsOnly,
- selectWorkflowLibraryTags,
- workflowLibraryCategoriesChanged,
- workflowLibraryShowOpenedWorkflowsOnlyChanged,
+ selectWorkflowLibrarySelectedTags,
+ selectWorkflowLibraryView,
workflowLibraryTagsReset,
workflowLibraryTagToggled,
+ workflowLibraryViewChanged,
} from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
@@ -27,135 +25,33 @@ import type { S } from 'services/api/types';
export const WorkflowLibrarySideNav = () => {
const { t } = useTranslation();
- const dispatch = useDispatch();
- const categories = useAppSelector(selectWorkflowLibraryCategories);
const categoryOptions = useStore($workflowLibraryCategoriesOptions);
- const tags = useAppSelector(selectWorkflowLibraryTags);
- const showOpenedWorkflowsOnly = useAppSelector(selectWorkflowLibraryShowOpenedWorkflowsOnly);
- const tagCategoryOptions = useStore($workflowLibraryTagCategoriesOptions);
-
- const selectYourWorkflows = useCallback(() => {
- dispatch(workflowLibraryCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user']));
- dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
- }, [categoryOptions, dispatch]);
-
- const selectPrivateWorkflows = useCallback(() => {
- dispatch(workflowLibraryCategoriesChanged(['user']));
- dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
- }, [dispatch]);
-
- const selectSharedWorkflows = useCallback(() => {
- dispatch(workflowLibraryCategoriesChanged(['project']));
- dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
- }, [dispatch]);
-
- const selectDefaultWorkflows = useCallback(() => {
- dispatch(workflowLibraryCategoriesChanged(['default']));
- dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(false));
- }, [dispatch]);
-
- const selectRecentWorkflows = useCallback(() => {
- dispatch(workflowLibraryCategoriesChanged(['default', 'user', 'project']));
- dispatch(workflowLibraryShowOpenedWorkflowsOnlyChanged(true));
- }, [dispatch]);
-
- const resetTags = useCallback(() => {
- dispatch(workflowLibraryTagsReset());
- }, [dispatch]);
-
- const isYourWorkflowsSelected = useMemo(() => {
- if (categoryOptions.includes('project')) {
- return categories.includes('user') && categories.includes('project') && !showOpenedWorkflowsOnly;
- } else {
- return categories.includes('user') && !showOpenedWorkflowsOnly;
- }
- }, [categoryOptions, categories, showOpenedWorkflowsOnly]);
-
- const isPrivateWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('user') && !showOpenedWorkflowsOnly;
- }, [categories, showOpenedWorkflowsOnly]);
-
- const isSharedWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('project') && !showOpenedWorkflowsOnly;
- }, [categories, showOpenedWorkflowsOnly]);
-
- const isDefaultWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('default') && !showOpenedWorkflowsOnly;
- }, [categories, showOpenedWorkflowsOnly]);
-
- const isRecentWorkflowsSelected = useMemo(() => {
- return categories.length === 3 && showOpenedWorkflowsOnly;
- }, [categories, showOpenedWorkflowsOnly]);
+ const view = useAppSelector(selectWorkflowLibraryView);
return (
-
- {t('workflows.recentlyOpened')}
-
+ {t('workflows.recentlyOpened')}
-
- {t('workflows.yourWorkflows')}
-
+ {t('workflows.yourWorkflows')}
{categoryOptions.includes('project') && (
-
+
-
+
{t('workflows.private')}
-
- }
- onClick={selectSharedWorkflows}
- isSelected={isSharedWorkflowsExclusivelySelected}
- >
+
+ } view="shared">
{t('workflows.shared')}
-
+
)}
-
- {t('workflows.browseWorkflows')}
-
-
-
- }
- h={8}
- >
- {t('workflows.deselectAll')}
-
-
- {tagCategoryOptions.map((tagCategory) => (
-
- ))}
-
-
-
+ {t('workflows.browseWorkflows')}
+
@@ -164,6 +60,45 @@ export const WorkflowLibrarySideNav = () => {
);
};
+const DefaultsViewCheckboxesCollapsible = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+ const tags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const tagCategoryOptions = useStore($workflowLibraryTagCategoriesOptions);
+ const view = useAppSelector(selectWorkflowLibraryView);
+
+ const resetTags = useCallback(() => {
+ dispatch(workflowLibraryTagsReset());
+ }, [dispatch]);
+
+ return (
+
+
+ }
+ h={8}
+ >
+ {t('workflows.deselectAll')}
+
+
+ {tagCategoryOptions.map((tagCategory) => (
+
+ ))}
+
+
+
+ );
+});
+DefaultsViewCheckboxesCollapsible.displayName = 'DefaultsViewCheckboxes';
+
const useCountForIndividualTag = (tag: string) => {
const allTags = useStore($workflowLibraryTagOptions);
const queryArg = useMemo(
@@ -244,7 +179,13 @@ const RecentWorkflowButton = memo(({ workflow }: { workflow: S['WorkflowRecordLi
});
RecentWorkflowButton.displayName = 'RecentWorkflowButton';
-const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected: boolean }) => {
+const WorkflowLibraryViewButton = memo(({ view, ...rest }: ButtonProps & { view: WorkflowLibraryView }) => {
+ const dispatch = useDispatch();
+ const selectedView = useAppSelector(selectWorkflowLibraryView);
+ const onClick = useCallback(() => {
+ dispatch(workflowLibraryViewChanged(view));
+ }, [dispatch, view]);
+
return (
);
});
-CategoryButton.displayName = 'NavButton';
+WorkflowLibraryViewButton.displayName = 'NavButton';
-const TagCategory = memo(({ tagCategory, isDisabled }: { tagCategory: WorkflowTagCategory; isDisabled: boolean }) => {
+const TagCategory = memo(({ tagCategory }: { tagCategory: WorkflowTagCategory }) => {
const { t } = useTranslation();
const count = useCountForTagCategory(tagCategory);
@@ -270,12 +212,12 @@ const TagCategory = memo(({ tagCategory, isDisabled }: { tagCategory: WorkflowTa
return (
-
+
{t(tagCategory.categoryTKey)}
{tagCategory.tags.map((tag) => (
-
+
))}
@@ -285,7 +227,7 @@ TagCategory.displayName = 'TagCategory';
const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) => {
const dispatch = useAppDispatch();
- const selectedTags = useAppSelector(selectWorkflowLibraryTags);
+ const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
const isChecked = selectedTags.includes(tag);
const count = useCountForIndividualTag(tag);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 30050c5b9d..17e53fa98d 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -2,33 +2,59 @@ import { Button, Flex, Grid, GridItem, Spacer, Spinner } from '@invoke-ai/ui-lib
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+import type { WorkflowLibraryView } from 'features/nodes/store/workflowLibrarySlice';
import {
- selectWorkflowLibraryCategories,
selectWorkflowLibraryDirection,
selectWorkflowLibraryHasSearchTerm,
selectWorkflowLibraryOrderBy,
selectWorkflowLibrarySearchTerm,
- selectWorkflowLibraryShowOpenedWorkflowsOnly,
- selectWorkflowLibraryTags,
+ selectWorkflowLibrarySelectedTags,
+ selectWorkflowLibraryView,
} from 'features/nodes/store/workflowLibrarySlice';
+import type { WorkflowCategory } from 'features/nodes/types/workflow';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
+import type { Equals } from 'tsafe';
+import { assert } from 'tsafe';
import { useDebounce } from 'use-debounce';
import { WorkflowListItem } from './WorkflowListItem';
const PER_PAGE = 30;
+const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => {
+ switch (view) {
+ case 'defaults':
+ return ['default'];
+ case 'recent':
+ return ['user', 'project', 'default'];
+ case 'yours':
+ return ['user', 'project'];
+ case 'private':
+ return ['user'];
+ case 'shared':
+ return ['project'];
+ default:
+ assert>(false);
+ }
+};
+
+const getHasBeenOpened = (view: WorkflowLibraryView): boolean | undefined => {
+ if (view === 'recent') {
+ return true;
+ }
+ return undefined;
+};
+
const useInfiniteQueryAry = () => {
- const categories = useAppSelector(selectWorkflowLibraryCategories);
const orderBy = useAppSelector(selectWorkflowLibraryOrderBy);
const direction = useAppSelector(selectWorkflowLibraryDirection);
- const query = useAppSelector(selectWorkflowLibrarySearchTerm);
- const tags = useAppSelector(selectWorkflowLibraryTags);
- const showOpenedWorkflowsOnly = useAppSelector(selectWorkflowLibraryShowOpenedWorkflowsOnly);
- const [debouncedQuery] = useDebounce(query, 500);
+ const searchTerm = useAppSelector(selectWorkflowLibrarySearchTerm);
+ const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const view = useAppSelector(selectWorkflowLibraryView);
+ const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
const queryArg = useMemo(() => {
return {
@@ -36,12 +62,12 @@ const useInfiniteQueryAry = () => {
per_page: PER_PAGE,
order_by: orderBy ?? 'opened_at',
direction,
- categories,
- query: debouncedQuery,
- tags: categories.length === 1 && categories.includes('default') ? tags : [],
- has_been_opened: showOpenedWorkflowsOnly || undefined,
+ categories: getCategories(view),
+ query: debouncedSearchTerm,
+ tags: view === 'defaults' ? selectedTags : [],
+ has_been_opened: getHasBeenOpened(view),
} satisfies Parameters[0];
- }, [orderBy, direction, categories, debouncedQuery, tags, showOpenedWorkflowsOnly]);
+ }, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);
return queryArg;
};
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index 4b3ed772cc..12d3af74c8 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -5,22 +5,22 @@ import type { WorkflowCategory } from 'features/nodes/types/workflow';
import { atom, computed } from 'nanostores';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
+export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults';
+
type WorkflowLibraryState = {
- searchTerm: string;
+ view: WorkflowLibraryView;
orderBy: WorkflowRecordOrderBy;
direction: SQLiteDirection;
- tags: string[];
- categories: WorkflowCategory[];
- showOpenedWorkflowsOnly: boolean;
+ searchTerm: string;
+ selectedTags: string[];
};
const initialWorkflowLibraryState: WorkflowLibraryState = {
searchTerm: '',
orderBy: 'opened_at',
direction: 'DESC',
- tags: [],
- categories: ['user'],
- showOpenedWorkflowsOnly: false,
+ selectedTags: [],
+ view: 'defaults',
};
export const workflowLibrarySlice = createSlice({
@@ -36,24 +36,21 @@ export const workflowLibrarySlice = createSlice({
workflowLibraryDirectionChanged: (state, action: PayloadAction) => {
state.direction = action.payload;
},
- workflowLibraryCategoriesChanged: (state, action: PayloadAction) => {
- state.categories = action.payload;
+ workflowLibraryViewChanged: (state, action: PayloadAction) => {
+ state.view = action.payload;
state.searchTerm = '';
},
- workflowLibraryShowOpenedWorkflowsOnlyChanged: (state, action: PayloadAction) => {
- state.showOpenedWorkflowsOnly = action.payload;
- },
workflowLibraryTagToggled: (state, action: PayloadAction) => {
const tag = action.payload;
- const tags = state.tags;
+ const tags = state.selectedTags;
if (tags.includes(tag)) {
- state.tags = tags.filter((t) => t !== tag);
+ state.selectedTags = tags.filter((t) => t !== tag);
} else {
- state.tags = [...tags, tag];
+ state.selectedTags = [...tags, tag];
}
},
workflowLibraryTagsReset: (state) => {
- state.tags = [];
+ state.selectedTags = [];
},
},
});
@@ -62,10 +59,9 @@ export const {
workflowLibrarySearchTermChanged,
workflowLibraryOrderByChanged,
workflowLibraryDirectionChanged,
- workflowLibraryCategoriesChanged,
- workflowLibraryShowOpenedWorkflowsOnlyChanged,
workflowLibraryTagToggled,
workflowLibraryTagsReset,
+ workflowLibraryViewChanged,
} = workflowLibrarySlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
@@ -86,11 +82,10 @@ export const selectWorkflowLibrarySearchTerm = createWorkflowLibrarySelector(({
export const selectWorkflowLibraryHasSearchTerm = createWorkflowLibrarySelector(({ searchTerm }) => !!searchTerm);
export const selectWorkflowLibraryOrderBy = createWorkflowLibrarySelector(({ orderBy }) => orderBy);
export const selectWorkflowLibraryDirection = createWorkflowLibrarySelector(({ direction }) => direction);
-export const selectWorkflowLibraryTags = createWorkflowLibrarySelector(({ tags }) => tags);
-export const selectWorkflowLibraryCategories = createWorkflowLibrarySelector(({ categories }) => categories);
-export const selectWorkflowLibraryShowOpenedWorkflowsOnly = createWorkflowLibrarySelector(({ showOpenedWorkflowsOnly }) => showOpenedWorkflowsOnly);
+export const selectWorkflowLibrarySelectedTags = createWorkflowLibrarySelector(({ selectedTags }) => selectedTags);
+export const selectWorkflowLibraryView = createWorkflowLibrarySelector(({ view }) => view);
-export const DEFAULT_WORKFLOW_LIBRARY_CATEGORIES = ['user', 'default'] satisfies WorkflowCategory[];
+export const DEFAULT_WORKFLOW_LIBRARY_CATEGORIES = ['user', 'default', 'project'] satisfies WorkflowCategory[];
export const $workflowLibraryCategoriesOptions = atom(DEFAULT_WORKFLOW_LIBRARY_CATEGORIES);
export type WorkflowTagCategory = { categoryTKey: string; tags: string[] };
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 60a08c2618..2bf66d8363 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -30,7 +30,8 @@ export const workflowsApi = api.injectEndpoints({
// Because this may change the order of the list, we need to invalidate the whole list
{ type: 'Workflow', id: LIST_TAG },
{ type: 'Workflow', id: workflow_id },
- 'WorkflowTagCountsWithFilter',
+ 'WorkflowTagCounts',
+ 'WorkflowCategoryCounts',
],
}),
createWorkflow: build.mutation<
@@ -45,7 +46,8 @@ export const workflowsApi = api.injectEndpoints({
invalidatesTags: [
// Because this may change the order of the list, we need to invalidate the whole list
{ type: 'Workflow', id: LIST_TAG },
- 'WorkflowTagCountsWithFilter',
+ 'WorkflowTagCounts',
+ 'WorkflowCategoryCounts',
],
}),
updateWorkflow: build.mutation<
@@ -59,7 +61,8 @@ export const workflowsApi = api.injectEndpoints({
}),
invalidatesTags: (response, error, workflow) => [
{ type: 'Workflow', id: workflow.id },
- 'WorkflowTagCountsWithFilter',
+ 'WorkflowTagCounts',
+ 'WorkflowCategoryCounts',
],
}),
listWorkflows: build.query<
@@ -78,7 +81,16 @@ export const workflowsApi = api.injectEndpoints({
query: (params) => ({
url: `${buildWorkflowsUrl('counts_by_tag')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
}),
- providesTags: ['WorkflowTagCountsWithFilter'],
+ providesTags: ['WorkflowTagCounts'],
+ }),
+ getCountsByCategory: build.query<
+ paths['/api/v1/workflows/counts_by_category']['get']['responses']['200']['content']['application/json'],
+ NonNullable
+ >({
+ query: (params) => ({
+ url: `${buildWorkflowsUrl('counts_by_category')}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
+ }),
+ providesTags: ['WorkflowCategoryCounts'],
}),
listWorkflowsInfinite: build.infiniteQuery<
paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
@@ -151,6 +163,7 @@ export const workflowsApi = api.injectEndpoints({
export const {
useUpdateOpenedAtMutation,
useGetCountsByTagQuery,
+ useGetCountsByCategoryQuery,
useLazyGetWorkflowQuery,
useGetWorkflowQuery,
useCreateWorkflowMutation,
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index 3bda78533e..8740e465b6 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -44,7 +44,8 @@ const tagTypes = [
'LoRAModel',
'SDXLRefinerModel',
'Workflow',
- 'WorkflowTagCountsWithFilter',
+ 'WorkflowTagCounts',
+ 'WorkflowCategoryCounts',
'StylePreset',
'Schema',
'QueueCountsByDestination',
From e83536f396beebc3fe2c394260fde9cd11b715d5 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 10:00:24 +1000
Subject: [PATCH 46/67] chore(ui): lint
---
.../web/src/services/api/endpoints/workflows.ts | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 2bf66d8363..b9a02204fc 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -65,15 +65,6 @@ export const workflowsApi = api.injectEndpoints({
'WorkflowCategoryCounts',
],
}),
- listWorkflows: build.query<
- paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'],
- NonNullable
- >({
- query: (params) => ({
- url: `${buildWorkflowsUrl()}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
- }),
- providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }],
- }),
getCountsByTag: build.query<
paths['/api/v1/workflows/counts_by_tag']['get']['responses']['200']['content']['application/json'],
NonNullable
@@ -169,7 +160,6 @@ export const {
useCreateWorkflowMutation,
useDeleteWorkflowMutation,
useUpdateWorkflowMutation,
- useListWorkflowsQuery,
useListWorkflowsInfiniteInfiniteQuery,
useSetWorkflowThumbnailMutation,
useDeleteWorkflowThumbnailMutation,
From 3334652acc6781906c20bab6262ceb1d66fca102 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 10:05:49 +1000
Subject: [PATCH 47/67] feat(db): drop the opened_at column instead of marking
deprecated
---
.../sqlite_migrator/migrations/migration_18.py | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
index 426a3c4165..7879ddc378 100644
--- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
@@ -10,15 +10,15 @@ class Migration18Callback:
def _make_workflow_opened_at_nullable(self, cursor: sqlite3.Cursor) -> None:
"""
Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
- - Renaming the existing column to `opened_at_deprecated`
- - Drop the existing `idx_workflow_library_opened_at` index
+ - Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
+ - Dropping the existing `opened_at` column
- Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
- - Recreate the `idx_workflow_library_opened_at` index on the `opened_at` column
+ - Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
"""
- # Rename existing column to deprecated
- cursor.execute("ALTER TABLE workflow_library RENAME COLUMN opened_at TO opened_at_deprecated;")
# For index renaming in SQLite, we need to drop and recreate
cursor.execute("DROP INDEX IF EXISTS idx_workflow_library_opened_at;")
+ # Rename existing column to deprecated
+ cursor.execute("ALTER TABLE workflow_library DROP COLUMN opened_at;")
# Add new nullable column - all values will be NULL - no migration of data needed
cursor.execute("ALTER TABLE workflow_library ADD COLUMN opened_at DATETIME;")
# Create new index on the new column
@@ -33,10 +33,10 @@ def build_migration_18() -> Migration:
This migration does the following:
- Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
- - Renaming the existing column to `opened_at_deprecated`
- - Drop the existing `idx_workflow_library_opened_at` index
+ - Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
+ - Dropping the existing `opened_at` column
- Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
- - Recreate the `idx_workflow_library_opened_at` index on the `opened_at` column
+ - Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
"""
migration_18 = Migration(
from_version=17,
From 30ed09a36ef0567781dc41cebd22c71502b742f8 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 10:20:01 +1000
Subject: [PATCH 48/67] fix(ui): default categories for oss
---
.../web/src/features/nodes/store/workflowLibrarySlice.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index 12d3af74c8..51ae920bcc 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -85,7 +85,7 @@ export const selectWorkflowLibraryDirection = createWorkflowLibrarySelector(({ d
export const selectWorkflowLibrarySelectedTags = createWorkflowLibrarySelector(({ selectedTags }) => selectedTags);
export const selectWorkflowLibraryView = createWorkflowLibrarySelector(({ view }) => view);
-export const DEFAULT_WORKFLOW_LIBRARY_CATEGORIES = ['user', 'default', 'project'] satisfies WorkflowCategory[];
+export const DEFAULT_WORKFLOW_LIBRARY_CATEGORIES = ['user', 'default'] satisfies WorkflowCategory[];
export const $workflowLibraryCategoriesOptions = atom(DEFAULT_WORKFLOW_LIBRARY_CATEGORIES);
export type WorkflowTagCategory = { categoryTKey: string; tags: string[] };
From 89f457c486d703abc0273c0c919a8afdb7c667ac Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 10:26:11 +1000
Subject: [PATCH 49/67] fix(ui): mark workflow as opened when creating a new
workflow
---
.../features/workflowLibrary/hooks/useCreateNewWorkflow.ts | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
index a47975ff6d..2df850a895 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
@@ -13,7 +13,7 @@ import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/use
import { newWorkflowSaved } from 'features/workflowLibrary/store/actions';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
+import { useCreateWorkflowMutation, useUpdateOpenedAtMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { SetFieldType } from 'type-fest';
/**
@@ -44,6 +44,7 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [createWorkflow, { isLoading, isError }] = useCreateWorkflowMutation();
+ const [updateOpenedAt] = useUpdateOpenedAtMutation();
const getFormFieldInitialValues = useGetFormFieldInitialValues();
const toast = useToast();
@@ -70,7 +71,7 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
dispatch(newWorkflowSaved({ category }));
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
-
+ updateOpenedAt({ workflow_id: id });
onSuccess && onSuccess();
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),
@@ -92,7 +93,7 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
}
}
},
- [toast, t, createWorkflow, dispatch, getFormFieldInitialValues]
+ [toast, t, createWorkflow, dispatch, getFormFieldInitialValues, updateOpenedAt]
);
return {
createNewWorkflow,
From e81c9b0d6e2a002e0ffc94228e3f0187bb2ca9ad Mon Sep 17 00:00:00 2001
From: Mary Hipp
Date: Wed, 12 Mar 2025 14:26:25 -0400
Subject: [PATCH 50/67] add default for opened_at
---
.../app/services/workflow_records/workflow_records_common.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index da773a9e8b..909ed3b463 100644
--- a/invokeai/app/services/workflow_records/workflow_records_common.py
+++ b/invokeai/app/services/workflow_records/workflow_records_common.py
@@ -98,7 +98,9 @@ class WorkflowRecordDTOBase(BaseModel):
name: str = Field(description="The name of the workflow.")
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.")
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.")
- opened_at: Optional[Union[datetime.datetime, str]] = Field(description="The opened timestamp of the workflow.")
+ opened_at: Optional[Union[datetime.datetime, str]] = Field(
+ default=None, description="The opened timestamp of the workflow."
+ )
class WorkflowRecordDTO(WorkflowRecordDTOBase):
From aed446f01307e7e51b1598825e3fd81ceb38feb8 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 11:33:10 +1000
Subject: [PATCH 51/67] fix(ui): make the workflow load from file menu item
work the same as the button in library
Upload and save as instead of just upload as draft.
---
.../WorkflowLibraryMenu/UploadWorkflowMenuItem.tsx | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/UploadWorkflowMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/UploadWorkflowMenuItem.tsx
index a769f52909..cd2ceb50b6 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/UploadWorkflowMenuItem.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/UploadWorkflowMenuItem.tsx
@@ -1,4 +1,6 @@
import { MenuItem } from '@invoke-ai/ui-library';
+import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
+import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
import { memo, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
@@ -8,7 +10,14 @@ import { PiUploadSimpleBold } from 'react-icons/pi';
const UploadWorkflowMenuItem = () => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
- const loadWorkflowFromFile = useLoadWorkflowFromFile({ resetRef });
+ const workflowLibraryModal = useWorkflowLibraryModal();
+ const loadWorkflowFromFile = useLoadWorkflowFromFile({
+ resetRef,
+ onSuccess: (workflow) => {
+ workflowLibraryModal.close();
+ saveWorkflowAs(workflow);
+ },
+ });
const onDropAccepted = useCallback(
(files: File[]) => {
From a29fb18c0b6033d8487da71766c2d093ef55c295 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 12 Mar 2025 18:23:36 +1000
Subject: [PATCH 52/67] feat(ui): standardize and clean up workflow loading
hooks and logic
---
.../web/src/app/hooks/useStudioInitAction.ts | 13 ++--
.../ImageMenuItemLoadWorkflow.tsx | 4 +-
.../features/gallery/hooks/useImageActions.ts | 4 +-
.../LoadWorkflowConfirmationAlertDialog.tsx | 13 ++--
.../LoadWorkflowFromGraphModal.tsx | 18 +++---
.../components/UploadWorkflowButton.tsx | 25 ++++----
.../UploadWorkflowMenuItem.tsx | 26 ++++----
.../hooks/useGetAndLoadEmbeddedWorkflow.ts | 44 -------------
.../hooks/useGetAndLoadLibraryWorkflow.ts | 47 --------------
.../hooks/useLoadWorkflowFromFile.tsx | 50 +++++++--------
.../hooks/useLoadWorkflowFromImage.ts | 62 +++++++++++++++++++
.../hooks/useLoadWorkflowFromLibrary.ts | 48 ++++++++++++++
...kflow.ts => useValidateAndLoadWorkflow.ts} | 47 ++++++--------
13 files changed, 204 insertions(+), 197 deletions(-)
delete mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts
delete mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow.ts
create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflowFromImage.ts
create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflowFromLibrary.ts
rename invokeai/frontend/web/src/features/workflowLibrary/hooks/{useLoadWorkflow.ts => useValidateAndLoadWorkflow.ts} (71%)
diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
index 8af256ad38..6cc83854d4 100644
--- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
+++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
@@ -15,7 +15,7 @@ import { $isWorkflowLibraryModalOpen } from 'features/nodes/store/workflowLibrar
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
-import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
+import { useLoadWorkflowFromLibrary } from 'features/workflowLibrary/hooks/useLoadWorkflowFromLibrary';
import { atom } from 'nanostores';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -57,7 +57,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
const { t } = useTranslation();
const didParseOpenAPISchema = useStore($hasTemplates);
const store = useAppStore();
- const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
+ const loadWorkflowFromLibrary = useLoadWorkflowFromLibrary();
const handleSendToCanvas = useCallback(
async (imageName: string) => {
@@ -113,10 +113,13 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
const handleLoadWorkflow = useCallback(
async (workflowId: string) => {
// This shows a toast
- await getAndLoadWorkflow(workflowId);
- store.dispatch(setActiveTab('workflows'));
+ await loadWorkflowFromLibrary(workflowId, {
+ onSuccess: () => {
+ store.dispatch(setActiveTab('workflows'));
+ },
+ });
},
- [getAndLoadWorkflow, store]
+ [loadWorkflowFromLibrary, store]
);
const handleSelectStylePreset = useCallback(
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx
index 1aab80b21d..f39206811e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx
@@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react';
import { SpinnerIcon } from 'features/gallery/components/ImageContextMenu/SpinnerIcon';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
-import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
+import { useLoadWorkflowFromImage } from 'features/workflowLibrary/hooks/useLoadWorkflowFromImage';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFlowArrowBold } from 'react-icons/pi';
@@ -11,7 +11,7 @@ import { PiFlowArrowBold } from 'react-icons/pi';
export const ImageMenuItemLoadWorkflow = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
- const [getAndLoadEmbeddedWorkflow, { isLoading }] = useGetAndLoadEmbeddedWorkflow();
+ const [getAndLoadEmbeddedWorkflow, { isLoading }] = useLoadWorkflowFromImage();
const hasTemplates = useStore($hasTemplates);
const onClick = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
index c02b72575f..2f90766512 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
@@ -17,7 +17,7 @@ import {
} from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
-import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow';
+import { useLoadWorkflowFromImage } from 'features/workflowLibrary/hooks/useLoadWorkflowFromImage';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
@@ -147,7 +147,7 @@ export const useImageActions = (imageDTO: ImageDTO) => {
});
}, [metadata, imageDTO]);
- const [getAndLoadEmbeddedWorkflow] = useGetAndLoadEmbeddedWorkflow();
+ const [getAndLoadEmbeddedWorkflow] = useLoadWorkflowFromImage();
const loadWorkflow = useCallback(() => {
if (!imageDTO.has_workflow || !hasTemplates) {
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
index 9deda79605..7761adf767 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
@@ -4,7 +4,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
import { selectWorkflowIsTouched, workflowModeChanged } from 'features/nodes/store/workflowSlice';
-import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
+import { useLoadWorkflowFromLibrary } from 'features/workflowLibrary/hooks/useLoadWorkflowFromLibrary';
import { atom } from 'nanostores';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,7 +15,7 @@ const cleanup = () => $workflowToLoad.set(null);
export const useLoadWorkflow = () => {
const dispatch = useAppDispatch();
const workflowLibraryModal = useWorkflowLibraryModal();
- const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
+ const loadWorkflowFromLibrary = useLoadWorkflowFromLibrary();
const isTouched = useAppSelector(selectWorkflowIsTouched);
@@ -25,11 +25,14 @@ export const useLoadWorkflow = () => {
return;
}
const { workflowId, mode } = workflow;
- await getAndLoadWorkflow(workflowId);
- dispatch(workflowModeChanged(mode));
+ await loadWorkflowFromLibrary(workflowId, {
+ onSuccess: () => {
+ dispatch(workflowModeChanged(mode));
+ },
+ });
cleanup();
workflowLibraryModal.close();
- }, [dispatch, getAndLoadWorkflow, workflowLibraryModal]);
+ }, [dispatch, loadWorkflowFromLibrary, workflowLibraryModal]);
const loadWithDialog = useCallback(
(workflowId: string, mode: 'view' | 'edit') => {
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx
index d759eff611..f12d56ae61 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx
@@ -15,7 +15,7 @@ import {
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow';
-import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow';
+import { useValidateAndLoadWorkflow } from 'features/workflowLibrary/hooks/useValidateAndLoadWorkflow';
import { atom } from 'nanostores';
import type { ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
@@ -37,16 +37,16 @@ export const useLoadWorkflowFromGraphModal = () => {
export const LoadWorkflowFromGraphModal = () => {
const { t } = useTranslation();
- const _loadWorkflow = useLoadWorkflow();
+ const validateAndLoadWorkflow = useValidateAndLoadWorkflow();
const { isOpen, onClose } = useLoadWorkflowFromGraphModal();
const [graphRaw, setGraphRaw] = useState('');
- const [workflowRaw, setWorkflowRaw] = useState('');
+ const [unvalidatedWorkflow, setUnvalidatedWorkflow] = useState('');
const [shouldAutoLayout, setShouldAutoLayout] = useState(true);
const onChangeGraphRaw = useCallback((e: ChangeEvent) => {
setGraphRaw(e.target.value);
}, []);
const onChangeWorkflowRaw = useCallback((e: ChangeEvent) => {
- setWorkflowRaw(e.target.value);
+ setUnvalidatedWorkflow(e.target.value);
}, []);
const onChangeShouldAutoLayout = useCallback((e: ChangeEvent) => {
setShouldAutoLayout(e.target.checked);
@@ -54,12 +54,12 @@ export const LoadWorkflowFromGraphModal = () => {
const parse = useCallback(() => {
const graph = JSON.parse(graphRaw);
const workflow = graphToWorkflow(graph, shouldAutoLayout);
- setWorkflowRaw(JSON.stringify(workflow, null, 2));
+ setUnvalidatedWorkflow(JSON.stringify(workflow, null, 2));
}, [graphRaw, shouldAutoLayout]);
- const loadWorkflow = useCallback(() => {
- _loadWorkflow({ workflow: workflowRaw, graph: null });
+ const loadWorkflow = useCallback(async () => {
+ await validateAndLoadWorkflow(unvalidatedWorkflow);
onClose();
- }, [_loadWorkflow, onClose, workflowRaw]);
+ }, [validateAndLoadWorkflow, onClose, unvalidatedWorkflow]);
return (
@@ -95,7 +95,7 @@ export const LoadWorkflowFromGraphModal = () => {
{t('nodes.workflow')}