diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 2ec071ac76..5a37a75dcf 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)"),
+ 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] = []
@@ -116,6 +117,7 @@ async def list_workflows(
query=query,
categories=categories,
tags=tags,
+ has_been_opened=has_been_opened,
)
for workflow in workflows.items:
workflows_with_thumbnails.append(
@@ -221,14 +223,29 @@ 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("/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"),
-) -> int:
- """Gets a the count of workflows that include the specified tags and categories"""
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+) -> dict[str, int]:
+ """Counts workflows by tag"""
- return ApiDependencies.invoker.services.workflow_records.get_counts(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/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]
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..7879ddc378
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
@@ -0,0 +1,47 @@
+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:
+ """
+ Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
+ - 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)
+ - Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
+ """
+ # 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
+ cursor.execute(
+ "CREATE INDEX idx_workflow_library_opened_at ON workflow_library(opened_at);",
+ )
+
+
+def build_migration_18() -> Migration:
+ """
+ Build the migration from database version 17 to 18.
+
+ This migration does the following:
+ - Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
+ - 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)
+ - Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
+ """
+ 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 de25ea876d..5bf42ed253 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -46,17 +46,28 @@ class WorkflowRecordsStorageBase(ABC):
per_page: Optional[int],
query: Optional[str],
tags: Optional[list[str]],
+ has_been_opened: Optional[bool],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
@abstractmethod
- def get_counts(
+ def counts_by_category(
self,
- tags: Optional[list[str]],
- categories: Optional[list[WorkflowCategory]],
- ) -> int:
- """Gets the count of workflows for the given tags and categories."""
+ 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
@abstractmethod
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index 698a90cf91..909ed3b463 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,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: 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):
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 9425653eff..ad67837a06 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,
+ has_been_opened: 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 has_been_opened:
+ conditions.append("opened_at IS NOT NULL")
+ elif has_been_opened is False:
+ conditions.append("opened_at IS NULL")
+
# Ignore whitespace in the query
stripped_query = query.strip() if query else None
if stripped_query:
@@ -230,54 +236,105 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
total=total,
)
- def get_counts(
+ def counts_by_tag(
self,
- tags: Optional[list[str]],
- categories: Optional[list[WorkflowCategory]],
- ) -> int:
+ tags: list[str],
+ categories: Optional[list[WorkflowCategory]] = None,
+ has_been_opened: Optional[bool] = 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] = []
- # 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)
-
+ # 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])
+ 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")
- stmt = """--sql
- SELECT COUNT(*)
- FROM workflow_library
- """
+ # 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()
- 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 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:
diff --git a/invokeai/app/services/workflow_thumbnails/default_workflow_thumbnails/default_686bb1d0-d086-4c70-9fa3-2f600b922023.png b/invokeai/app/services/workflow_thumbnails/default_workflow_thumbnails/default_686bb1d0-d086-4c70-9fa3-2f600b922023.png
deleted file mode 100644
index 5db78ce086..0000000000
Binary files a/invokeai/app/services/workflow_thumbnails/default_workflow_thumbnails/default_686bb1d0-d086-4c70-9fa3-2f600b922023.png and /dev/null differ
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]] = {
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
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",
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index b71454ed2a..e23de0b159 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1692,10 +1692,11 @@
"filterByTags": "Filter by Tags",
"yourWorkflows": "Your Workflows",
"recentlyOpened": "Recently Opened",
+ "noRecentWorkflows": "No Recent Workflows",
"private": "Private",
"shared": "Shared",
"browseWorkflows": "Browse Workflows",
- "resetTags": "Reset Tags",
+ "deselectAll": "Deselect All",
"opened": "Opened",
"openWorkflow": "Open Workflow",
"updated": "Updated",
@@ -1726,6 +1727,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",
@@ -1741,6 +1743,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/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..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...",
@@ -1734,7 +1735,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",
@@ -1772,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": {
@@ -2330,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": {
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..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,11 +2255,10 @@
"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",
- "uploadAndSaveWorkflow": "Tải Lên Thư Viện",
"openLibrary": "Mở Thư Viện",
"builder": {
"resetAllNodeFields": "Tải Lại Các Vùng Node",
@@ -2287,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",
@@ -2322,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": {
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": {
diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
index 3f66ae4a9e..c3bbb3fa58 100644
--- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
+++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
@@ -16,10 +16,16 @@ 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 +54,7 @@ interface Props extends PropsWithChildren {
isDebugging?: boolean;
logo?: ReactNode;
workflowCategories?: WorkflowCategory[];
+ workflowTagCategories?: WorkflowTagCategory[];
loggingOverrides?: LoggingOverrides;
}
@@ -68,6 +75,7 @@ const InvokeAIUI = ({
isDebugging = false,
logo,
workflowCategories,
+ workflowTagCategories,
loggingOverrides,
}: Props) => {
useLayoutEffect(() => {
@@ -195,14 +203,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/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
index 8af256ad38..db486b5cee 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 { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
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 loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const handleSendToCanvas = useCallback(
async (imageName: string) => {
@@ -113,10 +113,15 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
const handleLoadWorkflow = useCallback(
async (workflowId: string) => {
// This shows a toast
- await getAndLoadWorkflow(workflowId);
- store.dispatch(setActiveTab('workflows'));
+ await loadWorkflowWithDialog({
+ type: 'library',
+ data: workflowId,
+ onSuccess: () => {
+ store.dispatch(setActiveTab('workflows'));
+ },
+ });
},
- [getAndLoadWorkflow, store]
+ [loadWorkflowWithDialog, store]
);
const handleSelectStylePreset = useCallback(
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/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/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/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx
index 1aab80b21d..86bf6426ba 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx
@@ -1,9 +1,8 @@
import { MenuItem } from '@invoke-ai/ui-library';
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 { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFlowArrowBold } from 'react-icons/pi';
@@ -11,19 +10,15 @@ import { PiFlowArrowBold } from 'react-icons/pi';
export const ImageMenuItemLoadWorkflow = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
- const [getAndLoadEmbeddedWorkflow, { isLoading }] = useGetAndLoadEmbeddedWorkflow();
+ const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const hasTemplates = useStore($hasTemplates);
const onClick = useCallback(() => {
- getAndLoadEmbeddedWorkflow(imageDTO.image_name);
- }, [getAndLoadEmbeddedWorkflow, imageDTO.image_name]);
+ loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
+ }, [loadWorkflowWithDialog, imageDTO.image_name]);
return (
- : }
- onClickCapture={onClick}
- isDisabled={!imageDTO.has_workflow || !hasTemplates}
- >
+ } onClickCapture={onClick} isDisabled={!imageDTO.has_workflow || !hasTemplates}>
{t('nodes.loadWorkflow')}
);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SpinnerIcon.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SpinnerIcon.tsx
deleted file mode 100644
index c2adee8958..0000000000
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SpinnerIcon.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Flex, Spinner } from '@invoke-ai/ui-library';
-
-export const SpinnerIcon = () => (
-
-
-
-);
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts
index c02b72575f..1ba22baf4d 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 { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
@@ -147,14 +147,15 @@ export const useImageActions = (imageDTO: ImageDTO) => {
});
}, [metadata, imageDTO]);
- const [getAndLoadEmbeddedWorkflow] = useGetAndLoadEmbeddedWorkflow();
+ const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
- const loadWorkflow = useCallback(() => {
+ const loadWorkflowFromImage = useCallback(() => {
if (!imageDTO.has_workflow || !hasTemplates) {
return;
}
- getAndLoadEmbeddedWorkflow(imageDTO.image_name);
- }, [getAndLoadEmbeddedWorkflow, hasTemplates, imageDTO.has_workflow, imageDTO.image_name]);
+
+ loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name });
+ }, [hasTemplates, imageDTO.has_workflow, imageDTO.image_name, loadWorkflowWithDialog]);
const recallSize = useCallback(() => {
if (isStaging) {
@@ -180,7 +181,7 @@ export const useImageActions = (imageDTO: ImageDTO) => {
recallSeed,
recallPrompts,
createAsPreset,
- loadWorkflow,
+ loadWorkflow: loadWorkflowFromImage,
hasWorkflow: imageDTO.has_workflow,
recallSize,
upscale,
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/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription.tsx
index 4b31c85823..0f702e5bfa 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription.tsx
@@ -1,4 +1,4 @@
-import { Text } from '@invoke-ai/ui-library';
+import { Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { linkifyOptions, linkifySx } from 'common/components/linkify';
import { selectWorkflowDescription } from 'features/nodes/store/workflowSlice';
@@ -13,9 +13,11 @@ export const ActiveWorkflowDescription = memo(() => {
}
return (
-
- {description}
-
+
+
+ {description}
+
+
);
});
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 (
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementContent.tsx
index dc89d50f8a..271c405a45 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementContent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementContent.tsx
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
const headingSx: SystemStyleObject = {
fontWeight: 'bold',
fontSize: '2xl',
+ whiteSpace: 'pre-wrap',
'&[data-is-empty="true"]': {
opacity: 0.3,
},
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementContent.tsx
index c81d2bddb3..35b187e1fc 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementContent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementContent.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
const textSx: SystemStyleObject = {
fontSize: 'md',
+ whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
'&[data-is-empty="true"]': {
opacity: 0.3,
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx
index cf3ef49faa..025406a0f0 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx
@@ -20,7 +20,8 @@ export const DeleteWorkflow = ({ workflowId }: { workflowId: string }) => {
{
}
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx
index 05c8266dec..778f8ca179 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx
@@ -1,27 +1,37 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
-import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
+import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPencilBold } from 'react-icons/pi';
export const EditWorkflow = ({ workflowId }: { workflowId: string }) => {
- const loadWorkflow = useLoadWorkflow();
+ const dispatch = useAppDispatch();
+ const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const { t } = useTranslation();
const handleClickEdit = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
- loadWorkflow.loadWithDialog(workflowId, 'edit');
+ loadWorkflowWithDialog({
+ type: 'library',
+ data: workflowId,
+ onSuccess: () => {
+ dispatch(workflowModeChanged('edit'));
+ },
+ });
},
- [loadWorkflow, workflowId]
+ [dispatch, loadWorkflowWithDialog, workflowId]
);
return (
}
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/ShareWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx
index 9fdc4a5bb8..971e9eca78 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx
@@ -22,7 +22,8 @@ export const ShareWorkflowButton = memo(({ workflow }: { workflow: WorkflowRecor
}
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..fed0393435 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
@@ -1,28 +1,38 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
-import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
+import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold } from 'react-icons/pi';
export const ViewWorkflow = ({ workflowId }: { workflowId: string }) => {
- const loadWorkflow = useLoadWorkflow();
+ const dispatch = useAppDispatch();
+ const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const { t } = useTranslation();
const handleClickLoad = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
- loadWorkflow.loadWithDialog(workflowId, 'view');
+ loadWorkflowWithDialog({
+ type: 'library',
+ data: workflowId,
+ onSuccess: () => {
+ dispatch(workflowModeChanged('view'));
+ },
+ });
},
- [loadWorkflow, workflowId]
+ [dispatch, loadWorkflowWithDialog, workflowId]
);
return (
-
+
}
/>
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..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,16 +8,27 @@ 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 {
+ $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';
import { WorkflowList } from './WorkflowList';
-export const WorkflowLibraryModal = () => {
+export const WorkflowLibraryModal = memo(() => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
+ const didSync = useSyncInitialWorkflowLibraryCategories();
return (
@@ -30,16 +41,102 @@ export const WorkflowLibraryModal = () => {
{t('workflows.workflowLibrary')}
-
-
-
-
-
-
+ {didSync && (
+
+
+
+
+
+
+
-
+ )}
+ {!didSync && }
);
+});
+
+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 0f2f9d6692..9c16efc390 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,145 +1,55 @@
import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library';
-import { Button, Checkbox, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
+import { Button, Checkbox, Collapse, Flex, 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 { WorkflowLibraryView, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import {
+ $workflowLibraryCategoriesOptions,
+ $workflowLibraryTagCategoriesOptions,
+ $workflowLibraryTagOptions,
selectWorkflowLibrarySelectedTags,
- selectWorkflowSelectedCategories,
- workflowSelectedCategoriesChanged,
- workflowSelectedTagsRese,
- workflowSelectedTagToggled,
-} from 'features/nodes/store/workflowSlice';
-import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
+ selectWorkflowLibraryView,
+ workflowLibraryTagsReset,
+ workflowLibraryTagToggled,
+ workflowLibraryViewChanged,
+} from 'features/nodes/store/workflowLibrarySlice';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton';
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 type { S } from 'services/api/types';
+import { useGetCountsByTagQuery } from 'services/api/endpoints/workflows';
export const WorkflowLibrarySideNav = () => {
const { t } = useTranslation();
- const dispatch = useDispatch();
- const categories = useAppSelector(selectWorkflowSelectedCategories);
- const categoryOptions = useStore($workflowCategories);
- const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
-
- const selectYourWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user']));
- }, [categoryOptions, dispatch]);
-
- const selectPrivateWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(['user']));
- }, [dispatch]);
-
- const selectSharedWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(['project']));
- }, [dispatch]);
-
- const selectDefaultWorkflows = useCallback(() => {
- dispatch(workflowSelectedCategoriesChanged(['default']));
- }, [dispatch]);
-
- const resetTags = useCallback(() => {
- dispatch(workflowSelectedTagsRese());
- }, [dispatch]);
-
- const isYourWorkflowsSelected = useMemo(() => {
- if (categoryOptions.includes('project')) {
- return categories.includes('user') && categories.includes('project');
- } else {
- return categories.includes('user');
- }
- }, [categoryOptions, categories]);
-
- const isPrivateWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('user');
- }, [categories]);
-
- const isSharedWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('project');
- }, [categories]);
-
- const isDefaultWorkflowsExclusivelySelected = useMemo(() => {
- return categories.length === 1 && categories.includes('default');
- }, [categories]);
+ const categoryOptions = useStore($workflowLibraryCategoriesOptions);
+ 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.resetTags')}
-
-
- {WORKFLOW_TAGS.map((tagCategory) => (
-
- ))}
-
-
-
+ {t('workflows.browseWorkflows')}
+
@@ -148,61 +58,107 @@ export const WorkflowLibrarySideNav = () => {
);
};
-const recentWorkflowsQueryArg = {
- page: 0,
- per_page: 5,
- order_by: 'opened_at',
- direction: 'DESC',
-} satisfies Parameters[0];
-
-const RecentWorkflows = memo(() => {
+const DefaultsViewCheckboxesCollapsible = memo(() => {
const { t } = useTranslation();
- const { data, isLoading } = useListWorkflowsQuery(recentWorkflowsQueryArg);
+ const dispatch = useDispatch();
+ const tags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const tagCategoryOptions = useStore($workflowLibraryTagCategoriesOptions);
+ const view = useAppSelector(selectWorkflowLibraryView);
- if (isLoading) {
- return {t('common.loading')};
- }
-
- if (!data) {
- return {t('workflows.noRecentWorkflows')};
- }
+ const resetTags = useCallback(() => {
+ dispatch(workflowLibraryTagsReset());
+ }, [dispatch]);
return (
- <>
- {data.items.map((workflow) => {
- return ;
- })}
- >
+
+
+ }
+ h={8}
+ >
+ {t('workflows.deselectAll')}
+
+
+ {tagCategoryOptions.map((tagCategory) => (
+
+ ))}
+
+
+
);
});
-RecentWorkflows.displayName = 'RecentWorkflows';
+DefaultsViewCheckboxesCollapsible.displayName = 'DefaultsViewCheckboxes';
-const RecentWorkflowButton = memo(({ workflow }: { workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }) => {
- const loadWorkflow = useLoadWorkflow();
- const load = useCallback(() => {
- loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
- }, [loadWorkflow, workflow.workflow_id]);
-
- return (
-
-
- {workflow.name}
-
- {workflow.category === 'project' && }
-
+const useCountForIndividualTag = (tag: string) => {
+ const allTags = useStore($workflowLibraryTagOptions);
+ const queryArg = useMemo(
+ () =>
+ ({
+ tags: allTags,
+ categories: ['default'],
+ }) satisfies Parameters[0],
+ [allTags]
+ );
+ const queryOptions = useMemo(
+ () =>
+ ({
+ selectFromResult: ({ data }) => ({
+ count: data?.[tag] ?? 0,
+ }),
+ }) satisfies Parameters[1],
+ [tag]
);
-});
-RecentWorkflowButton.displayName = 'RecentWorkflowButton';
-const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected: boolean }) => {
+ const { count } = useGetCountsByTagQuery(queryArg, queryOptions);
+
+ return count;
+};
+
+const useCountForTagCategory = (tagCategory: WorkflowTagCategory) => {
+ const allTags = useStore($workflowLibraryTagOptions);
+ const queryArg = useMemo(
+ () =>
+ ({
+ tags: allTags,
+ categories: ['default'], // We only allow filtering by tag for default workflows
+ }) satisfies Parameters[0],
+ [allTags]
+ );
+ 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 } = useGetCountsByTagQuery(queryArg, queryOptions);
+
+ return count;
+};
+
+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: (typeof WORKFLOW_TAGS)[number]; isDisabled: boolean }) => {
- 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) => (
-
- ))}
-
-
- );
- }
-);
-TagCategory.displayName = 'TagCategory';
-
-const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: WorkflowTag }) => {
- const dispatch = useAppDispatch();
- const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
- const isSelected = selectedTags.includes(tag);
-
- const onChange = useCallback(() => {
- dispatch(workflowSelectedTagToggled(tag));
- }, [dispatch, tag]);
-
- const { count } = useGetCountsQuery(
- { tags: [tag], categories: ['default'] },
- { selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
- );
+const TagCategory = memo(({ tagCategory }: { tagCategory: WorkflowTagCategory }) => {
+ const { t } = useTranslation();
+ const count = useCountForTagCategory(tagCategory);
if (count === 0) {
return null;
}
return (
-
+
+
+ {t(tagCategory.categoryTKey)}
+
+
+ {tagCategory.tags.map((tag) => (
+
+ ))}
+
+
+ );
+});
+TagCategory.displayName = 'TagCategory';
+
+const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: string }) => {
+ const dispatch = useAppDispatch();
+ const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const isChecked = selectedTags.includes(tag);
+ const count = useCountForIndividualTag(tag);
+
+ const onChange = useCallback(() => {
+ dispatch(workflowLibraryTagToggled(tag));
+ }, [dispatch, tag]);
+
+ if (count === 0) {
+ return null;
+ }
+
+ return (
+
{`${tag} (${count})`}
);
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..0eb60f490f 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,31 +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 {
+ selectWorkflowLibraryDirection,
+ selectWorkflowLibraryHasSearchTerm,
+ selectWorkflowLibraryOrderBy,
+ selectWorkflowLibrarySearchTerm,
selectWorkflowLibrarySelectedTags,
- selectWorkflowOrderBy,
- selectWorkflowOrderDirection,
- selectWorkflowSearchTerm,
- selectWorkflowSelectedCategories,
-} from 'features/nodes/store/workflowSlice';
+ 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 type { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
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(selectWorkflowSelectedCategories);
- const orderBy = useAppSelector(selectWorkflowOrderBy);
- const direction = useAppSelector(selectWorkflowOrderDirection);
- const query = useAppSelector(selectWorkflowSearchTerm);
- const tags = useAppSelector(selectWorkflowLibrarySelectedTags);
- const [debouncedQuery] = useDebounce(query, 500);
+ const orderBy = useAppSelector(selectWorkflowLibraryOrderBy);
+ const direction = useAppSelector(selectWorkflowLibraryDirection);
+ const searchTerm = useAppSelector(selectWorkflowLibrarySearchTerm);
+ const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
+ const view = useAppSelector(selectWorkflowLibraryView);
+ const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
const queryArg = useMemo(() => {
return {
@@ -34,11 +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 : [],
- } satisfies Parameters[0];
- }, [orderBy, direction, categories, debouncedQuery, tags]);
+ categories: getCategories(view),
+ query: debouncedSearchTerm,
+ tags: view === 'defaults' ? selectedTags : [],
+ has_been_opened: getHasBeenOpened(view),
+ } satisfies Parameters[0];
+ }, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);
return queryArg;
};
@@ -52,9 +81,7 @@ const queryOptions = {
},
} satisfies Parameters[1];
-export const WorkflowList = () => {
- const searchTerm = useAppSelector(selectWorkflowSearchTerm);
- const { t } = useTranslation();
+export const WorkflowList = memo(() => {
const queryArg = useInfiniteQueryAry();
const { items, isFetching, isLoading, fetchNextPage, hasNextPage } = useListWorkflowsInfiniteInfiniteQuery(
queryArg,
@@ -70,14 +97,7 @@ export const WorkflowList = () => {
}
if (items.length === 0) {
- return (
-
- );
+ return ;
}
return (
@@ -88,8 +108,23 @@ export const WorkflowList = () => {
isFetching={isFetching}
/>
);
-};
+});
+WorkflowList.displayName = 'WorkflowList';
+const NoItems = memo(() => {
+ const { t } = useTranslation();
+ const hasSearchTerm = useAppSelector(selectWorkflowLibraryHasSearchTerm);
+
+ return (
+
+ );
+});
+NoItems.displayName = 'NoItems';
const WorkflowListContent = memo(
({
items,
@@ -153,7 +188,7 @@ const WorkflowListContent = memo(
{
+export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
const { t } = useTranslation();
-
+ const dispatch = useAppDispatch();
const workflowId = useAppSelector(selectWorkflowId);
- const loadWorkflow = useLoadWorkflow();
+ const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const isActive = useMemo(() => {
return workflowId === workflow.workflow_id;
}, [workflowId, workflow.workflow_id]);
const handleClickLoad = useCallback(() => {
- loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
- }, [loadWorkflow, workflow.workflow_id]);
+ loadWorkflowWithDialog({
+ type: 'library',
+ data: workflow.workflow_id,
+ onSuccess: () => {
+ dispatch(workflowModeChanged('view'));
+ },
+ });
+ }, [dispatch, loadWorkflowWithDialog, workflow.workflow_id]);
return (
-
-
-
- }
- objectFit="cover"
- objectPosition="50% 50%"
- height={IMAGE_THUMBNAIL_SIZE}
- width={IMAGE_THUMBNAIL_SIZE}
- minHeight={IMAGE_THUMBNAIL_SIZE}
- minWidth={IMAGE_THUMBNAIL_SIZE}
- borderRadius="base"
- />
-
-
- {workflow.name}
-
- {isActive && (
-
- {t('workflows.opened')}
-
- )}
-
-
- {workflow.description}
-
+
+ : }
+ objectFit="cover"
+ objectPosition="50% 50%"
+ height={IMAGE_THUMBNAIL_SIZE}
+ width={IMAGE_THUMBNAIL_SIZE}
+ minHeight={IMAGE_THUMBNAIL_SIZE}
+ minWidth={IMAGE_THUMBNAIL_SIZE}
+ borderRadius="base"
+ />
-
-
-
-
- {workflow.category === 'project' && }
- {workflow.category === 'default' && (
-
- )}
+
+
+
+ {workflow.name}
+
+ {isActive && (
+
+ {t('workflows.opened')}
+
+ )}
+ {workflow.category === 'project' && }
+ {workflow.category === 'default' && (
+
+ )}
+
+
+
+ {workflow.description}
+
-
-
- {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.opened_at && (
+
+ {t('workflows.opened')} {new Date(workflow.opened_at).toLocaleString()}
+
)}
+
+ {workflow.category === 'default' && }
{workflow.category !== 'default' && (
<>
@@ -133,4 +132,39 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
);
-};
+});
+WorkflowListItem.displayName = 'WorkflowListItem';
+
+const UserThumbnailFallback = memo(() => {
+ return (
+
+
+
+ );
+});
+UserThumbnailFallback.displayName = 'UserThumbnailFallback';
+
+const DefaultThumbnailFallback = memo(() => {
+ return (
+
+
+
+ );
+});
+DefaultThumbnailFallback.displayName = 'DefaultThumbnailFallback';
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..51ae920bcc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -0,0 +1,103 @@
+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, computed } from 'nanostores';
+import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
+
+export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults';
+
+type WorkflowLibraryState = {
+ view: WorkflowLibraryView;
+ orderBy: WorkflowRecordOrderBy;
+ direction: SQLiteDirection;
+ searchTerm: string;
+ selectedTags: string[];
+};
+
+const initialWorkflowLibraryState: WorkflowLibraryState = {
+ searchTerm: '',
+ orderBy: 'opened_at',
+ direction: 'DESC',
+ selectedTags: [],
+ view: 'defaults',
+};
+
+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;
+ },
+ workflowLibraryViewChanged: (state, action: PayloadAction) => {
+ state.view = action.payload;
+ state.searchTerm = '';
+ },
+ workflowLibraryTagToggled: (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];
+ }
+ },
+ workflowLibraryTagsReset: (state) => {
+ state.selectedTags = [];
+ },
+ },
+});
+
+export const {
+ workflowLibrarySearchTermChanged,
+ workflowLibraryOrderByChanged,
+ workflowLibraryDirectionChanged,
+ workflowLibraryTagToggled,
+ workflowLibraryTagsReset,
+ workflowLibraryViewChanged,
+} = 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: [],
+};
+
+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 selectWorkflowLibrarySelectedTags = createWorkflowLibrarySelector(({ selectedTags }) => selectedTags);
+export const selectWorkflowLibraryView = createWorkflowLibrarySelector(({ view }) => view);
+
+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
+);
+export const $workflowLibraryTagOptions = computed($workflowLibraryTagCategoriesOptions, (tagCategories) =>
+ tagCategories.flatMap(({ tags }) => tags)
+);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
index fda9049124..816a763dcd 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -11,12 +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,
- WorkflowTag,
-} 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 {
@@ -40,7 +35,6 @@ import {
} from 'features/nodes/types/workflow';
import { isEqual } from 'lodash-es';
import { useMemo } from 'react';
-import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
import { selectNodesSlice } from './selectors';
@@ -83,11 +77,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 +87,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,24 +126,13 @@ 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 = {
elements: { [rootElement.id]: rootElement },
rootElementId: rootElement.id,
};
+ state.isTouched = true;
},
formElementAdded: (
state,
@@ -184,29 +149,36 @@ export const workflowSlice = createSlice({
if (isNodeFieldElement(element)) {
state.formFieldInitialValues[element.id] = initialValue;
}
+ state.isTouched = true;
},
formElementRemoved: (state, action: PayloadAction<{ id: string }>) => {
const { form } = state;
const { id } = action.payload;
removeElement({ form, id });
delete state.formFieldInitialValues[id];
+ state.isTouched = true;
},
formElementReparented: (state, action: PayloadAction<{ id: string; newParentId: string; index: number }>) => {
const { form } = state;
const { id, newParentId, index } = action.payload;
reparentElement({ form, id, newParentId, index });
+ state.isTouched = true;
},
formElementHeadingDataChanged: (state, action: FormElementDataChangedAction) => {
formElementDataChangedReducer(state, action, isHeadingElement);
+ state.isTouched = true;
},
formElementTextDataChanged: (state, action: FormElementDataChangedAction) => {
formElementDataChangedReducer(state, action, isTextElement);
+ state.isTouched = true;
},
formElementNodeFieldDataChanged: (state, action: FormElementDataChangedAction) => {
formElementDataChangedReducer(state, action, isNodeFieldElement);
+ state.isTouched = true;
},
formElementContainerDataChanged: (state, action: FormElementDataChangedAction) => {
formElementDataChangedReducer(state, action, isContainerElement);
+ state.isTouched = true;
},
formFieldInitialValuesChanged: (
state,
@@ -214,6 +186,7 @@ export const workflowSlice = createSlice({
) => {
const { formFieldInitialValues } = action.payload;
state.formFieldInitialValues = formFieldInitialValues;
+ state.isTouched = true;
},
},
extraReducers: (builder) => {
@@ -314,12 +287,6 @@ export const {
workflowContactChanged,
workflowIDChanged,
workflowSaved,
- workflowSearchTermChanged,
- workflowOrderByChanged,
- workflowOrderDirectionChanged,
- workflowSelectedCategoriesChanged,
- workflowSelectedTagToggled,
- workflowSelectedTagsRese,
formReset,
formElementAdded,
formElementRemoved,
@@ -382,12 +349,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) => {
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/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
index 9deda79605..f4048be9e6 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
@@ -1,75 +1,148 @@
import { ConfirmationAlertDialog, Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { 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 { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
+import type { WorkflowV3 } from 'features/nodes/types/workflow';
+import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
+import { useLoadWorkflowFromImage } from 'features/workflowLibrary/hooks/useLoadWorkflowFromImage';
+import { useLoadWorkflowFromLibrary } from 'features/workflowLibrary/hooks/useLoadWorkflowFromLibrary';
+import { useLoadWorkflowFromObject } from 'features/workflowLibrary/hooks/useLoadWorkflowFromObject';
import { atom } from 'nanostores';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-const $workflowToLoad = atom<{ workflowId: string; mode: 'view' | 'edit'; isOpen: boolean } | null>(null);
-const cleanup = () => $workflowToLoad.set(null);
+type Callbacks = {
+ onSuccess?: (workflow: WorkflowV3) => void;
+ onError?: () => void;
+ onCompleted?: () => void;
+};
-export const useLoadWorkflow = () => {
- const dispatch = useAppDispatch();
+type LoadLibraryWorkflowData = Callbacks & {
+ type: 'library';
+ data: string;
+};
+
+type LoadWorkflowFromObjectData = Callbacks & {
+ type: 'object';
+ data: unknown;
+};
+
+type LoadWorkflowFromFileData = Callbacks & {
+ type: 'file';
+ data: File;
+};
+
+type LoadWorkflowFromImageData = Callbacks & {
+ type: 'image';
+ data: string;
+};
+
+type DialogStateExtra = {
+ isOpen: boolean;
+};
+
+const $dialogState = atom<
+ | (LoadLibraryWorkflowData & DialogStateExtra)
+ | (LoadWorkflowFromObjectData & DialogStateExtra)
+ | (LoadWorkflowFromFileData & DialogStateExtra)
+ | (LoadWorkflowFromImageData & DialogStateExtra)
+ | null
+>(null);
+const cleanup = () => $dialogState.set(null);
+
+const useLoadImmediate = () => {
const workflowLibraryModal = useWorkflowLibraryModal();
- const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
-
- const isTouched = useAppSelector(selectWorkflowIsTouched);
+ const loadWorkflowFromLibrary = useLoadWorkflowFromLibrary();
+ const loadWorkflowFromFile = useLoadWorkflowFromFile();
+ const loadWorkflowFromImage = useLoadWorkflowFromImage();
+ const loadWorkflowFromObject = useLoadWorkflowFromObject();
const loadImmediate = useCallback(async () => {
- const workflow = $workflowToLoad.get();
- if (!workflow) {
+ const dialogState = $dialogState.get();
+ if (!dialogState) {
return;
}
- const { workflowId, mode } = workflow;
- await getAndLoadWorkflow(workflowId);
- dispatch(workflowModeChanged(mode));
+ const { type, data, onSuccess, onError, onCompleted } = dialogState;
+ const options = {
+ onSuccess,
+ onError,
+ onCompleted,
+ };
+ if (type === 'object') {
+ await loadWorkflowFromObject(data, options);
+ } else if (type === 'file') {
+ await loadWorkflowFromFile(data, options);
+ } else if (type === 'library') {
+ await loadWorkflowFromLibrary(data, options);
+ } else if (type === 'image') {
+ await loadWorkflowFromImage(data, options);
+ }
cleanup();
workflowLibraryModal.close();
- }, [dispatch, getAndLoadWorkflow, workflowLibraryModal]);
+ }, [
+ loadWorkflowFromFile,
+ loadWorkflowFromImage,
+ loadWorkflowFromLibrary,
+ loadWorkflowFromObject,
+ workflowLibraryModal,
+ ]);
- const loadWithDialog = useCallback(
- (workflowId: string, mode: 'view' | 'edit') => {
+ return loadImmediate;
+};
+
+/**
+ * Handles loading workflows from various sources. If there are unsaved changes, the user will be prompted to confirm
+ * before loading the workflow.
+ */
+export const useLoadWorkflowWithDialog = () => {
+ const isTouched = useAppSelector(selectWorkflowIsTouched);
+ const loadImmediate = useLoadImmediate();
+
+ const loadWorkflowWithDialog = useCallback(
+ /**
+ * Loads a workflow from various sources. If there are unsaved changes, the user will be prompted to confirm before
+ * loading the workflow. The workflow will be loaded immediately if there are no unsaved changes. On success, error
+ * or completion, the corresponding callback will be called.
+ *
+ * @param data - The data to load the workflow from.
+ * @param data.type - The type of data to load the workflow from.
+ * @param data.data - The data to load the workflow from. The type of this data depends on the `type` field.
+ * @param data.onSuccess - A callback to call when the workflow is successfully loaded.
+ * @param data.onError - A callback to call when an error occurs while loading the workflow.
+ * @param data.onCompleted - A callback to call when the loading process is completed (both success and error).
+ */
+ (
+ data: LoadLibraryWorkflowData | LoadWorkflowFromObjectData | LoadWorkflowFromFileData | LoadWorkflowFromImageData
+ ) => {
if (!isTouched) {
- $workflowToLoad.set({
- workflowId,
- mode,
- isOpen: false,
- });
+ $dialogState.set({ ...data, isOpen: false });
loadImmediate();
} else {
- $workflowToLoad.set({
- workflowId,
- mode,
- isOpen: true,
- });
+ $dialogState.set({ ...data, isOpen: true });
}
},
[loadImmediate, isTouched]
);
- return {
- loadImmediate,
- loadWithDialog,
- } as const;
+ return loadWorkflowWithDialog;
};
export const LoadWorkflowConfirmationAlertDialog = memo(() => {
useAssertSingleton('LoadWorkflowConfirmationAlertDialog');
const { t } = useTranslation();
- const workflow = useStore($workflowToLoad);
- const loadWorkflow = useLoadWorkflow();
+ const workflow = useStore($dialogState);
+ const loadImmediate = useLoadImmediate();
return (
{t('nodes.loadWorkflowDesc')}
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..131409e7cf 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 { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { atom } from 'nanostores';
import type { ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
@@ -37,16 +37,17 @@ export const useLoadWorkflowFromGraphModal = () => {
export const LoadWorkflowFromGraphModal = () => {
const { t } = useTranslation();
- const _loadWorkflow = useLoadWorkflow();
const { isOpen, onClose } = useLoadWorkflowFromGraphModal();
+ const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const [graphRaw, setGraphRaw] = useState('');
- const [workflowRaw, setWorkflowRaw] = useState('');
+ const [unvalidatedWorkflow, setUnvalidatedWorkflow] = useState();
+ const [unvalidatedWorkflowAsString, setUnvalidatedWorkflowAsString] = 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 +55,13 @@ export const LoadWorkflowFromGraphModal = () => {
const parse = useCallback(() => {
const graph = JSON.parse(graphRaw);
const workflow = graphToWorkflow(graph, shouldAutoLayout);
- setWorkflowRaw(JSON.stringify(workflow, null, 2));
+ setUnvalidatedWorkflow(workflow);
+ setUnvalidatedWorkflowAsString(JSON.stringify(workflow, null, 2));
}, [graphRaw, shouldAutoLayout]);
- const loadWorkflow = useCallback(() => {
- _loadWorkflow({ workflow: workflowRaw, graph: null });
+ const loadWorkflow = useCallback(async () => {
+ await loadWorkflowWithDialog({ type: 'object', data: unvalidatedWorkflow });
onClose();
- }, [_loadWorkflow, onClose, workflowRaw]);
+ }, [loadWorkflowWithDialog, unvalidatedWorkflow, onClose]);
return (
@@ -95,7 +97,7 @@ export const LoadWorkflowFromGraphModal = () => {
{t('nodes.workflow')}