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')} - - - - - - {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 ; - })} - + + + + + {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 (