Compare commits

..

96 Commits

Author SHA1 Message Date
Riccardo Giovanetti
e7e25a0c37 translationBot(ui): update translation (Italian)
Currently translated at 98.7% (1849 of 1873 strings)

translationBot(ui): update translation (Italian)

Currently translated at 97.8% (1833 of 1873 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2025-04-08 11:01:37 +10:00
Linos
589b849e64 translationBot(ui): update translation (Vietnamese)
Currently translated at 100.0% (1873 of 1873 strings)

translationBot(ui): update translation (Vietnamese)

Currently translated at 100.0% (1871 of 1871 strings)

translationBot(ui): update translation (Vietnamese)

Currently translated at 99.2% (1857 of 1871 strings)

translationBot(ui): update translation (Vietnamese)

Currently translated at 100.0% (1840 of 1840 strings)

Co-authored-by: Linos <linos.coding@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/vi/
Translation: InvokeAI/Web UI
2025-04-08 11:01:37 +10:00
psychedelicious
aedbc9f778 chore: prep for v5.10.0a1 2025-04-08 10:59:08 +10:00
psychedelicious
a0cf9e2e80 tweak(ui): ip adapter settings layout 2025-04-08 10:33:45 +10:00
psychedelicious
5c8f1c5666 fix(ui): use flux redux influence on regional guidance 2025-04-08 10:33:45 +10:00
psychedelicious
fd37117221 chore(ui): lint 2025-04-08 10:33:45 +10:00
psychedelicious
5956f96e57 feat(ui): add flux redux image influence to canvas 2025-04-08 10:33:45 +10:00
psychedelicious
49622c37ed fix(nodes): logic bug in flux redux node 2025-04-08 10:33:45 +10:00
psychedelicious
50387c8f64 chore(ui): typegen 2025-04-08 10:33:45 +10:00
skunkworxdark
e1538af219 Update flux_redux.py
Add down sampling and weight to redux node
2025-04-08 10:33:45 +10:00
psychedelicious
e5a0010a72 fix(ui): normalize alpha value to 0-1 when picking color on canvas 2025-04-08 08:20:49 +10:00
psychedelicious
b75d1b2473 refactor(ui): move update node logic from listener to hook 2025-04-08 08:18:17 +10:00
psychedelicious
b91bb9ba9f fix(ui): remove debug logger middleware 2025-04-08 08:18:17 +10:00
psychedelicious
a7c818bcae fix(ui): rebase import issue 2025-04-08 08:18:17 +10:00
psychedelicious
a54b255718 chore(ui): lint 2025-04-08 08:18:17 +10:00
psychedelicious
3e04baa684 feat(ui): improved undo/redo history grouping for selections and postiino changes 2025-04-08 08:18:17 +10:00
psychedelicious
d23db705dd feat(ui): improved undo/redo history grouping 2025-04-08 08:18:17 +10:00
psychedelicious
96a481530d refactor(ui): merge the workflow and nodes slices
This allows undo/redo history to apply to node editor and workflow details/form.
2025-04-08 08:18:17 +10:00
psychedelicious
a0b515979a Revert "correctly set is_published when loading a workflow"
This reverts commit e4b07894fd55b3a24fc006882585b6d55fe329c3.
2025-04-08 07:05:12 +10:00
Mary Hipp
2da8ac216b add mutation for unpublishing 2025-04-08 07:05:12 +10:00
Mary Hipp
1558fe9a37 correctly set is_published when loading a workflow 2025-04-08 07:05:12 +10:00
Mary Hipp
ded080ae04 show cancel icon and not retry icon on validation run queue items 2025-04-08 07:05:12 +10:00
psychedelicious
982603e051 fix(ui): use getDefaultForm when resetting form 2025-04-08 06:54:43 +10:00
psychedelicious
a23b5c3408 refactor(ui): make workflow published status server-side state
Whether a workflow is published or not shouldn't be something stored on the client. It's properly server-side state.

This change removes the `is_published` flag from redux and updates all references to the flag to use the getWorkflow query.

It also updates the socket event listener that handles session complete events. When a validation run completes, we invalidate the tags for the getWorkflow query. We need to do a bit of juggling to avoid a race condition (documented in the code). Works well though.
2025-04-08 06:54:43 +10:00
psychedelicious
c9f93b3746 refactor(ui): workflow unsaved changes tracking
Previously, we maintained an `isTouched` flag in redux state to indicate if a workflow had unsaved changes. We manually updated this whenever we changed something on the workflow.

This was tedious and error-prone. It also didn't handle undo/redo, so if you made a change to a node and undid it, we'd still think the workflow had unsaved changes.

Moving forward, we use a simpler and more robust strategy by hashing the server's version of the workflow and comparing it to the client's version of the workflow.

The hashing uses `stable-hash`, which is both fast and, well, stable. Most importantly, the ordering of keys in hashed objects does not change the resultant hash.

- Remove `isTouched` state entirely.
- Extract the logic that builds the "preview" workflow object from redux state into its own hook. This "preview" workflow is what we send to the server when saving a workflow. This "preview" workflow is effectively the client version of the workflow.
- Add `useDoesWorkflowHaveUnsavedChanges()` hook, which compares the hash of the client workflow and server workflow (if it exists).
- Add `useIsWorkflowUntouched()` hook, which compares the hash of the client workflow and the initial workflow that you get when you click new workflow.
- Remove `reactflow` workaround in the nodes slice undo/redo filter. When we set the nodes state while loading a workflow, `reactflow` emits a nodes size/placement change event. This triggered up our `isTouched` flag logic and marked the workflow as unsaved right from the get-go. With the new strategy to track touched status, this workaround can be removed.
- Update all logic that tracked the old `isTouched` flag to use the new hooks.
2025-04-08 06:54:43 +10:00
psychedelicious
e381024cc0 fix(ui): remove debug logger middleware from store setup
Accidentally left in from prev change
2025-04-08 06:54:43 +10:00
psychedelicious
bb65884040 refactor(ui): workflow form root element is a constant
Previously, the workflow form's root element id was random. Every time we reset the workflow editor, the root id changed. This makes it difficult to check if the workflow editor is untouched (in its default state).

Now that root element's id is simply "root". I can't imagine any way that this would break anything.
2025-04-08 06:54:43 +10:00
psychedelicious
920339dbeb refactor(ui): split out the modal isolator component 2025-04-08 06:54:43 +10:00
psychedelicious
0f618bdbcb refactor(ui): split out the hook isolator component 2025-04-08 06:54:43 +10:00
psychedelicious
8294e2cdea feat(mm): support size calculation for onnx models 2025-04-07 11:37:55 +10:00
psychedelicious
7da43be4b7 docs: fix incorrect filename 2025-04-07 10:57:32 +10:00
psychedelicious
8561e9e540 docs: remove legacy scripts documentation 2025-04-07 10:57:32 +10:00
psychedelicious
b0d5e7e3d8 feat(app): restore "Using torch device" message on startup 2025-04-07 10:56:26 +10:00
Eugene Brodsky
ab2d203d5e fix(build): re-add sentencepiece which is apparently needed by gguf, but is not defined as its dependency 2025-04-04 16:26:20 -04:00
Eugene Brodsky
eae5c54091 fix(docker): another pip install is needed in docker build after copying sources 2025-04-04 16:26:20 -04:00
Mary Hipp
ee2b486e8b fix badge for validation run 2025-04-04 11:38:40 -04:00
psychedelicious
a2c7050832 docs: update README.md 2025-04-04 18:42:13 +11:00
psychedelicious
cd090eb76f build: fix path in build script 2025-04-04 18:42:13 +11:00
psychedelicious
3348755e6e ci: fix name of build hweel workflow 2025-04-04 18:42:13 +11:00
psychedelicious
d6dbdaacd1 chore: bump version to v5.10.0dev4 2025-04-04 18:42:13 +11:00
psychedelicious
1c6fa1ad18 ci: update workflows to use revised build scripts 2025-04-04 18:42:13 +11:00
psychedelicious
39bed90eda build: remove installer & convert installer build script to only build the wheel 2025-04-04 18:42:13 +11:00
psychedelicious
c0e48193a7 chore: bump version to v5.10.0dev3 2025-04-04 18:42:13 +11:00
psychedelicious
41677394c0 chore: update uv.lock 2025-04-04 18:42:13 +11:00
psychedelicious
405cfd46e7 build: remove pin on spandrel dependency 2025-04-04 18:42:13 +11:00
psychedelicious
9cc9a5c8b0 build: add comment about torchsde to pyproject 2025-04-04 18:42:13 +11:00
psychedelicious
ddc0461882 build: remove pin on gguf dependency
This allows it to pull in sentencepiece on its own. In 0.10.0, it didn't have this package listed as a dependency, but in recent releases it does. So we are able to remove sentencepiece as an explicit dep.
2025-04-04 18:42:13 +11:00
psychedelicious
0f09091a26 build: remove unused clip_anytorch dependency 2025-04-04 18:42:13 +11:00
psychedelicious
dedb77b6f2 build: remove unused pytorch-lightning dependency 2025-04-04 18:42:13 +11:00
psychedelicious
89f8dbee6c build: remove unused pyreadline3 dependency 2025-04-04 18:42:13 +11:00
psychedelicious
8b0dc8ce84 build: remove unused pyperclip dependency 2025-04-04 18:42:13 +11:00
psychedelicious
018121e407 build: remove unused pympler dependency 2025-04-04 18:42:13 +11:00
psychedelicious
095025b637 build: remove unused scikit-image dependency 2025-04-04 18:42:13 +11:00
psychedelicious
ed8487659e build: remove unused npyscreen dependency 2025-04-04 18:42:13 +11:00
psychedelicious
3745d2be0c build: remove unused torchmetrics dependency 2025-04-04 18:42:13 +11:00
psychedelicious
b5206e204f build: remove unused datasets dependency 2025-04-04 18:42:13 +11:00
psychedelicious
b237ccbdd8 build: remove unused click dependency 2025-04-04 18:42:13 +11:00
psychedelicious
224ebc72ae build: remove unused omegaconf dependency 2025-04-04 18:42:13 +11:00
psychedelicious
05c3d47be9 build: remove unused facexlib dependency 2025-04-04 18:42:13 +11:00
psychedelicious
a4d709c169 build: remove unused timm dependency 2025-04-04 18:42:13 +11:00
psychedelicious
5a8e95c700 chore(ui): typegen 2025-04-04 18:42:13 +11:00
psychedelicious
e630f364df chore: update uv.lock 2025-04-04 18:42:13 +11:00
psychedelicious
9c287038e4 build: remove unused matplotlib dep 2025-04-04 18:42:13 +11:00
psychedelicious
8d32ede082 tidy(nodes): remove matplotlib dependency
It was only used for a single color conversion function. Replaced with cv2 code, tested functionality to confirm it works the same.
2025-04-04 18:42:13 +11:00
psychedelicious
bab0b6d069 build: move humanize to test deps 2025-04-04 18:42:13 +11:00
psychedelicious
8e013ef3be build: remove unused albumentations dependency
This is not used
2025-04-04 18:42:13 +11:00
psychedelicious
8188484a40 tidy: delete unused file 2025-04-04 18:42:13 +11:00
psychedelicious
5d8fe9fb56 build: remove controlnet_aux dependency, remove pin for timm 2025-04-04 18:42:13 +11:00
psychedelicious
8d3743c6f2 tidy(nodes): rename controlnet_image_processors.py -> controlnet.py 2025-04-04 18:42:13 +11:00
psychedelicious
986b7426d2 tidy(nodes): remove unused old dw openpose detector class 2025-04-04 18:42:13 +11:00
psychedelicious
8d8150b47e tidy(nodes): remove deprecated controlnet "processor" nodes 2025-04-04 18:42:13 +11:00
psychedelicious
ae3944b4e0 build: upgrade python to 3.12 in pins 2025-04-04 18:42:13 +11:00
psychedelicious
6f0c5c9c05 build: update uv.lock 2025-04-04 18:42:13 +11:00
psychedelicious
89c999ca58 fix(backend): remove mps_fixes
The fixes in this module monkeypatched `torch` to resolve some issues with FP16 on macOS. These issues have long since been resolved.

Included in the now-removed fixes is `CustomSlicedAttentionProcessor`, which is intended to reduce memory requirements for MPS. This overrides `diffusers`' own `SlicedAttentionProcessor`.

Unfortunately, `attention_type: sliced` produces hot garbage with the fixes and black images without the fixes. So this class appears to now be a moot point.

Regardless, SDPA is supported on MPS and very efficient, so sliced attention is largely obsolete.
2025-04-04 18:42:13 +11:00
psychedelicious
89cefc6a88 chore: bump version to v5.10.0dev2
Doing a dev build so I can test the launcher.
2025-04-04 18:42:13 +11:00
psychedelicious
79e384e71c build: downgrade python to 3.11 in pins 2025-04-04 18:42:13 +11:00
psychedelicious
3ebe96765a build: restore prev setuptools config to fix wheel build 2025-04-04 18:42:13 +11:00
psychedelicious
97e158f13a ci: use py3.12 to build installer 2025-04-04 18:42:13 +11:00
psychedelicious
2b1a36ef4a experiment: add pins.json to repo
The launcher will query this file to get the pins needed for installation
2025-04-04 18:42:13 +11:00
psychedelicious
6824b4b036 chore: bump version to v5.10.0dev1
Doing a dev build so I can test the launcher.
2025-04-04 18:42:13 +11:00
psychedelicious
e8a09a5ed8 chore: update uv.lock for latest pydantic
Ran `uv lock --upgrade-package pydantic`
2025-04-04 18:42:13 +11:00
psychedelicious
c4df7d3cb9 fix(ui): handle updated schema structure during invocation parsing
In https://github.com/pydantic/pydantic/pull/10029, pydantic made an improvement to its generated JSON schemas (OpenAPI schemas). The previous and new generated schemas both meet the schema spec.

When we parse the OpenAPI schema to generate node templates, we use some typeguard to narrow schema components from generic OpenAPI schema objects to a node field schema objects. The narrower node field schema objects contain extra data.

For example, they contain a `field_kind` attribute that indicates it the field is an input field or output field. These extra attributes are not part of the OpenAPI spec (but the spec allows does allow for this extra data).

This typeguard relied on a pydantic implementation detail. This was changed in the linked pydantic PR, which released with v2.9.0. With the change, our typeguard rejects input field schema objects, causing parsing to fail with errors/warnings like `Unhandled input property` in the JS console.

In the UI, this causes many fields - mostly model fields - to not show up in the workflow editor.

The fix for this is very simple - instead of relying on an implementation detail for the typeguard, we can check if the incoming schema object has any of our invoke-specific extra attributes. Specifically, we now look for the presence of the `field_kind` attribute on the incoming schema object. If it is present, we know we are dealing with an invocation input field and can parse it appropriately.
2025-04-04 18:42:13 +11:00
psychedelicious
b9e76afbf5 chore: typegen 2025-04-04 18:42:13 +11:00
psychedelicious
dfd8b8f220 chore: remove pydantic pin 2025-04-04 18:42:13 +11:00
psychedelicious
a089e1bf5c chore(ui): typegen 2025-04-04 18:42:13 +11:00
psychedelicious
875f3fe779 tests: update tests/test_object_serializer_disk.py 2025-04-04 18:42:13 +11:00
psychedelicious
5fa2cf59e2 fix(app): add trusted classes to torch safe globals to prevent errors when loading them
In `ObjectSerializerDisk`, we use `torch.load` to load serialized objects from disk. With torch 2.6.0, torch defaults to `weights_only=True`. As a result, torch will raise when attempting to deserialize anything with an unrecognized class.

For example, our `ConditioningFieldData` class is untrusted. When we load conditioning from disk, we will get a runtime error.

Torch provides a method to add trusted classes to an allowlist. This change adds an arg to `ObjectSerializerDisk` to add a list of safe globals to the allowlist and uses it for both `ObjectSerializerDisk` instances.

Note: My first attempt inferred the class from the generic type arg that `ObjectSerializerDisk` accepts, and added that to the allowlist. Unfortunately, this doesn't work.

For example, `ConditioningFieldData` has a `conditionings` attribute that may be one some other untrusted classes representing model-specific conditioning data. So, even if we allowlist `ConditioningFieldData`, loading will fail when torch deserializes the `conditionings` attribute.
2025-04-04 18:42:13 +11:00
Eugene Brodsky
4d58c222f3 resolve conflict between timm version needed by LLaVA and controlnet-aux 2025-04-04 18:42:13 +11:00
Eugene Brodsky
c27142bb02 reintroduce GPU_DRIVER build arg in CI container build, as it has apparently been removed 2025-04-04 18:42:13 +11:00
Eugene Brodsky
e3c441fda4 remove obsoleted depenencies that were used by the CLI 2025-04-04 18:42:13 +11:00
Eugene Brodsky
6bb102f860 modify docs for python 3.12 2025-04-04 18:42:13 +11:00
Eugene Brodsky
5c45ef1a8c update nodes schema / typegen 2025-04-04 18:42:13 +11:00
Eugene Brodsky
7a218a8040 update uv.lock 2025-04-04 18:42:13 +11:00
Eugene Brodsky
929d86768f refactor Dockerfile; get rid of multi-stage build; upgrade to python 3.12 2025-04-04 18:42:13 +11:00
Eugene Brodsky
3676160496 use uv.lock to pin dependencies 2025-04-04 18:42:13 +11:00
Eugene Brodsky
8e6ebb537b upgrade pytorch and unpin some of the strict dependency pins to facilitate upgrading co-dependencies.
we will use uv.lock to ensure reproducibility
2025-04-04 18:42:13 +11:00
101 changed files with 1400 additions and 1102 deletions

View File

@@ -99,4 +99,15 @@ CMD ["invokeai-web"]
COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
# add sources last to minimize image changes on code changes
COPY invokeai ${INVOKEAI_SRC}/invokeai
COPY invokeai ${INVOKEAI_SRC}/invokeai
# this should not increase image size because we've already installed dependencies
# in a previous layer
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then UV_INDEX="https://download.pytorch.org/whl/cpu"; \
elif [ "$GPU_DRIVER" = "rocm" ]; then UV_INDEX="https://download.pytorch.org/whl/rocm6.2"; \
fi && \
uv pip install -e .

View File

@@ -60,16 +60,11 @@ Next, these jobs run and must pass. They are the same jobs that are run for ever
- **`frontend-checks`**: runs `prettier` (format), `eslint` (lint), `dpdm` (circular refs), `tsc` (static type check) and `knip` (unused imports)
- **`typegen-checks`**: ensures the frontend and backend types are synced
#### `build-installer` Job
#### `build-wheel` Job
This sets up both python and frontend dependencies and builds the python package. Internally, this runs `installer/create_installer.sh` and uploads two artifacts:
This sets up both python and frontend dependencies and builds the python package. Internally, this runs `./scripts/build_wheel.sh` and uploads `dist.zip`, which contains the wheel and unarchived build.
- **`dist`**: the python distribution, to be published on PyPI
- **`InvokeAI-installer-${VERSION}.zip`**: the legacy install scripts
You don't need to download either of these files.
> The legacy install scripts are no longer used, but we haven't updated the workflow to skip building them.
You don't need to download or test these artifacts.
#### Sanity Check & Smoke Test
@@ -79,7 +74,7 @@ It's possible to test the python package before it gets published to PyPI. We've
But, if you want to be extra-super careful, here's how to test it:
- Download the `dist.zip` build artifact from the `build-installer` job
- Download the `dist.zip` build artifact from the `build-wheel` job
- Unzip it and find the wheel file
- Create a fresh Invoke install by following the [manual install guide](https://invoke-ai.github.io/InvokeAI/installation/manual/) - but instead of installing from PyPI, install from the wheel
- Test the app

View File

@@ -1,121 +0,0 @@
# Legacy Scripts
!!! warning "Legacy Scripts"
We recommend using the Invoke Launcher to install and update Invoke. It's a desktop application for Windows, macOS and Linux. It takes care of a lot of nitty gritty details for you.
Follow the [quick start guide](./quick_start.md) to get started.
!!! tip "Use the installer to update"
Using the installer for updates will not erase any of your data (images, models, boards, etc). It only updates the core libraries used to run Invoke.
Simply use the same path you installed to originally to update your existing installation.
Both release and pre-release versions can be installed using the installer. It also supports install through a wheel if needed.
Be sure to review the [installation requirements] and ensure your system has everything it needs to install Invoke.
## Getting the Latest Installer
Download the `InvokeAI-installer-vX.Y.Z.zip` file from the [latest release] page. It is at the bottom of the page, under **Assets**.
After unzipping the installer, you should have a `InvokeAI-Installer` folder with some files inside, including `install.bat` and `install.sh`.
## Running the Installer
!!! tip
Windows users should first double-click the `WinLongPathsEnabled.reg` file to prevent a failed installation due to long file paths.
Double-click the install script:
=== "Windows"
```sh
install.bat
```
=== "Linux/macOS"
```sh
install.sh
```
!!! info "Running the Installer from the commandline"
You can also run the install script from cmd/powershell (Windows) or terminal (Linux/macOS).
!!! warning "Untrusted Publisher (Windows)"
You may get a popup saying the file comes from an `Untrusted Publisher`. Click `More Info` and `Run Anyway` to get past this.
The installation process is simple, with a few prompts:
- Select the version to install. Unless you have a specific reason to install a specific version, select the default (the latest version).
- Select location for the install. Be sure you have enough space in this folder for the base application, as described in the [installation requirements].
- Select a GPU device.
!!! info "Slow Installation"
The installer needs to download several GB of data and install it all. It may appear to get stuck at 99.9% when installing `pytorch` or during a step labeled "Installing collected packages".
If it is stuck for over 10 minutes, something has probably gone wrong and you should close the window and restart.
## Running the Application
Find the install location you selected earlier. Double-click the launcher script to run the app:
=== "Windows"
```sh
invoke.bat
```
=== "Linux/macOS"
```sh
invoke.sh
```
Choose the first option to run the UI. After a series of startup messages, you'll see something like this:
```sh
Uvicorn running on http://127.0.0.1:9090 (Press CTRL+C to quit)
```
Copy the URL into your browser and you should see the UI.
## Improved Outpainting with PatchMatch
PatchMatch is an extra add-on that can improve outpainting. Windows users are in luck - it works out of the box.
On macOS and Linux, a few extra steps are needed to set it up. See the [PatchMatch installation guide](./patchmatch.md).
## First-time Setup
You will need to [install some models] before you can generate.
Check the [configuration docs] for details on configuring the application.
## Updating
Updating is exactly the same as installing - download the latest installer, choose the latest version, enter your existing installation path, and the app will update. None of your data (images, models, boards, etc) will be erased.
!!! info "Dependency Resolution Issues"
We've found that pip's dependency resolution can cause issues when upgrading packages. One very common problem was pip "downgrading" torch from CUDA to CPU, but things broke in other novel ways.
The installer doesn't have this kind of problem, so we use it for updating as well.
## Installation Issues
If you have installation issues, please review the [FAQ]. You can also [create an issue] or ask for help on [discord].
[installation requirements]: ./requirements.md
[FAQ]: ../faq.md
[install some models]: ./models.md
[configuration docs]: ../configuration.md
[latest release]: https://github.com/invoke-ai/InvokeAI/releases/latest
[create an issue]: https://github.com/invoke-ai/InvokeAI/issues
[discord]: https://discord.gg/ZmtBAhwWhy

View File

@@ -49,9 +49,9 @@ If you have an existing Invoke installation, you can select it and let the launc
!!! warning "Problem running the launcher on macOS"
macOS may not allow you to run the launcher. We are working to resolve this by signing the launcher executable. Until that is done, you can either use the [legacy scripts](./legacy_scripts.md) to install, or manually flag the launcher as safe:
macOS may not allow you to run the launcher. We are working to resolve this by signing the launcher executable. Until that is done, you can manually flag the launcher as safe:
- Open the **Invoke-Installer-mac-arm64.dmg** file.
- Open the **Invoke Community Edition.dmg** file.
- Drag the launcher to **Applications**.
- Open a terminal.
- Run `xattr -d 'com.apple.quarantine' /Applications/Invoke\ Community\ Edition.app`.
@@ -117,7 +117,6 @@ If you still have problems, ask for help on the Invoke [discord](https://discord
- You can install the Invoke application as a python package. See our [manual install](./manual.md) docs.
- You can run Invoke with docker. See our [docker install](./docker.md) docs.
- You can still use our legacy scripts to install and run Invoke. See the [legacy scripts](./legacy_scripts.md) docs.
## Need Help?

View File

@@ -1,4 +1,5 @@
from typing import Optional
import math
from typing import Literal, Optional
import torch
from PIL import Image
@@ -39,12 +40,15 @@ class FluxReduxOutput(BaseInvocationOutput):
)
DOWNSAMPLING_FUNCTIONS = Literal["nearest", "bilinear", "bicubic", "area", "nearest-exact"]
@invocation(
"flux_redux",
title="FLUX Redux",
tags=["ip_adapter", "control"],
category="ip_adapter",
version="2.0.0",
version="2.1.0",
classification=Classification.Beta,
)
class FluxReduxInvocation(BaseInvocation):
@@ -61,18 +65,53 @@ class FluxReduxInvocation(BaseInvocation):
title="FLUX Redux Model",
ui_type=UIType.FluxReduxModel,
)
downsampling_factor: int = InputField(
ge=1,
le=9,
default=1,
description="Redux Downsampling Factor (1-9)",
)
downsampling_function: DOWNSAMPLING_FUNCTIONS = InputField(
default="area",
description="Redux Downsampling Function",
)
weight: float = InputField(
ge=0,
le=1,
default=1.0,
description="Redux weight (0.0-1.0)",
)
def invoke(self, context: InvocationContext) -> FluxReduxOutput:
image = context.images.get_pil(self.image.image_name, "RGB")
encoded_x = self._siglip_encode(context, image)
redux_conditioning = self._flux_redux_encode(context, encoded_x)
if self.downsampling_factor > 1 or self.weight != 1.0:
redux_conditioning = self._downsample_weight(context, redux_conditioning)
tensor_name = context.tensors.save(redux_conditioning)
return FluxReduxOutput(
redux_cond=FluxReduxConditioningField(conditioning=TensorField(tensor_name=tensor_name), mask=self.mask)
)
@torch.no_grad()
def _downsample_weight(self, context: InvocationContext, redux_conditioning: torch.Tensor) -> torch.Tensor:
# Downsampling derived from https://github.com/kaibioinfo/ComfyUI_AdvancedRefluxControl
(b, t, h) = redux_conditioning.shape
m = int(math.sqrt(t))
if self.downsampling_factor > 1:
redux_conditioning = redux_conditioning.view(b, m, m, h)
redux_conditioning = torch.nn.functional.interpolate(
redux_conditioning.transpose(1, -1),
size=(m // self.downsampling_factor, m // self.downsampling_factor),
mode=self.downsampling_function,
)
redux_conditioning = redux_conditioning.transpose(1, -1).reshape(b, -1, h)
if self.weight != 1.0:
redux_conditioning = redux_conditioning * self.weight * self.weight
return redux_conditioning
@torch.no_grad()
def _siglip_encode(self, context: InvocationContext, image: Image.Image) -> torch.Tensor:
siglip_model_config = self._get_siglip_model(context)

View File

@@ -31,6 +31,12 @@ def run_app() -> None:
if app_config.pytorch_cuda_alloc_conf:
configure_torch_cuda_allocator(app_config.pytorch_cuda_alloc_conf, logger)
# This import must happen after configure_torch_cuda_allocator() is called, because the module imports torch.
from invokeai.backend.util.devices import TorchDevice
torch_device_name = TorchDevice.get_torch_device_name()
logger.info(f"Using torch device: {torch_device_name}")
# Import from startup_utils here to avoid importing torch before configure_torch_cuda_allocator() is called.
from invokeai.app.util.startup_utils import (
apply_monkeypatches,

View File

@@ -6,6 +6,7 @@ import logging
from pathlib import Path
from typing import Optional
import onnxruntime as ort
import torch
from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from diffusers.schedulers.scheduling_utils import SchedulerMixin
@@ -55,6 +56,16 @@ def calc_model_size_by_data(logger: logging.Logger, model: AnyModel) -> int:
),
):
return model.calc_size()
elif isinstance(model, ort.InferenceSession):
if model._model_bytes is not None:
# If the model is already loaded, return the size of the model bytes
return len(model._model_bytes)
elif model._model_path is not None:
# If the model is not loaded, return the size of the model path
return calc_model_size_by_fs(Path(model._model_path))
else:
# If neither is available, return 0
return 0
elif isinstance(
model,
(

View File

@@ -1306,7 +1306,10 @@
"unableToCopy": "Unable to Copy",
"unableToCopyDesc": "Your browser does not support clipboard access. Firefox users may be able to fix this by following ",
"unableToCopyDesc_theseSteps": "these steps",
"fluxFillIncompatibleWithT2IAndI2I": "FLUX Fill is not compatible with Text to Image or Image to Image. Use other FLUX models for these tasks."
"fluxFillIncompatibleWithT2IAndI2I": "FLUX Fill is not compatible with Text to Image or Image to Image. Use other FLUX models for these tasks.",
"problemUnpublishingWorkflow": "Problem Unpublishing Workflow",
"problemUnpublishingWorkflowDescription": "There was a problem unpublishing the workflow. Please try again.",
"workflowUnpublished": "Workflow Unpublished"
},
"popovers": {
"clipSkip": {
@@ -1786,8 +1789,8 @@
"minimum": "Minimum",
"maximum": "Maximum",
"publish": "Publish",
"published": "Published",
"unpublish": "Unpublish",
"published": "Published",
"workflowLocked": "Workflow Locked",
"workflowLockedPublished": "Published workflows are locked for editing.\nYou can unpublish the workflow to edit it, or make a copy of it.",
"workflowLockedDuringPublishing": "Workflow is locked while configuring for publishing.",
@@ -2017,6 +2020,14 @@
"composition": "Composition Only",
"compositionDesc": "Replicates layout & structure while ignoring the reference's style."
},
"fluxReduxImageInfluence": {
"imageInfluence": "Image Influence",
"lowest": "Lowest",
"low": "Low",
"medium": "Medium",
"high": "High",
"highest": "Highest"
},
"fill": {
"fillColor": "Fill Color",
"fillStyle": "Fill Style",

View File

@@ -1790,7 +1790,37 @@
"maximum": "Massimo",
"dropdown": "Elenco a discesa",
"addOption": "Aggiungi opzione",
"resetOptions": "Reimposta opzioni"
"resetOptions": "Reimposta opzioni",
"publish": "Pubblica",
"workflowLocked": "Flusso di lavoro bloccato",
"workflowLockedDuringPublishing": "Il flusso di lavoro è bloccato durante la configurazione per la pubblicazione.",
"selectOutputNode": "Seleziona nodo di uscita",
"changeOutputNode": "Cambia nodo di uscita",
"publishedWorkflowOutputs": "Uscite",
"noPublishableInputs": "Nessun ingresso pubblicabile",
"published": "Pubblicato",
"cannotPublish": "Impossibile pubblicare il flusso di lavoro",
"noOutputNodeSelected": "Nessun nodo di uscita selezionato",
"unpublish": "Annulla pubblicazione",
"workflowLockedPublished": "I flussi di lavoro pubblicati sono bloccati per la modifica.\nPuoi annullare la pubblicazione del flusso di lavoro per modificarlo o crearne una copia.",
"publishedWorkflowInputs": "Ingressi",
"unpublishableInputs": "Questi input non pubblicabili verranno omessi",
"publishWarnings": "Avvertenze",
"errorWorkflowHasUnsavedChanges": "Il flusso di lavoro presenta modifiche non salvate",
"errorWorkflowHasBatchOrGeneratorNodes": "Il flusso di lavoro ha nodi lotto e/o generatori",
"errorWorkflowHasInvalidGraph": "Grafico del flusso di lavoro non valido (passare il mouse sul pulsante Invoke per i dettagli)",
"errorWorkflowHasNoOutputNode": "Nessun nodo di uscita selezionato",
"warningWorkflowHasUnpublishableInputFields": "Il flusso di lavoro presenta alcuni ingressi non pubblicabili: questi verranno omessi dal flusso di lavoro pubblicato",
"publishFailed": "Pubblicazione non riuscita",
"publishFailedDesc": "Si è verificato un problema durante la pubblicazione del flusso di lavoro. Riprova.",
"publishSuccess": "Il tuo flusso di lavoro è in fase di pubblicazione",
"publishSuccessDesc": "Controlla il <LinkComponent>pannello di controllo del progetto</LinkComponent> per verificarne l'avanzamento.",
"publishedWorkflowIsLocked": "Il flusso di lavoro pubblicato è bloccato",
"publishingValidationRun": "Esecuzione della convalida della pubblicazione",
"publishingValidationRunInProgress": "È in corso la convalida della pubblicazione.",
"publishedWorkflowsLocked": "I flussi di lavoro pubblicati sono bloccati e non possono essere modificati o eseguiti. Annulla la pubblicazione del flusso di lavoro o salva una copia per modificare o eseguire questo flusso di lavoro.",
"warningWorkflowHasNoPublishableInputFields": "Nessun campo di ingresso pubblicabile selezionato: il flusso di lavoro pubblicato verrà eseguito solo con i valori predefiniti",
"publishInProgress": "Pubblicazione in corso"
},
"loadMore": "Carica altro",
"searchPlaceholder": "Cerca per nome, descrizione o etichetta",
@@ -1807,7 +1837,8 @@
"noRecentWorkflows": "Nessun flusso di lavoro recente",
"view": "Visualizza",
"recommended": "Consigliato per te",
"emptyStringPlaceholder": "<stringa vuota>"
"emptyStringPlaceholder": "<stringa vuota>",
"published": "Pubblicato"
},
"accordions": {
"compositing": {

View File

@@ -2229,7 +2229,7 @@
"workflows": {
"delete": "Xoá",
"descending": "Giảm Dần",
"created": "Ngày Tạo",
"created": "Đã Tạo",
"edit": "Chỉnh Sửa",
"download": "Tải Xuống",
"copyShareLink": "Sao Chép Liên Kết Chia Sẻ",
@@ -2255,7 +2255,7 @@
"saveWorkflow": "Lưu Workflow",
"problemSavingWorkflow": "Có Vấn Đề Khi Lưu Workflow",
"noDescription": "Không có mô tả",
"updated": "Ngày Cập Nhật",
"updated": "Đã Cập Nhật",
"uploadWorkflow": "Tải Từ Tệp",
"autoLayout": "Bố Trí Tự Động",
"loadWorkflow": "$t(common.load) Workflow",
@@ -2267,7 +2267,7 @@
"saveWorkflowToProject": "Lưu Workflow Vào Dự Án",
"workflowName": "Tên Workflow",
"workflowLibrary": "Thư Viện Workflow",
"opened": "Ngày Mở",
"opened": "Đã Mở",
"deleteWorkflow": "Xoá Workflow",
"workflowEditorMenu": "Menu Biên Tập Workflow",
"openLibrary": "Mở Thư Viện",
@@ -2306,7 +2306,39 @@
"containerColumnLayout": "Hộp Chứa (bố cục cột)",
"resetOptions": "Tải Lại Lựa Chọn",
"addOption": "Thêm Lựa Chọn",
"dropdown": "Danh Sách Thả Xuống"
"dropdown": "Danh Sách Thả Xuống",
"publish": "Đăng Tải",
"published": "Đã Đăng",
"workflowLocked": "Workflow Bị Khóa",
"workflowLockedDuringPublishing": "Workflow bị khóa khi đang điều chỉnh để đăng tải.",
"selectOutputNode": "Chọn Node Đầu Ra",
"changeOutputNode": "Đổi Node Đầu Ra",
"publishedWorkflowOutputs": "Đầu Ra",
"unpublishableInputs": "Những đầu vào không đăng tải được sẽ bị bỏ sót",
"noPublishableInputs": "Không có đầu vào không đăng tải được",
"noOutputNodeSelected": "Không có node đầu ra được chọn",
"publishWarnings": "Cảnh Báo",
"errorWorkflowHasUnsavedChanges": "Workflow có các thay đổi chưa lưu",
"cannotPublish": "Không thể đăng workflow",
"publishedWorkflowInputs": "Đầu Vào",
"unpublish": "Chưa Đăng",
"workflowLockedPublished": "Workflow được đăng tải sẽ bị khóa không thể biên tập.\nBạn có thể ngừng đăng để chỉnh sửa, hoặc tạo một bản sao của nó.",
"errorWorkflowHasBatchOrGeneratorNodes": "Workflow có lô node và/hoặc node sản sinh",
"errorWorkflowHasInvalidGraph": "Đồ thị workflow không hợp lệ (di chuột đến nút Khởi Động để xem chi tiết)",
"errorWorkflowHasNoOutputNode": "Không có node đầu ra được chọn",
"warningWorkflowHasUnpublishableInputFields": "Workflow có một số đầu ra không đăng được - chúng sẽ bị bỏ sót khỏi workflow",
"publishFailed": "Đăng Tải Thất Bại",
"publishFailedDesc": "Có vấn đề khi đăng tải workflow. Xin vui lòng thử lại.",
"publishSuccessDesc": "Kiểm tra <LinkComponent>Bảng Dự Án</LinkComponent> để xem tiến độ.",
"publishingValidationRun": "Kiểm Tra Tính Hợp Lệ",
"publishedWorkflowsLocked": "Workflow đã đăng sẽ bị khóa và không thể biên tập hoặc chạy nữa. Hoặc là ngừng đăng, hoặc là lưu một bản sao của chính nó để biên tập hay chạy workflow này.",
"publishInProgress": "Quá trình đăng tải đang diễn ra",
"warningWorkflowHasNoPublishableInputFields": "Không có vùng đầu vào đăng tải được được chọn - workflow sẽ chạy với các giá trị mặc định",
"publishSuccess": "Workflow của bạn đã được đăng",
"publishedWorkflowIsLocked": "Workflow đã đăng đang bị khóa",
"publishingValidationRunInProgress": "Quá trình kiểm tra tính hợp lệ đang diễn ra.",
"selectingOutputNodeDesc": "Bấm vào node để biến nó thành node đầu ra của workflow.",
"selectingOutputNode": "Chọn node đầu ra"
},
"yourWorkflows": "Workflow Của Bạn",
"browseWorkflows": "Khám Phá Workflow",
@@ -2323,7 +2355,8 @@
"deselectAll": "Huỷ Chọn Tất Cả",
"noRecentWorkflows": "Không Có Workflows Gần Đây",
"recommended": "Có Thể Bạn Sẽ Cần",
"emptyStringPlaceholder": "<xâu ký tự trống>"
"emptyStringPlaceholder": "<xâu ký tự trống>",
"published": "Đã Đăng"
},
"upscaling": {
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",

View File

@@ -1,54 +1,15 @@
import { Box, useGlobalModifiersInit } from '@invoke-ai/ui-library';
import { Box } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import { GlobalHookIsolator } from 'app/components/GlobalHookIsolator';
import { GlobalModalIsolator } from 'app/components/GlobalModalIsolator';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { $didStudioInit, useStudioInitAction } from 'app/hooks/useStudioInitAction';
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
import { useLogger } from 'app/logging/useLogger';
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useClearStorage } from 'common/hooks/useClearStorage';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import {
NewCanvasSessionDialog,
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal';
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { useReadinessWatcher } from 'features/queue/store/readiness';
import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
import { VideosModal } from 'features/system/components/VideosModal/VideosModal';
import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
import { AppContent } from 'features/ui/components/AppContent';
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import i18n from 'i18n';
import { size } from 'lodash-es';
import { memo, useCallback, useEffect } from 'react';
import { memo, useCallback } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import { useSocketIO } from 'services/events/useSocketIO';
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
const DEFAULT_CONFIG = {};
@@ -74,83 +35,10 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
<AppContent />
{!didStudioInit && <Loading />}
</Box>
<HookIsolator config={config} studioInitAction={studioInitAction} />
<ModalIsolator />
<GlobalHookIsolator config={config} studioInitAction={studioInitAction} />
<GlobalModalIsolator />
</ErrorBoundary>
);
};
export default memo(App);
// Running these hooks in a separate component ensures we do not inadvertently rerender the entire app when they change.
const HookIsolator = memo(
({ config, studioInitAction }: { config: PartialAppConfig; studioInitAction?: StudioInitAction }) => {
const language = useAppSelector(selectLanguage);
const logger = useLogger('system');
const dispatch = useAppDispatch();
// singleton!
useReadinessWatcher();
useSocketIO();
useGlobalModifiersInit();
useGlobalHotkeys();
useGetOpenAPISchemaQuery();
useSyncLoggingConfig();
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
useEffect(() => {
if (size(config)) {
logger.info({ config }, 'Received config');
dispatch(configChanged(config));
}
}, [dispatch, config, logger]);
useEffect(() => {
dispatch(appStarted());
}, [dispatch]);
useStudioInitAction(studioInitAction);
useStarterModelsToast();
useSyncQueueStatus();
useFocusRegionWatcher();
return null;
}
);
HookIsolator.displayName = 'HookIsolator';
const ModalIsolator = memo(() => {
return (
<>
<DeleteImageModal />
<ChangeBoardModal />
<DynamicPromptsModal />
<StylePresetModal />
<WorkflowLibraryModal />
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
<ClearQueueConfirmationsAlertDialog />
<NewWorkflowConfirmationAlertDialog />
<LoadWorkflowConfirmationAlertDialog />
<DeleteStylePresetDialog />
<DeleteWorkflowDialog />
<ShareWorkflowModal />
<RefreshAfterResetModal />
<DeleteBoardModal />
<GlobalImageHotkeys />
<NewGallerySessionDialog />
<NewCanvasSessionDialog />
<ImageContextMenu />
<FullscreenDropzone />
<VideosModal />
<SaveWorkflowAsDialog />
<CanvasManagerProviderGate>
<CanvasPasteModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
</>
);
});
ModalIsolator.displayName = 'ModalIsolator';

View File

@@ -0,0 +1,65 @@
import { useGlobalModifiersInit } from '@invoke-ai/ui-library';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
import { useLogger } from 'app/logging/useLogger';
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { PartialAppConfig } from 'app/types/invokeai';
import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useReadinessWatcher } from 'features/queue/store/readiness';
import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
import i18n from 'i18n';
import { size } from 'lodash-es';
import { memo, useEffect } from 'react';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import { useSocketIO } from 'services/events/useSocketIO';
/**
* GlobalHookIsolator is a logical component that runs global hooks in an isolated component, so that they do not
* cause needless re-renders of any other components.
*/
export const GlobalHookIsolator = memo(
({ config, studioInitAction }: { config: PartialAppConfig; studioInitAction?: StudioInitAction }) => {
const language = useAppSelector(selectLanguage);
const logger = useLogger('system');
const dispatch = useAppDispatch();
// singleton!
useReadinessWatcher();
useSocketIO();
useGlobalModifiersInit();
useGlobalHotkeys();
useGetOpenAPISchemaQuery();
useSyncLoggingConfig();
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
useEffect(() => {
if (size(config)) {
logger.info({ config }, 'Received config');
dispatch(configChanged(config));
}
}, [dispatch, config, logger]);
useEffect(() => {
dispatch(appStarted());
}, [dispatch]);
useStudioInitAction(studioInitAction);
useStarterModelsToast();
useSyncQueueStatus();
useFocusRegionWatcher();
useWorkflowBuilderWatcher();
return null;
}
);
GlobalHookIsolator.displayName = 'GlobalHookIsolator';

View File

@@ -0,0 +1,64 @@
import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal';
import {
NewCanvasSessionDialog,
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal';
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
import { VideosModal } from 'features/system/components/VideosModal/VideosModal';
import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal';
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import { memo } from 'react';
/**
* GlobalModalIsolator is a logical component that isolates global modal components, so that they do not cause needless
* re-renders of any other components.
*/
export const GlobalModalIsolator = memo(() => {
return (
<>
<DeleteImageModal />
<ChangeBoardModal />
<DynamicPromptsModal />
<StylePresetModal />
<WorkflowLibraryModal />
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
<ClearQueueConfirmationsAlertDialog />
<NewWorkflowConfirmationAlertDialog />
<LoadWorkflowConfirmationAlertDialog />
<DeleteStylePresetDialog />
<DeleteWorkflowDialog />
<ShareWorkflowModal />
<RefreshAfterResetModal />
<DeleteBoardModal />
<GlobalImageHotkeys />
<NewGallerySessionDialog />
<NewCanvasSessionDialog />
<ImageContextMenu />
<FullscreenDropzone />
<VideosModal />
<SaveWorkflowAsDialog />
<CanvasManagerProviderGate>
<CanvasPasteModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
</>
);
});
GlobalModalIsolator.displayName = 'GlobalModalIsolator';

View File

@@ -1,20 +1,7 @@
import type { UnknownAction } from '@reduxjs/toolkit';
import { isAnyGraphBuilt } from 'features/nodes/store/actions';
import { appInfoApi } from 'services/api/endpoints/appInfo';
import type { Graph } from 'services/api/types';
export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
if (isAnyGraphBuilt(action)) {
if (action.payload.nodes) {
const sanitizedNodes: Graph['nodes'] = {};
return {
...action,
payload: { ...action.payload, nodes: sanitizedNodes },
};
}
}
if (appInfoApi.endpoints.getOpenAPISchema.matchFulfilled(action)) {
return {
...action,

View File

@@ -25,7 +25,6 @@ import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware
import { addDynamicPromptsListener } from 'app/store/middleware/listenerMiddleware/listeners/promptChanged';
import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings';
import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
import { addUpdateAllNodesRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested';
import type { AppDispatch, RootState } from 'app/store/store';
import { addArchivedOrDeletedBoardListener } from './listeners/addArchivedOrDeletedBoardListener';
@@ -85,9 +84,6 @@ addArchivedOrDeletedBoardListener(startAppListening);
// Node schemas
addGetOpenAPISchemaListener(startAppListening);
// Workflows
addUpdateAllNodesRequestedListener(startAppListening);
// Models
addModelSelectedListener(startAppListening);

View File

@@ -1,69 +0,0 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { updateAllNodesRequested } from 'features/nodes/store/actions';
import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { NodeUpdateError } from 'features/nodes/types/error';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
const log = logger('workflows');
export const addUpdateAllNodesRequestedListener = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: updateAllNodesRequested,
effect: (action, { dispatch, getState }) => {
const nodes = selectNodes(getState());
const templates = $templates.get();
let unableToUpdateCount = 0;
nodes.filter(isInvocationNode).forEach((node) => {
const template = templates[node.data.type];
if (!template) {
unableToUpdateCount++;
return;
}
if (!getNeedsUpdate(node.data, template)) {
// No need to increment the count here, since we're not actually updating
return;
}
try {
const updatedNode = updateNode(node, template);
dispatch(
nodesChanged([
{ type: 'remove', id: updatedNode.id },
{ type: 'add', item: updatedNode },
])
);
} catch (e) {
if (e instanceof NodeUpdateError) {
unableToUpdateCount++;
}
}
});
if (unableToUpdateCount) {
log.warn(
t('nodes.unableToUpdateNodes', {
count: unableToUpdateCount,
})
);
toast({
id: 'UNABLE_TO_UPDATE_NODES',
title: t('nodes.unableToUpdateNodes', {
count: unableToUpdateCount,
}),
});
} else {
toast({
id: 'ALL_NODES_UPDATED',
title: t('nodes.allNodesUpdated'),
status: 'success',
});
}
},
});
};

View File

@@ -3,7 +3,6 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too
import { logger } from 'app/logging/logger';
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
import { getDebugLoggerMiddleware } from 'app/store/middleware/debugLoggerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
@@ -22,7 +21,6 @@ import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/model
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';
import { queueSlice } from 'features/queue/store/queueSlice';
import { stylePresetPersistConfig, stylePresetSlice } from 'features/stylePresets/store/stylePresetSlice';
@@ -60,7 +58,6 @@ const allReducers = {
[changeBoardModalSlice.name]: changeBoardModalSlice.reducer,
[modelManagerV2Slice.name]: modelManagerV2Slice.reducer,
[queueSlice.name]: queueSlice.reducer,
[workflowSlice.name]: workflowSlice.reducer,
[hrfSlice.name]: hrfSlice.reducer,
[canvasSlice.name]: undoable(canvasSlice.reducer, canvasUndoableConfig),
[workflowSettingsSlice.name]: workflowSettingsSlice.reducer,
@@ -103,7 +100,6 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[galleryPersistConfig.name]: galleryPersistConfig,
[nodesPersistConfig.name]: nodesPersistConfig,
[systemPersistConfig.name]: systemPersistConfig,
[workflowPersistConfig.name]: workflowPersistConfig,
[uiPersistConfig.name]: uiPersistConfig,
[dynamicPromptsPersistConfig.name]: dynamicPromptsPersistConfig,
[modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig,
@@ -176,7 +172,6 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
.concat(api.middleware)
.concat(dynamicMiddlewares)
.concat(authToastMiddleware)
.concat(getDebugLoggerMiddleware())
.prepend(listenerMiddleware.middleware),
enhancers: (getDefaultEnhancers) => {
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());

View File

@@ -0,0 +1,60 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import type { FLUXReduxImageInfluence as FLUXReduxImageInfluenceType } from 'features/controlLayers/store/types';
import { isFLUXReduxImageInfluence } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { assert } from 'tsafe';
type Props = {
imageInfluence: FLUXReduxImageInfluenceType;
onChange: (imageInfluence: FLUXReduxImageInfluenceType) => void;
};
export const FLUXReduxImageInfluence = memo(({ imageInfluence, onChange }: Props) => {
const { t } = useTranslation();
const options = useMemo(
() =>
[
{
label: t('controlLayers.fluxReduxImageInfluence.lowest'),
value: 'lowest',
},
{
label: t('controlLayers.fluxReduxImageInfluence.low'),
value: 'low',
},
{
label: t('controlLayers.fluxReduxImageInfluence.medium'),
value: 'medium',
},
{
label: t('controlLayers.fluxReduxImageInfluence.high'),
value: 'high',
},
{
label: t('controlLayers.fluxReduxImageInfluence.highest'),
value: 'highest',
},
] satisfies { label: string; value: FLUXReduxImageInfluenceType }[],
[t]
);
const _onChange = useCallback<ComboboxOnChange>(
(v) => {
assert(isFLUXReduxImageInfluence(v?.value));
onChange(v.value);
},
[onChange]
);
const value = useMemo(() => options.find((o) => o.value === imageInfluence), [options, imageInfluence]);
return (
<FormControl>
<FormLabel m={0}>{t('controlLayers.fluxReduxImageInfluence.imageInfluence')}</FormLabel>
<Combobox value={value} options={options} onChange={_onChange} />
</FormControl>
);
});
FLUXReduxImageInfluence.displayName = 'FLUXReduxImageInfluence';

View File

@@ -61,7 +61,7 @@ export const IPAdapterImagePreview = memo(
)}
{imageDTO && (
<>
<DndImage imageDTO={imageDTO} />
<DndImage imageDTO={imageDTO} borderWidth={1} borderStyle="solid" />
<Flex position="absolute" flexDir="column" top={2} insetInlineEnd={2} gap={1}>
<DndImageIcon
onClick={handleResetControlImage}

View File

@@ -50,7 +50,7 @@ export const IPAdapterMethod = memo(({ method, onChange }: Props) => {
return (
<FormControl>
<InformationalPopover feature="ipAdapterMethod">
<FormLabel>{t('controlLayers.ipAdapterMethod.ipAdapterMethod')}</FormLabel>
<FormLabel m={0}>{t('controlLayers.ipAdapterMethod.ipAdapterMethod')}</FormLabel>
</InformationalPopover>
<Combobox value={value} options={options} onChange={_onChange} />
</FormControl>

View File

@@ -5,6 +5,7 @@ import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginE
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 { FLUXReduxImageInfluence } from 'features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence';
import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod';
import { IPAdapterSettingsEmptyState } from 'features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@@ -13,6 +14,7 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import {
referenceImageIPAdapterBeginEndStepPctChanged,
referenceImageIPAdapterCLIPVisionModelChanged,
referenceImageIPAdapterFLUXReduxImageInfluenceChanged,
referenceImageIPAdapterImageChanged,
referenceImageIPAdapterMethodChanged,
referenceImageIPAdapterModelChanged,
@@ -20,7 +22,12 @@ import {
} from 'features/controlLayers/store/canvasSlice';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice, selectEntity, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier, CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
import type {
CanvasEntityIdentifier,
CLIPVisionModelV2,
FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
IPMethodV2,
} from 'features/controlLayers/store/types';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
@@ -65,6 +72,13 @@ const IPAdapterSettingsContent = memo(() => {
[dispatch, entityIdentifier]
);
const onChangeFLUXReduxImageInfluence = useCallback(
(imageInfluence: FLUXReduxImageInfluenceType) => {
dispatch(referenceImageIPAdapterFLUXReduxImageInfluenceChanged({ entityIdentifier, imageInfluence }));
},
[dispatch, entityIdentifier]
);
const onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => {
dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier, modelConfig }));
@@ -116,7 +130,7 @@ const IPAdapterSettingsContent = memo(() => {
icon={<PiBoundingBoxBold />}
/>
</Flex>
<Flex gap={2} w="full" alignItems="center">
<Flex gap={2} w="full">
{ipAdapter.type === 'ip_adapter' && (
<Flex flexDir="column" gap={2} w="full">
{!isFLUX && <IPAdapterMethod method={ipAdapter.method} onChange={onChangeIPMethod} />}
@@ -124,6 +138,14 @@ const IPAdapterSettingsContent = memo(() => {
<BeginEndStepPct beginEndStepPct={ipAdapter.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
</Flex>
)}
{ipAdapter.type === 'flux_redux' && (
<Flex flexDir="column" gap={2} w="full" alignItems="flex-start">
<FLUXReduxImageInfluence
imageInfluence={ipAdapter.imageInfluence ?? 'lowest'}
onChange={onChangeFLUXReduxImageInfluence}
/>
</Flex>
)}
<Flex alignItems="center" justifyContent="center" h={32} w={32} aspectRatio="1/1" flexGrow={1}>
<IPAdapterImagePreview
image={ipAdapter.image}

View File

@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { Weight } from 'features/controlLayers/components/common/Weight';
import { CLIPVisionModel } from 'features/controlLayers/components/IPAdapter/CLIPVisionModel';
import { FLUXReduxImageInfluence } from 'features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence';
import { IPAdapterImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterImagePreview';
import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod';
import { IPAdapterModel } from 'features/controlLayers/components/IPAdapter/IPAdapterModel';
@@ -15,13 +16,19 @@ import {
rgIPAdapterBeginEndStepPctChanged,
rgIPAdapterCLIPVisionModelChanged,
rgIPAdapterDeleted,
rgIPAdapterFLUXReduxImageInfluenceChanged,
rgIPAdapterImageChanged,
rgIPAdapterMethodChanged,
rgIPAdapterModelChanged,
rgIPAdapterWeightChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier, CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
import type {
CanvasEntityIdentifier,
CLIPVisionModelV2,
FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
IPMethodV2,
} from 'features/controlLayers/store/types';
import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
@@ -73,6 +80,13 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeFLUXReduxImageInfluence = useCallback(
(imageInfluence: FLUXReduxImageInfluenceType) => {
dispatch(rgIPAdapterFLUXReduxImageInfluenceChanged({ entityIdentifier, referenceImageId, imageInfluence }));
},
[dispatch, entityIdentifier, referenceImageId]
);
const onChangeModel = useCallback(
(modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => {
dispatch(rgIPAdapterModelChanged({ entityIdentifier, referenceImageId, modelConfig }));
@@ -151,6 +165,14 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro
<BeginEndStepPct beginEndStepPct={ipAdapter.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
</Flex>
)}
{ipAdapter.type === 'flux_redux' && (
<Flex flexDir="column" gap={2} w="full">
<FLUXReduxImageInfluence
imageInfluence={ipAdapter.imageInfluence ?? 'lowest'}
onChange={onChangeFLUXReduxImageInfluence}
/>
</Flex>
)}
<Flex alignItems="center" justifyContent="center" h={32} w={32} aspectRatio="1/1" flexGrow={1}>
<IPAdapterImagePreview
image={ipAdapter.image}

View File

@@ -407,8 +407,7 @@ export class CanvasColorPickerToolModule extends CanvasModuleBase {
onStagePointerUp = (_e: KonvaEventObject<PointerEvent>) => {
const color = this.$colorUnderCursor.get();
const settings = this.manager.stateApi.getSettings();
this.manager.stateApi.setColor({ ...settings.color, ...color });
this.manager.stateApi.setColor({ ...color, a: color.a / 255 });
};
onStagePointerMove = (_e: KonvaEventObject<PointerEvent>) => {

View File

@@ -21,6 +21,7 @@ import type {
ControlLoRAConfig,
EntityMovedByPayload,
FillStyle,
FLUXReduxImageInfluence,
RegionalGuidanceReferenceImageState,
RgbColor,
} from 'features/controlLayers/store/types';
@@ -626,6 +627,20 @@ export const canvasSlice = createSlice({
}
entity.ipAdapter.method = method;
},
referenceImageIPAdapterFLUXReduxImageInfluenceChanged: (
state,
action: PayloadAction<EntityIdentifierPayload<{ imageInfluence: FLUXReduxImageInfluence }, 'reference_image'>>
) => {
const { entityIdentifier, imageInfluence } = action.payload;
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
return;
}
if (entity.ipAdapter.type !== 'flux_redux') {
return;
}
entity.ipAdapter.imageInfluence = imageInfluence;
},
referenceImageIPAdapterModelChanged: (
state,
action: PayloadAction<
@@ -926,6 +941,26 @@ export const canvasSlice = createSlice({
referenceImage.ipAdapter.method = method;
},
rgIPAdapterFLUXReduxImageInfluenceChanged: (
state,
action: PayloadAction<
EntityIdentifierPayload<
{ referenceImageId: string; imageInfluence: FLUXReduxImageInfluence },
'regional_guidance'
>
>
) => {
const { entityIdentifier, referenceImageId, imageInfluence } = action.payload;
const referenceImage = selectRegionalGuidanceReferenceImage(state, entityIdentifier, referenceImageId);
if (!referenceImage) {
return;
}
if (referenceImage.ipAdapter.type !== 'flux_redux') {
return;
}
referenceImage.ipAdapter.imageInfluence = imageInfluence;
},
rgIPAdapterModelChanged: (
state,
action: PayloadAction<
@@ -1731,6 +1766,7 @@ export const {
referenceImageIPAdapterCLIPVisionModelChanged,
referenceImageIPAdapterWeightChanged,
referenceImageIPAdapterBeginEndStepPctChanged,
referenceImageIPAdapterFLUXReduxImageInfluenceChanged,
// Regions
rgAdded,
// rgRecalled,
@@ -1746,6 +1782,7 @@ export const {
rgIPAdapterMethodChanged,
rgIPAdapterModelChanged,
rgIPAdapterCLIPVisionModelChanged,
rgIPAdapterFLUXReduxImageInfluenceChanged,
// Inpaint mask
inpaintMaskAdded,
inpaintMaskConvertedToRegionalGuidance,

View File

@@ -233,10 +233,15 @@ const zIPAdapterConfig = z.object({
});
export type IPAdapterConfig = z.infer<typeof zIPAdapterConfig>;
const zFLUXReduxImageInfluence = z.enum(['lowest', 'low', 'medium', 'high', 'highest']);
export const isFLUXReduxImageInfluence = (v: unknown): v is FLUXReduxImageInfluence =>
zFLUXReduxImageInfluence.safeParse(v).success;
export type FLUXReduxImageInfluence = z.infer<typeof zFLUXReduxImageInfluence>;
const zFLUXReduxConfig = z.object({
type: z.literal('flux_redux'),
image: zImageWithDims.nullable(),
model: zServerValidatedModelIdentifierField.nullable(),
imageInfluence: zFLUXReduxImageInfluence.default('highest'),
});
export type FLUXReduxConfig = z.infer<typeof zFLUXReduxConfig>;

View File

@@ -75,6 +75,7 @@ export const initialFLUXRedux: FLUXReduxConfig = {
type: 'flux_redux',
image: null,
model: null,
imageInfluence: 'highest',
};
export const initialT2IAdapter: T2IAdapterConfig = {
type: 't2i_adapter',

View File

@@ -1,7 +1,7 @@
import { ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,7 +11,7 @@ const ClearFlowButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const isTouched = useAppSelector(selectWorkflowIsTouched);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const handleNewWorkflow = useCallback(() => {
dispatch(nodeEditorReset());
@@ -26,12 +26,12 @@ const ClearFlowButton = () => {
}, [dispatch, onClose, t]);
const onClick = useCallback(() => {
if (!isTouched) {
if (doesWorkflowHaveUnsavedChanges) {
handleNewWorkflow();
return;
}
onOpen();
}, [handleNewWorkflow, isTouched, onOpen]);
}, [doesWorkflowHaveUnsavedChanges, handleNewWorkflow, onOpen]);
return (
<>

View File

@@ -1,6 +1,5 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,7 +7,7 @@ import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const isTouched = useAppSelector(selectWorkflowIsTouched);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
return (
@@ -16,7 +15,7 @@ const SaveWorkflowButton = () => {
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={<PiFloppyDiskBold />}
isDisabled={!isTouched}
isDisabled={!doesWorkflowHaveUnsavedChanges}
onClick={saveOrSaveAsWorkflow}
pointerEvents="auto"
/>

View File

@@ -1,7 +1,7 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { selectWorkflowName } from 'features/nodes/store/selectors';
import { memo } from 'react';
export const TopCenterPanel = memo(() => {

View File

@@ -1,22 +1,21 @@
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import {
$isInPublishFlow,
$isSelectingOutputNode,
useIsValidationRunInProgress,
useIsWorkflowPublished,
} from 'features/nodes/components/sidePanel/workflow/publish';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export const TopLeftPanel = memo(() => {
const isLocked = useIsWorkflowEditorLocked();
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isPublished = useIsWorkflowPublished();
const isValidationRunInProgress = useIsValidationRunInProgress();
const isSelectingOutputNode = useStore($isSelectingOutputNode);

View File

@@ -1,18 +1,82 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { logger } from 'app/logging/logger';
import { useAppStore } from 'app/store/storeHooks';
import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate';
import { updateAllNodesRequested } from 'features/nodes/store/actions';
import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { NodeUpdateError } from 'features/nodes/types/error';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiWarningBold } from 'react-icons/pi';
const log = logger('workflows');
const useUpdateNodes = () => {
const store = useAppStore();
const { t } = useTranslation();
const updateNodes = useCallback(() => {
const nodes = selectNodes(store.getState());
const templates = $templates.get();
let unableToUpdateCount = 0;
nodes.filter(isInvocationNode).forEach((node) => {
const template = templates[node.data.type];
if (!template) {
unableToUpdateCount++;
return;
}
if (!getNeedsUpdate(node.data, template)) {
// No need to increment the count here, since we're not actually updating
return;
}
try {
const updatedNode = updateNode(node, template);
store.dispatch(
nodesChanged([
{ type: 'remove', id: updatedNode.id },
{ type: 'add', item: updatedNode },
])
);
} catch (e) {
if (e instanceof NodeUpdateError) {
unableToUpdateCount++;
}
}
});
if (unableToUpdateCount) {
log.warn(
t('nodes.unableToUpdateNodes', {
count: unableToUpdateCount,
})
);
toast({
id: 'UNABLE_TO_UPDATE_NODES',
title: t('nodes.unableToUpdateNodes', {
count: unableToUpdateCount,
}),
});
} else {
toast({
id: 'ALL_NODES_UPDATED',
title: t('nodes.allNodesUpdated'),
status: 'success',
});
}
}, [store, t]);
return updateNodes;
};
const UpdateNodesButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const nodesNeedUpdate = useGetNodesNeedUpdate();
const handleClickUpdateNodes = useCallback(() => {
dispatch(updateAllNodesRequested());
}, [dispatch]);
const updateNodes = useUpdateNodes();
if (!nodesNeedUpdate) {
return null;
@@ -23,7 +87,7 @@ const UpdateNodesButton = () => {
tooltip={t('nodes.updateAllNodes')}
aria-label={t('nodes.updateAllNodes')}
icon={<PiWarningBold />}
onClick={handleClickUpdateNodes}
onClick={updateNodes}
pointerEvents="auto"
colorScheme="warning"
/>

View File

@@ -1,12 +1,37 @@
import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowId } from 'features/nodes/store/selectors';
import { toast } from 'features/toast/toast';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiLockOpenBold } from 'react-icons/pi';
import { useUnpublishWorkflowMutation } from 'services/api/endpoints/workflows';
export const PublishedWorkflowPanelContent = memo(() => {
const { t } = useTranslation();
const saveAs = useSaveOrSaveAsWorkflow();
const [unpublishWorkflow] = useUnpublishWorkflowMutation();
const workflowId = useAppSelector(selectWorkflowId);
const handleUnpublish = useCallback(async () => {
if (workflowId) {
try {
await unpublishWorkflow(workflowId).unwrap();
toast({
title: t('toast.workflowUnpublished'),
status: 'success',
});
} catch (error) {
toast({
title: t('toast.problemUnpublishingWorkflow'),
description: t('toast.problemUnpublishingWorkflowDescription'),
status: 'error',
});
}
}
}, [unpublishWorkflow, workflowId, t]);
return (
<Flex flexDir="column" w="full" h="full" gap={2} alignItems="center">
<Heading size="md" pt={32}>
@@ -16,7 +41,7 @@ export const PublishedWorkflowPanelContent = memo(() => {
<Button size="md" onClick={saveAs} variant="ghost" leftIcon={<PiCopyBold />}>
{t('common.saveAs')}
</Button>
<Button size="md" onClick={undefined} variant="ghost" leftIcon={<PiLockOpenBold />}>
<Button size="md" onClick={handleUnpublish} variant="ghost" leftIcon={<PiLockOpenBold />}>
{t('workflows.builder.unpublish')}
</Button>
</Flex>

View File

@@ -1,7 +1,7 @@
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';
import { selectWorkflowDescription } from 'features/nodes/store/selectors';
import Linkify from 'linkify-react';
import { memo } from 'react';

View File

@@ -1,8 +1,9 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsWorkflowPublished } from 'features/nodes/components/sidePanel/workflow/publish';
import { WorkflowListMenuTrigger } from 'features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger';
import { WorkflowViewEditToggleButton } from 'features/nodes/components/sidePanel/WorkflowViewEditToggleButton';
import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { WorkflowLibraryMenu } from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
@@ -10,7 +11,7 @@ import SaveWorkflowButton from './SaveWorkflowButton';
export const ActiveWorkflowNameAndActions = memo(() => {
const mode = useAppSelector(selectWorkflowMode);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isPublished = useIsWorkflowPublished();
return (
<Flex w="full" alignItems="center" gap={1} minW={0}>

View File

@@ -1,7 +1,7 @@
import { Button, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowName } from 'features/nodes/store/selectors';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { useTranslation } from 'react-i18next';
import { PiFolderOpenFill } from 'react-icons/pi';

View File

@@ -1,6 +1,8 @@
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowIsTouched, selectWorkflowMode, selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { selectWorkflowName } from 'features/nodes/store/selectors';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { useTranslation } from 'react-i18next';
import { PiDotOutlineFill } from 'react-icons/pi';
@@ -10,7 +12,7 @@ import { WorkflowWarning } from './viewMode/WorkflowWarning';
export const WorkflowName = () => {
const { t } = useTranslation();
const name = useAppSelector(selectWorkflowName);
const isTouched = useAppSelector(selectWorkflowIsTouched);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const mode = useAppSelector(selectWorkflowMode);
return (
@@ -27,10 +29,10 @@ export const WorkflowName = () => {
</Text>
)}
{isTouched && mode === 'edit' && (
{doesWorkflowHaveUnsavedChanges && mode === 'edit' && (
<Tooltip label={t('nodes.newWorkflowDesc2')}>
<Flex>
<Icon as={PiDotOutlineFill} boxSize="20px" sx={{ color: 'invokeYellow.500' }} />
<Icon as={PiDotOutlineFill} boxSize="20px" color="invokeYellow.500" />
</Flex>
</Tooltip>
)}

View File

@@ -1,6 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowMode, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { selectWorkflowMode, workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import type { MouseEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -3,18 +3,18 @@ import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { EditModeLeftPanelContent } from 'features/nodes/components/sidePanel/EditModeLeftPanelContent';
import { PublishedWorkflowPanelContent } from 'features/nodes/components/sidePanel/PublishedWorkflowPanelContent';
import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
import { $isInPublishFlow, useIsWorkflowPublished } from 'features/nodes/components/sidePanel/workflow/publish';
import { PublishWorkflowPanelContent } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent';
import { ActiveWorkflowDescription } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription';
import { ActiveWorkflowNameAndActions } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions';
import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { memo } from 'react';
import { ViewModeLeftPanelContent } from './viewMode/ViewModeLeftPanelContent';
const WorkflowsTabLeftPanel = () => {
const mode = useAppSelector(selectWorkflowMode);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isPublished = useIsWorkflowPublished();
const isInPublishFlow = useStore($isInPublishFlow);
return (

View File

@@ -16,7 +16,9 @@ import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/b
import { HeadingElement } from 'features/nodes/components/sidePanel/builder/HeadingElement';
import { NodeFieldElement } from 'features/nodes/components/sidePanel/builder/NodeFieldElement';
import { TextElement } from 'features/nodes/components/sidePanel/builder/TextElement';
import { selectFormRootElement, selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
import { useElement } from 'features/nodes/components/sidePanel/builder/use-element';
import { selectFormRootElement } from 'features/nodes/store/selectors';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import type { ContainerElement } from 'features/nodes/types/workflow';
import {
CONTAINER_CLASS_NAME,

View File

@@ -12,7 +12,7 @@ import {
Portal,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { formElementContainerDataChanged } from 'features/nodes/store/workflowSlice';
import { formElementContainerDataChanged } from 'features/nodes/store/nodesSlice';
import type { ContainerElement } from 'features/nodes/types/workflow';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,7 +1,8 @@
import { useAppSelector } from 'app/store/storeHooks';
import { DividerElementEditMode } from 'features/nodes/components/sidePanel/builder/DividerElementEditMode';
import { DividerElementViewMode } from 'features/nodes/components/sidePanel/builder/DividerElementViewMode';
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
import { useElement } from 'features/nodes/components/sidePanel/builder/use-element';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { isDividerElement } from 'features/nodes/types/workflow';
import { memo } from 'react';

View File

@@ -7,7 +7,7 @@ import { useDepthContext } from 'features/nodes/components/sidePanel/builder/con
import { NodeFieldElementSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementSettings';
import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
import { formElementRemoved } from 'features/nodes/store/nodesSlice';
import type { FormElement, NodeFieldElement } from 'features/nodes/types/workflow';
import { isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
import { camelCase } from 'lodash-es';

View File

@@ -1,7 +1,8 @@
import { useAppSelector } from 'app/store/storeHooks';
import { HeadingElementEditMode } from 'features/nodes/components/sidePanel/builder/HeadingElementEditMode';
import { HeadingElementViewMode } from 'features/nodes/components/sidePanel/builder/HeadingElementViewMode';
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
import { useElement } from 'features/nodes/components/sidePanel/builder/use-element';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { isHeadingElement } from 'features/nodes/types/workflow';
import { memo } from 'react';

View File

@@ -2,7 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { AutosizeTextarea } from 'features/nodes/components/sidePanel/builder/AutosizeTextarea';
import { HeadingElementContent } from 'features/nodes/components/sidePanel/builder/HeadingElementContent';
import { formElementHeadingDataChanged } from 'features/nodes/store/workflowSlice';
import { formElementHeadingDataChanged } from 'features/nodes/store/nodesSlice';
import type { HeadingElement } from 'features/nodes/types/workflow';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,7 +1,8 @@
import { useAppSelector } from 'app/store/storeHooks';
import { NodeFieldElementEditMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import { NodeFieldElementViewMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementViewMode';
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
import { useElement } from 'features/nodes/components/sidePanel/builder/use-element';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { isNodeFieldElement } from 'features/nodes/types/workflow';
import { memo } from 'react';

View File

@@ -3,8 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { fieldFloatValueChanged, formElementNodeFieldDataChanged } from 'features/nodes/store/nodesSlice';
import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/nodes/types/field';
import { type NodeFieldFloatSettings, zNumberComponent } from 'features/nodes/types/workflow';
import { constrainNumber } from 'features/nodes/util/constrainNumber';

View File

@@ -3,8 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { fieldIntegerValueChanged } from 'features/nodes/store/nodesSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { fieldIntegerValueChanged, formElementNodeFieldDataChanged } from 'features/nodes/store/nodesSlice';
import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
import { zNumberComponent } from 'features/nodes/types/workflow';

View File

@@ -16,7 +16,7 @@ import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePan
import { NodeFieldElementIntegerSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings';
import { NodeFieldElementStringSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringSettings';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/nodesSlice';
import {
isFloatFieldInputTemplate,
isIntegerFieldInputTemplate,

View File

@@ -1,7 +1,7 @@
import { Button, ButtonGroup, Divider, Flex, Grid, GridItem, IconButton, Input, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import { getDefaultStringOption, type NodeFieldStringDropdownSettings } from 'features/nodes/types/workflow';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';

View File

@@ -1,6 +1,6 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/nodesSlice';
import { getDefaultStringOption, type NodeFieldStringSettings, zStringComponent } from 'features/nodes/types/workflow';
import { omit } from 'lodash-es';
import type { ChangeEvent } from 'react';

View File

@@ -1,7 +1,8 @@
import { useAppSelector } from 'app/store/storeHooks';
import { TextElementEditMode } from 'features/nodes/components/sidePanel/builder/TextElementEditMode';
import { TextElementViewMode } from 'features/nodes/components/sidePanel/builder/TextElementViewMode';
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
import { useElement } from 'features/nodes/components/sidePanel/builder/use-element';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { isTextElement } from 'features/nodes/types/workflow';
import { memo } from 'react';

View File

@@ -2,7 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { AutosizeTextarea } from 'features/nodes/components/sidePanel/builder/AutosizeTextarea';
import { TextElementContent } from 'features/nodes/components/sidePanel/builder/TextElementContent';
import { formElementTextDataChanged } from 'features/nodes/store/workflowSlice';
import { formElementTextDataChanged } from 'features/nodes/store/nodesSlice';
import type { TextElement } from 'features/nodes/types/workflow';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -11,7 +11,7 @@ import { RootContainerElementEditMode } from 'features/nodes/components/sidePane
import { buildFormElementDndData, useBuilderDndMonitor } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
import { WorkflowBuilderEditMenu } from 'features/nodes/components/sidePanel/builder/WorkflowBuilderMenu';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { selectIsFormEmpty } from 'features/nodes/store/workflowSlice';
import { selectIsFormEmpty } from 'features/nodes/store/selectors';
import type { FormElement } from 'features/nodes/types/workflow';
import { buildContainer, buildDivider, buildHeading, buildText } from 'features/nodes/types/workflow';
import type { PropsWithChildren, RefObject } from 'react';

View File

@@ -1,7 +1,7 @@
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { useResetAllNodeFields } from 'features/nodes/components/sidePanel/builder/use-reset-all-node-fields';
import { formReset } from 'features/nodes/store/workflowSlice';
import { formReset } from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiDotsThreeBold, PiTrashBold } from 'react-icons/pi';

View File

@@ -27,14 +27,12 @@ import {
getElement,
getInitialValue,
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import {
formElementAdded,
formElementContainerDataChanged,
formElementReparented,
selectFormRootElementId,
selectWorkflowSlice,
} from 'features/nodes/store/workflowSlice';
} from 'features/nodes/store/nodesSlice';
import { selectFormRootElementId, selectNodesSlice, selectWorkflowForm } from 'features/nodes/store/selectors';
import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { ElementId, FormElement } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, isContainerElement } from 'features/nodes/types/workflow';
@@ -100,7 +98,7 @@ const useGetElement = () => {
const store = useAppStore();
const _getElement = useCallback(
<T extends FormElement>(elementId: ElementId, guard?: FormElementTypeGuard<T>): T => {
const { form } = selectWorkflowSlice(store.getState());
const form = selectWorkflowForm(store.getState());
return getElement(form, elementId, guard);
},
[store]
@@ -116,7 +114,7 @@ const useElementExists = () => {
const store = useAppStore();
const _elementExists = useCallback(
(id: ElementId): boolean => {
const { form } = selectWorkflowSlice(store.getState());
const form = selectWorkflowForm(store.getState());
return elementExists(form, id);
},
[store]
@@ -132,7 +130,7 @@ const useGetAllowedDropRegions = () => {
const store = useAppStore();
const _getAllowedDropRegions = useCallback(
(element: FormElement): CenterOrEdge[] => {
const { form } = selectWorkflowSlice(store.getState());
const form = selectWorkflowForm(store.getState());
return getAllowedDropRegions(form, element);
},
[store]

View File

@@ -22,6 +22,18 @@ import { assert, AssertionError } from 'tsafe';
import { describe, expect, it } from 'vitest';
describe('workflow builder form manipulation', () => {
describe('getDefaultForm', () => {
it('should return a form with a root element', () => {
const form = getDefaultForm();
expect(form).toHaveProperty('rootElementId');
expect(form.elements[form.rootElementId]).toBeDefined();
});
it('should give the id "root" to the root element', () => {
const form = getDefaultForm();
expect(form.rootElementId).toBe('root');
});
});
describe('elementExists', () => {
const form = getDefaultForm();

View File

@@ -1,7 +1,8 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { formElementAdded, selectFormRootElementId } from 'features/nodes/store/workflowSlice';
import { formElementAdded } from 'features/nodes/store/nodesSlice';
import { selectFormRootElementId } from 'features/nodes/store/selectors';
import { buildNodeFieldElement } from 'features/nodes/types/workflow';
import { useCallback } from 'react';

View File

@@ -0,0 +1,10 @@
import { useAppSelector } from 'app/store/storeHooks';
import { buildSelectElement } from 'features/nodes/store/selectors';
import type { FormElement } from 'features/nodes/types/workflow';
import { useMemo } from 'react';
export const useElement = (id: string): FormElement | undefined => {
const selector = useMemo(() => buildSelectElement(id), [id]);
const element = useAppSelector(selector);
return element;
};

View File

@@ -1,6 +1,6 @@
import { useAppStore } from 'app/store/nanostores/store';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectFormInitialValues, selectNodeFieldElements } from 'features/nodes/store/workflowSlice';
import { selectFormInitialValues, selectNodeFieldElements } from 'features/nodes/store/selectors';
import { useCallback } from 'react';
export const useResetAllNodeFields = () => {

View File

@@ -1,13 +1,14 @@
import { Button, Flex, Image, Link, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { useIsWorkflowUntouched } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
import { selectCleanEditor, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
export const EmptyState = () => {
const isCleanEditor = useAppSelector(selectCleanEditor);
const isWorkflowUntouched = useIsWorkflowUntouched();
return (
<Flex w="full" h="full" userSelect="none" justifyContent="center">
@@ -31,7 +32,7 @@ export const EmptyState = () => {
minH={16}
userSelect="none"
/>
{isCleanEditor ? <CleanEditorContent /> : <DirtyEditorContent />}
{isWorkflowUntouched ? <CleanEditorContent /> : <DirtyEditorContent />}
</Flex>
</Flex>
);

View File

@@ -6,7 +6,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import { RootContainerElementViewMode } from 'features/nodes/components/sidePanel/builder/ContainerElement';
import { EmptyState } from 'features/nodes/components/sidePanel/viewMode/EmptyState';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
import { selectIsFormEmpty } from 'features/nodes/store/workflowSlice';
import { selectIsFormEmpty } from 'features/nodes/store/selectors';
import { t } from 'i18next';
import { memo } from 'react';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';

View File

@@ -1,17 +1,17 @@
import { Box, Flex, Text } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
const selector = createMemoizedSelector(selectNodesSlice, (nodes) => {
return {
name: workflow.name,
description: workflow.description,
notes: workflow.notes,
author: workflow.author,
tags: workflow.tags,
name: nodes.name,
description: nodes.description,
notes: nodes.notes,
author: nodes.author,
tags: nodes.tags,
};
});

View File

@@ -0,0 +1,80 @@
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { EMPTY_OBJECT } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { getInitialWorkflow } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice, selectWorkflowId } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { buildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
import { debounce } from 'lodash-es';
import { atom, computed } from 'nanostores';
import { useEffect, useMemo } from 'react';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
import stableHash from 'stable-hash';
const $maybePreviewWorkflow = atom<WorkflowV3 | null>(null);
export const $previewWorkflow = computed(
$maybePreviewWorkflow,
(maybePreviewWorkflow) => maybePreviewWorkflow ?? EMPTY_OBJECT
);
const $previewWorkflowHash = computed($maybePreviewWorkflow, (maybePreviewWorkflow) => {
if (maybePreviewWorkflow) {
return stableHash(maybePreviewWorkflow);
}
return null;
});
const debouncedBuildPreviewWorkflow = debounce((nodesState: NodesState) => {
$maybePreviewWorkflow.set(buildWorkflowFast(nodesState));
}, 300);
export const useWorkflowBuilderWatcher = () => {
useAssertSingleton('useWorkflowBuilderWatcher');
const nodesState = useAppSelector(selectNodesSlice);
useEffect(() => {
debouncedBuildPreviewWorkflow(nodesState);
}, [nodesState]);
};
const queryOptions = {
selectFromResult: ({ currentData }) => {
if (!currentData) {
return { serverWorkflowHash: null };
}
const { is_published: _is_published, ...serverWorkflow } = currentData.workflow;
return {
serverWorkflowHash: stableHash(serverWorkflow),
};
},
} satisfies Parameters<typeof useGetWorkflowQuery>[1];
export const useDoesWorkflowHaveUnsavedChanges = () => {
const workflowId = useAppSelector(selectWorkflowId);
const previewWorkflowHash = useStore($previewWorkflowHash);
const { serverWorkflowHash } = useGetWorkflowQuery(workflowId ?? skipToken, queryOptions);
const doesWorkflowHaveUnsavedChanges = useMemo(() => {
if (serverWorkflowHash === null) {
// If the hash is null, it means the workflow doesn't exist in the database
return true;
}
return previewWorkflowHash !== serverWorkflowHash;
}, [previewWorkflowHash, serverWorkflowHash]);
return doesWorkflowHaveUnsavedChanges;
};
const initialWorkflowHash = stableHash({ ...getInitialWorkflow(), nodes: [], edges: [] });
export const useIsWorkflowUntouched = () => {
const previewWorkflowHash = useStore($previewWorkflowHash);
const isWorkflowUntouched = useMemo(() => {
return previewWorkflowHash === initialWorkflowHash;
}, [previewWorkflowHash]);
return isWorkflowUntouched;
};

View File

@@ -19,12 +19,13 @@ import { withResultAsync } from 'common/util/result';
import { parseify } from 'common/util/serialize';
import { ExternalLink } from 'features/gallery/components/ImageViewer/NoContentForViewer';
import { NodeFieldElementOverlay } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import {
$isInPublishFlow,
$isReadyToDoValidationRun,
$isSelectingOutputNode,
$outputNodeId,
$validationRunBatchId,
$validationRunData,
usePublishInputs,
} from 'features/nodes/components/sidePanel/workflow/publish';
import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
@@ -36,7 +37,6 @@ import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
import { selectHasBatchOrGeneratorNodes } from 'features/nodes/store/selectors';
import { selectIsWorkflowSaved } from 'features/nodes/store/workflowSlice';
import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
import { $isReadyToEnqueue } from 'features/queue/store/readiness';
import { selectAllowPublishWorkflows } from 'features/system/store/configSlice';
@@ -200,7 +200,7 @@ const PublishWorkflowButton = memo(() => {
const { t } = useTranslation();
const isReadyToDoValidationRun = useStore($isReadyToDoValidationRun);
const isReadyToEnqueue = useStore($isReadyToEnqueue);
const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
const outputNodeId = useStore($outputNodeId);
const isSelectingOutputNode = useStore($isSelectingOutputNode);
@@ -237,14 +237,18 @@ const PublishWorkflowButton = memo(() => {
duration: null,
});
assert(result.value.enqueueResult.batch.batch_id);
$validationRunBatchId.set(result.value.enqueueResult.batch.batch_id);
assert(result.value.batchConfig.validation_run_data);
$validationRunData.set({
batchId: result.value.enqueueResult.batch.batch_id,
workflowId: result.value.batchConfig.validation_run_data.workflow_id,
});
log.debug(parseify(result.value), 'Enqueued batch');
}
}, [enqueue, projectUrl, t]);
return (
<PublishTooltip
isWorkflowSaved={isWorkflowSaved}
isWorkflowSaved={!doesWorkflowHaveUnsavedChanges}
hasBatchOrGeneratorNodes={hasBatchOrGeneratorNodes}
isReadyToEnqueue={isReadyToEnqueue}
hasOutputNode={outputNodeId !== null && !isSelectingOutputNode}
@@ -256,7 +260,7 @@ const PublishWorkflowButton = memo(() => {
isDisabled={
!allowPublishWorkflows ||
!isReadyToEnqueue ||
!isWorkflowSaved ||
doesWorkflowHaveUnsavedChanges ||
hasBatchOrGeneratorNodes ||
!isReadyToDoValidationRun ||
!(outputNodeId !== null && !isSelectingOutputNode)
@@ -325,7 +329,7 @@ export const StartPublishFlowButton = memo(() => {
const { t } = useTranslation();
const allowPublishWorkflows = useAppSelector(selectAllowPublishWorkflows);
const isReadyToEnqueue = useStore($isReadyToEnqueue);
const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
const inputs = usePublishInputs();
@@ -335,7 +339,7 @@ export const StartPublishFlowButton = memo(() => {
return (
<PublishTooltip
isWorkflowSaved={isWorkflowSaved}
isWorkflowSaved={!doesWorkflowHaveUnsavedChanges}
hasBatchOrGeneratorNodes={hasBatchOrGeneratorNodes}
isReadyToEnqueue={isReadyToEnqueue}
hasOutputNode={true}
@@ -347,7 +351,9 @@ export const StartPublishFlowButton = memo(() => {
leftIcon={<PiLightningFill />}
variant="ghost"
size="sm"
isDisabled={!allowPublishWorkflows || !isReadyToEnqueue || !isWorkflowSaved || hasBatchOrGeneratorNodes}
isDisabled={
!allowPublishWorkflows || !isReadyToEnqueue || doesWorkflowHaveUnsavedChanges || hasBatchOrGeneratorNodes
}
>
{t('workflows.builder.publish')}
</Button>

View File

@@ -1,11 +1,9 @@
import type { FormControlProps } from '@invoke-ai/ui-library';
import { Box, Flex, FormControl, FormControlGroup, FormLabel, Image, Input, Textarea } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import {
selectWorkflowSlice,
workflowAuthorChanged,
workflowContactChanged,
workflowDescriptionChanged,
@@ -13,7 +11,17 @@ import {
workflowNotesChanged,
workflowTagsChanged,
workflowVersionChanged,
} from 'features/nodes/store/workflowSlice';
} from 'features/nodes/store/nodesSlice';
import {
selectWorkflowAuthor,
selectWorkflowContact,
selectWorkflowDescription,
selectWorkflowId,
selectWorkflowName,
selectWorkflowNotes,
selectWorkflowTags,
selectWorkflowVersion,
} from 'features/nodes/store/selectors';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -21,23 +29,16 @@ import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
import { WorkflowThumbnailEditor } from './WorkflowThumbnail/WorkflowThumbnailEditor';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
const { id, author, name, description, tags, version, contact, notes } = workflow;
return {
id,
name,
author,
description,
tags,
version,
contact,
notes,
};
});
const WorkflowGeneralTab = () => {
const { id, author, name, description, tags, version, contact, notes } = useAppSelector(selector);
const id = useAppSelector(selectWorkflowId);
const name = useAppSelector(selectWorkflowName);
const description = useAppSelector(selectWorkflowDescription);
const notes = useAppSelector(selectWorkflowNotes);
const author = useAppSelector(selectWorkflowAuthor);
const contact = useAppSelector(selectWorkflowContact);
const tags = useAppSelector(selectWorkflowTags);
const version = useAppSelector(selectWorkflowVersion);
const dispatch = useAppDispatch();
const handleChangeName = useCallback(

View File

@@ -1,43 +1,10 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { EMPTY_OBJECT } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState, WorkflowsState } from 'features/nodes/store/types';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { buildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
import { debounce } from 'lodash-es';
import { atom, computed } from 'nanostores';
import { memo, useEffect } from 'react';
import { $previewWorkflow } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const $maybePreviewWorkflow = atom<WorkflowV3 | null>(null);
const $previewWorkflow = computed(
$maybePreviewWorkflow,
(maybePreviewWorkflow) => maybePreviewWorkflow ?? EMPTY_OBJECT
);
const debouncedBuildPreviewWorkflow = debounce(
(nodes: NodesState['nodes'], edges: NodesState['edges'], workflow: WorkflowsState) => {
$maybePreviewWorkflow.set(buildWorkflowFast({ nodes, edges, workflow }));
},
300
);
const IsolatedWorkflowBuilderWatcher = memo(() => {
const { nodes, edges } = useAppSelector(selectNodesSlice);
const workflow = useAppSelector(selectWorkflowSlice);
useEffect(() => {
debouncedBuildPreviewWorkflow(nodes, edges, workflow);
}, [edges, nodes, workflow]);
return null;
});
IsolatedWorkflowBuilderWatcher.displayName = 'IsolatedWorkflowBuilderWatcher';
const WorkflowJSONTab = () => {
const previewWorkflow = useStore($previewWorkflow);
const { t } = useTranslation();
@@ -45,7 +12,6 @@ const WorkflowJSONTab = () => {
return (
<Flex flexDir="column" alignItems="flex-start" gap={2} h="full">
<DataViewer data={previewWorkflow} label={t('nodes.workflow')} bg="base.850" color="base.200" />
<IsolatedWorkflowBuilderWatcher />
</Flex>
);
};

View File

@@ -1,6 +1,6 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';

View File

@@ -1,6 +1,6 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';

View File

@@ -3,7 +3,8 @@ import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { LockedWorkflowIcon } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon';
import { ShareWorkflowButton } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow';
import { selectWorkflowId, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { selectWorkflowId } from 'features/nodes/store/selectors';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { memo, useCallback, useMemo } from 'react';

View File

@@ -3,15 +3,19 @@ import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import {
selectNodesSlice,
selectWorkflowFormNodeFieldFieldIdentifiersDeduped,
selectWorkflowId,
} from 'features/nodes/store/selectors';
import type { Templates } from 'features/nodes/store/types';
import { selectWorkflowFormNodeFieldFieldIdentifiersDeduped } from 'features/nodes/store/workflowSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { isBoardFieldType } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { atom, computed } from 'nanostores';
import { useMemo } from 'react';
import { useGetBatchStatusQuery } from 'services/api/endpoints/queue';
import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
import { assert } from 'tsafe';
export const $isInPublishFlow = atom(false);
@@ -23,12 +27,12 @@ export const $isReadyToDoValidationRun = computed(
return isInPublishFlow && outputNodeId !== null && !isSelectingOutputNode;
}
);
export const $validationRunBatchId = atom<string | null>(null);
export const $validationRunData = atom<{ batchId: string; workflowId: string } | null>(null);
export const useIsValidationRunInProgress = () => {
const validationRunBatchId = useStore($validationRunBatchId);
const validationRunData = useStore($validationRunData);
const { isValidationRunInProgress } = useGetBatchStatusQuery(
validationRunBatchId ? { batch_id: validationRunBatchId } : skipToken,
validationRunData?.batchId ? { batch_id: validationRunData.batchId } : skipToken,
{
selectFromResult: ({ currentData }) => {
if (!currentData) {
@@ -41,7 +45,7 @@ export const useIsValidationRunInProgress = () => {
},
}
);
return validationRunBatchId !== null || isValidationRunInProgress;
return validationRunData !== null || isValidationRunInProgress;
};
export const selectFieldIdentifiersWithInvocationTypes = createSelector(
@@ -88,3 +92,19 @@ export const usePublishInputs = () => {
return fieldIdentifiers;
};
const queryOptions = {
selectFromResult: ({ currentData }) => {
if (!currentData) {
return { isPublished: false };
}
return { isPublished: currentData.is_published };
},
} satisfies Parameters<typeof useGetWorkflowQuery>[1];
export const useIsWorkflowPublished = () => {
const workflowId = useAppSelector(selectWorkflowId);
const { isPublished } = useGetWorkflowQuery(workflowId ?? skipToken, queryOptions);
return isPublished;
};

View File

@@ -2,7 +2,6 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { isEqual } from 'lodash-es';
import { useCallback, useMemo } from 'react';
@@ -13,11 +12,11 @@ export const useInputFieldInitialFormValue = (elementId: string, nodeId: string,
const dispatch = useAppDispatch();
const selectInitialValue = useMemo(
() =>
createSelector(selectWorkflowSlice, (workflow) => {
if (!(elementId in workflow.formFieldInitialValues)) {
createSelector(selectNodesSlice, (nodes) => {
if (!(elementId in nodes.formFieldInitialValues)) {
return uniqueNonexistentValue;
}
return workflow.formFieldInitialValues[elementId];
return nodes.formFieldInitialValues[elementId];
}),
[elementId]
);

View File

@@ -1,11 +1,13 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import {
$isInPublishFlow,
useIsValidationRunInProgress,
useIsWorkflowPublished,
} from 'features/nodes/components/sidePanel/workflow/publish';
export const useIsWorkflowEditorLocked = () => {
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isPublished = useIsWorkflowPublished();
const isValidationRunInProgress = useIsValidationRunInProgress();
const isLocked = isInPublishFlow || isPublished || isValidationRunInProgress;

View File

@@ -1,19 +0,0 @@
import { createAction, isAnyOf } from '@reduxjs/toolkit';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import type { Graph } from 'services/api/types';
const textToImageGraphBuilt = createAction<Graph>('nodes/textToImageGraphBuilt');
const imageToImageGraphBuilt = createAction<Graph>('nodes/imageToImageGraphBuilt');
const canvasGraphBuilt = createAction<Graph>('nodes/canvasGraphBuilt');
const nodesGraphBuilt = createAction<Graph>('nodes/nodesGraphBuilt');
export const isAnyGraphBuilt = isAnyOf(
textToImageGraphBuilt,
imageToImageGraphBuilt,
canvasGraphBuilt,
nodesGraphBuilt
);
export const updateAllNodesRequested = createAction('nodes/updateAllNodesRequested');
export const workflowLoaded = createAction<WorkflowV3>('workflow/workflowLoaded');

View File

@@ -1,9 +1,24 @@
import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import type { EdgeChange, NodeChange, Viewport, XYPosition } from '@xyflow/react';
import type {
EdgeChange,
EdgeSelectionChange,
NodeChange,
NodeDimensionChange,
NodePositionChange,
NodeSelectionChange,
Viewport,
XYPosition,
} from '@xyflow/react';
import { applyEdgeChanges, applyNodeChanges, getConnectedEdges, getIncomers, getOutgoers } from '@xyflow/react';
import type { PersistConfig } from 'app/store/store';
import { workflowLoaded } from 'features/nodes/store/actions';
import { deepClone } from 'common/util/deepClone';
import {
addElement,
removeElement,
reparentElement,
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
import type { NodesState } from 'features/nodes/store/types';
import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants';
import type {
BoardFieldValue,
@@ -83,17 +98,55 @@ import {
} from 'features/nodes/types/field';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { isInvocationNode, isNotesNode } from 'features/nodes/types/invocation';
import type {
BuilderForm,
ContainerElement,
ElementId,
FormElement,
HeadingElement,
NodeFieldElement,
TextElement,
WorkflowCategory,
WorkflowV3,
} from 'features/nodes/types/workflow';
import {
getDefaultForm,
isContainerElement,
isHeadingElement,
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
import { atom, computed } from 'nanostores';
import type { MouseEvent } from 'react';
import type { UndoableOptions } from 'redux-undo';
import type { z } from 'zod';
import type { NodesState, PendingConnection, Templates } from './types';
import type { PendingConnection, Templates } from './types';
const initialNodesState: NodesState = {
export const getInitialWorkflow = (): Omit<NodesState, 'mode' | 'formFieldInitialValues' | '_version'> => {
return {
name: '',
author: '',
description: '',
version: '',
contact: '',
tags: '',
notes: '',
exposedFields: [],
meta: { version: '3.0.0', category: 'user' },
form: getDefaultForm(),
nodes: [],
edges: [],
// Even though this value is `undefined`, the keys _must_ be present for the presistence layer to rehydrate
// them correctly. It uses a merge strategy that relies on the keys being present.
id: undefined,
};
};
const initialState: NodesState = {
_version: 1,
nodes: [],
edges: [],
formFieldInitialValues: {},
...getInitialWorkflow(),
};
type FieldValueAction<T extends FieldValue> = PayloadAction<{
@@ -102,6 +155,24 @@ type FieldValueAction<T extends FieldValue> = PayloadAction<{
value: T;
}>;
type FormElementDataChangedAction<T extends FormElement> = PayloadAction<{
id: string;
changes: Partial<T['data']>;
}>;
const formElementDataChangedReducer = <T extends FormElement>(
state: NodesState,
action: FormElementDataChangedAction<T>,
guard: (element: FormElement) => element is T
) => {
const { id, changes } = action.payload;
const element = state.form?.elements[id];
if (!element || !guard(element)) {
return;
}
element.data = { ...element.data, ...changes } as T['data'];
};
const getField = (nodeId: string, fieldName: string, state: NodesState) => {
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
const node = state.nodes?.[nodeIndex];
@@ -131,7 +202,7 @@ const fieldValueReducer = <T extends FieldValue>(
export const nodesSlice = createSlice({
name: 'nodes',
initialState: initialNodesState,
initialState: initialState,
reducers: {
nodesChanged: (state, action: PayloadAction<NodeChange<AnyNode>[]>) => {
state.nodes = applyNodeChanges<AnyNode>(action.payload, state.nodes);
@@ -145,6 +216,23 @@ export const nodesSlice = createSlice({
}
});
state.edges = applyEdgeChanges<AnyEdge>(edgeChanges, state.edges);
// If a node was removed, we should remove any form fields that were associated with it. However, node changes
// may remove and then add the same node back. For example, when updating a workflow, we replace old nodes with
// updated nodes. In this case, we should not remove the form fields. To handle this, we find the last remove
// and add changes for each exposed field. If the remove change comes after the add change, we remove the exposed
// field.
for (const el of Object.values(state.form.elements)) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId } = el.data.fieldIdentifier;
const removeIndex = action.payload.findLastIndex((change) => change.type === 'remove' && change.id === nodeId);
const addIndex = action.payload.findLastIndex((change) => change.type === 'add' && change.item.id === nodeId);
if (removeIndex > addIndex) {
removeElement({ form: state.form, id: el.id });
}
}
},
edgesChanged: (state, action: PayloadAction<EdgeChange<AnyEdge>[]>) => {
const changes: EdgeChange<AnyEdge>[] = [];
@@ -459,21 +547,101 @@ export const nodesSlice = createSlice({
}
node.data.notes = value;
},
nodeEditorReset: (state) => {
state.nodes = [];
state.edges = [];
nodeEditorReset: () => deepClone(initialState),
workflowNameChanged: (state, action: PayloadAction<string>) => {
state.name = action.payload;
},
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;
}
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload;
},
workflowTagsChanged: (state, action: PayloadAction<string>) => {
state.tags = action.payload;
},
workflowAuthorChanged: (state, action: PayloadAction<string>) => {
state.author = action.payload;
},
workflowNotesChanged: (state, action: PayloadAction<string>) => {
state.notes = action.payload;
},
workflowVersionChanged: (state, action: PayloadAction<string>) => {
state.version = action.payload;
},
workflowContactChanged: (state, action: PayloadAction<string>) => {
state.contact = action.payload;
},
workflowIDChanged: (state, action: PayloadAction<string>) => {
state.id = action.payload;
},
formReset: (state) => {
state.form = getDefaultForm();
},
formElementAdded: (
state,
action: PayloadAction<{
element: FormElement;
parentId: ElementId;
index?: number;
initialValue?: StatefulFieldValue;
}>
) => {
const { form } = state;
const { element, parentId, index, initialValue } = action.payload;
addElement({ form, element, parentId, index });
if (isNodeFieldElement(element)) {
state.formFieldInitialValues[element.id] = initialValue;
}
},
formElementRemoved: (state, action: PayloadAction<{ id: string }>) => {
const { form } = state;
const { id } = action.payload;
removeElement({ form, id });
delete state.formFieldInitialValues[id];
},
formElementReparented: (state, action: PayloadAction<{ id: string; newParentId: string; index: number }>) => {
const { form } = state;
const { id, newParentId, index } = action.payload;
reparentElement({ form, id, newParentId, index });
},
formElementHeadingDataChanged: (state, action: FormElementDataChangedAction<HeadingElement>) => {
formElementDataChangedReducer(state, action, isHeadingElement);
},
formElementTextDataChanged: (state, action: FormElementDataChangedAction<TextElement>) => {
formElementDataChangedReducer(state, action, isTextElement);
},
formElementNodeFieldDataChanged: (state, action: FormElementDataChangedAction<NodeFieldElement>) => {
formElementDataChangedReducer(state, action, isNodeFieldElement);
},
formElementContainerDataChanged: (state, action: FormElementDataChangedAction<ContainerElement>) => {
formElementDataChangedReducer(state, action, isContainerElement);
},
formFieldInitialValuesChanged: (
state,
action: PayloadAction<{ formFieldInitialValues: NodesState['formFieldInitialValues'] }>
) => {
const { formFieldInitialValues } = action.payload;
state.formFieldInitialValues = formFieldInitialValues;
},
workflowLoaded: (state, action: PayloadAction<WorkflowV3>) => {
const { nodes, edges, is_published: _is_published, ...workflowExtra } = action.payload;
const formFieldInitialValues = getFormFieldInitialValues(workflowExtra.form, nodes);
return {
...deepClone(initialState),
...deepClone(workflowExtra),
formFieldInitialValues,
nodes: nodes.map((node) => ({ ...SHARED_NODE_PROPERTIES, ...node })),
edges,
};
},
undo: (state) => state,
redo: (state) => state,
},
extraReducers: (builder) => {
builder.addCase(workflowLoaded, (state, action) => {
const { nodes, edges } = action.payload;
state.nodes = nodes.map((node) => ({ ...SHARED_NODE_PROPERTIES, ...node }));
state.edges = edges;
});
},
});
export const {
@@ -524,6 +692,25 @@ export const {
nodesChanged,
nodeUseCacheChanged,
notesNodeValueChanged,
workflowNameChanged,
workflowCategoryChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,
workflowNotesChanged,
workflowVersionChanged,
workflowContactChanged,
workflowIDChanged,
formReset,
formElementAdded,
formElementRemoved,
formElementReparented,
formElementHeadingDataChanged,
formElementTextDataChanged,
formElementNodeFieldDataChanged,
formElementContainerDataChanged,
formFieldInitialValuesChanged,
workflowLoaded,
undo,
redo,
} = nodesSlice.actions;
@@ -553,38 +740,144 @@ const migrateNodesState = (state: any): any => {
export const nodesPersistConfig: PersistConfig<NodesState> = {
name: nodesSlice.name,
initialState: initialNodesState,
initialState: initialState,
migrate: migrateNodesState,
persistDenylist: [],
};
const isSelectionAction = (action: UnknownAction) => {
if (nodesChanged.match(action)) {
if (action.payload.every((change) => change.type === 'select')) {
return true;
}
type NodeSelectionAction = {
type: ReturnType<typeof nodesChanged>['type'];
payload: NodeSelectionChange[];
};
type EdgeSelectionAction = {
type: ReturnType<typeof edgesChanged>['type'];
payload: EdgeSelectionChange[];
};
const isNodeSelectionAction = (action: UnknownAction): action is NodeSelectionAction => {
if (!nodesChanged.match(action)) {
return false;
}
if (edgesChanged.match(action)) {
if (action.payload.every((change) => change.type === 'select')) {
return true;
}
if (action.payload.every((change) => change.type === 'select')) {
return true;
}
return false;
};
const individualGroupByMatcher = isAnyOf(nodesChanged);
const isEdgeSelectionAction = (action: UnknownAction): action is EdgeSelectionAction => {
if (!edgesChanged.match(action)) {
return false;
}
if (action.payload.every((change) => change.type === 'select')) {
return true;
}
return false;
};
type NodeDimensionChangeAction = {
type: ReturnType<typeof nodesChanged>['type'];
payload: NodeDimensionChange[];
};
const isDimensionsChangeAction = (action: UnknownAction): action is NodeDimensionChangeAction => {
if (!nodesChanged.match(action)) {
return false;
}
if (action.payload.every((change) => change.type === 'dimensions')) {
return true;
}
return false;
};
type NodePositionChangeAction = {
type: ReturnType<typeof nodesChanged>['type'];
payload: (NodeDimensionChange | NodePositionChange)[];
};
const isPositionChangeAction = (action: UnknownAction): action is NodePositionChangeAction => {
if (!nodesChanged.match(action)) {
return false;
}
if (action.payload.every((change) => change.type === 'position')) {
return true;
}
return false;
};
// Match field mutations that are high frequency and should be grouped together - for example, when a user is
// typing in a text field, we don't want to create a new undo group for every keystroke.
const isHighFrequencyFieldChangeAction = isAnyOf(
fieldLabelChanged,
fieldIntegerValueChanged,
fieldFloatValueChanged,
fieldFloatCollectionValueChanged,
fieldIntegerCollectionValueChanged,
fieldStringValueChanged,
fieldStringCollectionValueChanged,
fieldFloatGeneratorValueChanged,
fieldIntegerGeneratorValueChanged,
fieldStringGeneratorValueChanged,
fieldImageGeneratorValueChanged,
fieldDescriptionChanged
);
// Match form changes that are high frequency and should be grouped together - for example, when a user is
// typing in a text field, we don't want to create a new undo group for every keystroke.
const isHighFrequencyFormChangeAction = isAnyOf(
formElementHeadingDataChanged,
formElementTextDataChanged,
formElementNodeFieldDataChanged,
formElementContainerDataChanged
);
// Match workflow changes that are high frequency and should be grouped together - for example, when a user is
// updating the workflow description, we don't want to create a new undo group for every keystroke.
const isHighFrequencyWorkflowDetailsAction = isAnyOf(
workflowNameChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,
workflowNotesChanged,
workflowVersionChanged,
workflowContactChanged
);
// Match node-scoped actions that are high frequency and should be grouped together - for example, when a user is
// updating the node label, we don't want to create a new undo group for every keystroke. Or when a user is writing
// a note in a notes node, we don't want to create a new undo group for every keystroke.
const isHighFrequencyNodeScopedAction = isAnyOf(nodeLabelChanged, nodeNotesChanged, notesNodeValueChanged);
export const nodesUndoableConfig: UndoableOptions<NodesState, UnknownAction> = {
limit: 64,
undoType: nodesSlice.actions.undo.type,
redoType: nodesSlice.actions.redo.type,
groupBy: (action, state, history) => {
if (isSelectionAction(action)) {
// Changes to selection should never be recorded on their own
return history.group;
groupBy: (action, _state, _history) => {
if (isHighFrequencyFieldChangeAction(action)) {
// Group by type, node id and field name
const { type, payload } = action;
const { nodeId, fieldName } = payload;
return `${type}-${nodeId}-${fieldName}`;
}
if (individualGroupByMatcher(action)) {
return action.type;
if (isPositionChangeAction(action)) {
const ids = action.payload.map((change) => change.id).join(',');
// Group by type and node ids
return `dimensions-or-position-${ids}`;
}
if (isHighFrequencyFormChangeAction(action)) {
// Group by type and form element id
const { type, payload } = action;
const { id } = payload;
return `${type}-${id}`;
}
if (isHighFrequencyWorkflowDetailsAction(action)) {
return 'workflow-details';
}
if (isHighFrequencyNodeScopedAction(action)) {
const { type, payload } = action;
const { nodeId } = payload;
// Group by type and node id
return `${type}-${nodeId}`;
}
return null;
},
@@ -593,51 +886,42 @@ export const nodesUndoableConfig: UndoableOptions<NodesState, UnknownAction> = {
if (!action.type.startsWith(nodesSlice.name)) {
return false;
}
if (nodesChanged.match(action)) {
if (action.payload.every((change) => change.type === 'dimensions')) {
return false;
}
// Ignore actions that only select or deselect nodes and edges
if (isNodeSelectionAction(action) || isEdgeSelectionAction(action)) {
return false;
}
if (isDimensionsChangeAction(action)) {
// Ignore actions that only change the dimensions of nodes - these are internal to reactflow
return false;
}
return true;
},
};
// This is used for tracking `state.workflow.isTouched`
export const isAnyNodeOrEdgeMutation = isAnyOf(
edgesChanged,
fieldBoardValueChanged,
fieldBooleanValueChanged,
fieldColorValueChanged,
fieldControlNetModelValueChanged,
fieldEnumModelValueChanged,
fieldImageValueChanged,
fieldImageCollectionValueChanged,
fieldIPAdapterModelValueChanged,
fieldT2IAdapterModelValueChanged,
fieldLabelChanged,
fieldLoRAModelValueChanged,
fieldLLaVAModelValueChanged,
fieldMainModelValueChanged,
fieldIntegerValueChanged,
fieldIntegerCollectionValueChanged,
fieldFloatValueChanged,
fieldFloatCollectionValueChanged,
fieldRefinerModelValueChanged,
fieldSchedulerValueChanged,
fieldStringValueChanged,
fieldStringCollectionValueChanged,
fieldVaeModelValueChanged,
fieldT5EncoderValueChanged,
fieldCLIPEmbedValueChanged,
fieldCLIPLEmbedValueChanged,
fieldCLIPGEmbedValueChanged,
fieldFluxVAEModelValueChanged,
// The `nodesChanged` has extra logic and is handled in its own extra reducer
// nodesChanged,
nodeIsIntermediateChanged,
nodeIsOpenChanged,
nodeLabelChanged,
nodeNotesChanged,
nodeUseCacheChanged,
notesNodeValueChanged
);
// The form builder's initial values are based on the current values of the node fields in the workflow.
export const getFormFieldInitialValues = (form: BuilderForm, nodes: NodesState['nodes']) => {
const formFieldInitialValues: Record<string, StatefulFieldValue> = {};
for (const el of Object.values(form.elements)) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId, fieldName } = el.data.fieldIdentifier;
const node = nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node)) {
continue;
}
const field = node.data.inputs[fieldName];
if (!field) {
continue;
}
formFieldInitialValues[el.id] = field.value;
}
return formFieldInitialValues;
};

View File

@@ -1,10 +1,13 @@
import type { Selector } from '@reduxjs/toolkit';
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { getElement } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import type { NodesState } from 'features/nodes/store/types';
import type { FieldInputInstance } from 'features/nodes/types/field';
import type { AnyNode, InvocationNode, InvocationNodeData } from 'features/nodes/types/invocation';
import { isBatchNode, isGeneratorNode, isInvocationNode } from 'features/nodes/types/invocation';
import { isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
import { uniqBy } from 'lodash-es';
import { assert } from 'tsafe';
export const selectNode = (nodesSlice: NodesState, nodeId: string): AnyNode => {
@@ -85,3 +88,41 @@ export const selectMayRedo = createSelector(
export const selectHasBatchOrGeneratorNodes = createSelector(selectNodes, (nodes) =>
nodes.filter(isInvocationNode).some((node) => isBatchNode(node) || isGeneratorNode(node))
);
export const selectWorkflowName = createNodesSelector((nodes) => nodes.name);
export const selectWorkflowId = createNodesSelector((workflow) => workflow.id);
export const selectWorkflowDescription = createNodesSelector((workflow) => workflow.description);
export const selectWorkflowNotes = createNodesSelector((workflow) => workflow.notes);
export const selectWorkflowAuthor = createNodesSelector((workflow) => workflow.author);
export const selectWorkflowContact = createNodesSelector((workflow) => workflow.contact);
export const selectWorkflowTags = createNodesSelector((workflow) => workflow.tags);
export const selectWorkflowVersion = createNodesSelector((workflow) => workflow.version);
export const selectWorkflowForm = createNodesSelector((workflow) => workflow.form);
export const selectFormRootElementId = createNodesSelector((workflow) => {
return workflow.form.rootElementId;
});
export const selectFormRootElement = createNodesSelector((workflow) => {
return getElement(workflow.form, workflow.form.rootElementId, isContainerElement);
});
export const selectIsFormEmpty = createNodesSelector((workflow) => {
const rootElement = workflow.form.elements[workflow.form.rootElementId];
if (!rootElement || !isContainerElement(rootElement)) {
return true;
}
return rootElement.data.children.length === 0;
});
export const selectFormInitialValues = createNodesSelector((workflow) => workflow.formFieldInitialValues);
export const selectNodeFieldElements = createNodesSelector((workflow) =>
Object.values(workflow.form.elements).filter(isNodeFieldElement)
);
export const selectWorkflowFormNodeFieldFieldIdentifiersDeduped = createSelector(
selectNodeFieldElements,
(nodeFieldElements) =>
uniqBy(nodeFieldElements, (el) => `${el.data.fieldIdentifier.nodeId}-${el.data.fieldIdentifier.fieldName}`).map(
(el) => el.data.fieldIdentifier
)
);
export const buildSelectElement = (id: string) => createNodesSelector((workflow) => workflow.form?.elements[id]);

View File

@@ -13,17 +13,11 @@ export type PendingConnection = {
fieldTemplate: FieldInputTemplate | FieldOutputTemplate;
};
export type WorkflowMode = 'edit' | 'view';
export type NodesState = {
_version: 1;
nodes: AnyNode[];
edges: AnyEdge[];
};
export type WorkflowMode = 'edit' | 'view';
export type WorkflowsState = Omit<WorkflowV3, 'nodes' | 'edges'> & {
_version: 1;
isTouched: boolean;
mode: WorkflowMode;
formFieldInitialValues: Record<string, StatefulFieldValue>;
};
} & Omit<WorkflowV3, 'nodes' | 'edges' | 'is_published'>;

View File

@@ -1,6 +1,7 @@
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import type { WorkflowMode } from 'features/nodes/store/types';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import { atom, computed } from 'nanostores';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
@@ -8,6 +9,7 @@ import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types'
export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults' | 'published';
type WorkflowLibraryState = {
mode: WorkflowMode;
view: WorkflowLibraryView;
orderBy: WorkflowRecordOrderBy;
direction: SQLiteDirection;
@@ -16,6 +18,7 @@ type WorkflowLibraryState = {
};
const initialWorkflowLibraryState: WorkflowLibraryState = {
mode: 'view',
searchTerm: '',
orderBy: 'opened_at',
direction: 'DESC',
@@ -27,6 +30,9 @@ export const workflowLibrarySlice = createSlice({
name: 'workflowLibrary',
initialState: initialWorkflowLibraryState,
reducers: {
workflowModeChanged: (state, action: PayloadAction<WorkflowMode>) => {
state.mode = action.payload;
},
workflowLibrarySearchTermChanged: (state, action: PayloadAction<string>) => {
state.searchTerm = action.payload;
},
@@ -60,6 +66,7 @@ export const workflowLibrarySlice = createSlice({
});
export const {
workflowModeChanged,
workflowLibrarySearchTermChanged,
workflowLibraryOrderByChanged,
workflowLibraryDirectionChanged,
@@ -82,6 +89,7 @@ const selectWorkflowLibrarySlice = (state: RootState) => state.workflowLibrary;
const createWorkflowLibrarySelector = <T>(selector: Selector<WorkflowLibraryState, T>) =>
createSelector(selectWorkflowLibrarySlice, selector);
export const selectWorkflowMode = createWorkflowLibrarySelector((workflow) => workflow.mode);
export const selectWorkflowLibrarySearchTerm = createWorkflowLibrarySelector(({ searchTerm }) => searchTerm);
export const selectWorkflowLibraryHasSearchTerm = createWorkflowLibrarySelector(({ searchTerm }) => !!searchTerm);
export const selectWorkflowLibraryOrderBy = createWorkflowLibrarySelector(({ orderBy }) => orderBy);

View File

@@ -1,402 +0,0 @@
import type { PayloadAction, Selector } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { deepClone } from 'common/util/deepClone';
import {
addElement,
getElement,
removeElement,
reparentElement,
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { workflowLoaded } from 'features/nodes/store/actions';
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged } from 'features/nodes/store/nodesSlice';
import type { NodesState, WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type {
BuilderForm,
ContainerElement,
ElementId,
FormElement,
HeadingElement,
NodeFieldElement,
TextElement,
WorkflowCategory,
WorkflowV3,
} from 'features/nodes/types/workflow';
import {
buildContainer,
getDefaultForm,
isContainerElement,
isHeadingElement,
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
import { isEqual, uniqBy } from 'lodash-es';
import { useMemo } from 'react';
import { selectNodesSlice } from './selectors';
type FormElementDataChangedAction<T extends FormElement> = PayloadAction<{
id: string;
changes: Partial<T['data']>;
}>;
const formElementDataChangedReducer = <T extends FormElement>(
state: WorkflowState,
action: FormElementDataChangedAction<T>,
guard: (element: FormElement) => element is T
) => {
const { id, changes } = action.payload;
const element = state.form?.elements[id];
if (!element || !guard(element)) {
return;
}
element.data = { ...element.data, ...changes } as T['data'];
};
const getBlankWorkflow = (): Omit<WorkflowV3, 'nodes' | 'edges'> => {
return {
name: '',
author: '',
description: '',
version: '',
contact: '',
tags: '',
notes: '',
exposedFields: [],
meta: { version: '3.0.0', category: 'user' },
form: getDefaultForm(),
// Even though these values are `undefined`, the keys _must_ be present for the presistence layer to rehydrate
// them correctly. It uses a merge strategy that relies on the keys being present.
id: undefined,
is_published: undefined,
};
};
const initialWorkflowState: WorkflowState = {
_version: 1,
isTouched: false,
mode: 'view',
formFieldInitialValues: {},
...getBlankWorkflow(),
};
export const workflowSlice = createSlice({
name: 'workflow',
initialState: initialWorkflowState,
reducers: {
workflowModeChanged: (state, action: PayloadAction<WorkflowMode>) => {
state.mode = action.payload;
},
workflowNameChanged: (state, action: PayloadAction<string>) => {
state.name = action.payload;
state.isTouched = true;
},
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;
}
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload;
state.isTouched = true;
},
workflowTagsChanged: (state, action: PayloadAction<string>) => {
state.tags = action.payload;
state.isTouched = true;
},
workflowAuthorChanged: (state, action: PayloadAction<string>) => {
state.author = action.payload;
state.isTouched = true;
},
workflowNotesChanged: (state, action: PayloadAction<string>) => {
state.notes = action.payload;
state.isTouched = true;
},
workflowVersionChanged: (state, action: PayloadAction<string>) => {
state.version = action.payload;
state.isTouched = true;
},
workflowContactChanged: (state, action: PayloadAction<string>) => {
state.contact = action.payload;
state.isTouched = true;
},
workflowIDChanged: (state, action: PayloadAction<string>) => {
state.id = action.payload;
},
workflowIsPublishedChanged(state, action: PayloadAction<boolean>) {
state.is_published = action.payload;
},
workflowSaved: (state) => {
state.isTouched = false;
},
formReset: (state) => {
const rootElement = buildContainer('column', []);
state.form = {
elements: { [rootElement.id]: rootElement },
rootElementId: rootElement.id,
};
state.isTouched = true;
},
formElementAdded: (
state,
action: PayloadAction<{
element: FormElement;
parentId: ElementId;
index?: number;
initialValue?: StatefulFieldValue;
}>
) => {
const { form } = state;
const { element, parentId, index, initialValue } = action.payload;
addElement({ form, element, parentId, index });
if (isNodeFieldElement(element)) {
state.formFieldInitialValues[element.id] = initialValue;
}
state.isTouched = true;
},
formElementRemoved: (state, action: PayloadAction<{ id: string }>) => {
const { form } = state;
const { id } = action.payload;
removeElement({ form, id });
delete state.formFieldInitialValues[id];
state.isTouched = true;
},
formElementReparented: (state, action: PayloadAction<{ id: string; newParentId: string; index: number }>) => {
const { form } = state;
const { id, newParentId, index } = action.payload;
reparentElement({ form, id, newParentId, index });
state.isTouched = true;
},
formElementHeadingDataChanged: (state, action: FormElementDataChangedAction<HeadingElement>) => {
formElementDataChangedReducer(state, action, isHeadingElement);
state.isTouched = true;
},
formElementTextDataChanged: (state, action: FormElementDataChangedAction<TextElement>) => {
formElementDataChangedReducer(state, action, isTextElement);
state.isTouched = true;
},
formElementNodeFieldDataChanged: (state, action: FormElementDataChangedAction<NodeFieldElement>) => {
formElementDataChangedReducer(state, action, isNodeFieldElement);
state.isTouched = true;
},
formElementContainerDataChanged: (state, action: FormElementDataChangedAction<ContainerElement>) => {
formElementDataChangedReducer(state, action, isContainerElement);
state.isTouched = true;
},
formFieldInitialValuesChanged: (
state,
action: PayloadAction<{ formFieldInitialValues: WorkflowState['formFieldInitialValues'] }>
) => {
const { formFieldInitialValues } = action.payload;
state.formFieldInitialValues = formFieldInitialValues;
},
},
extraReducers: (builder) => {
builder.addCase(workflowLoaded, (state, action): WorkflowState => {
const { nodes, edges: _edges, ...workflowExtra } = action.payload;
const formFieldInitialValues = getFormFieldInitialValues(workflowExtra.form, nodes);
return {
...deepClone(initialWorkflowState),
...deepClone(workflowExtra),
formFieldInitialValues,
mode: state.mode,
};
});
builder.addCase(nodeEditorReset, (state) => {
const mode = state.mode;
const newState = deepClone(initialWorkflowState);
newState.mode = mode;
return newState;
});
builder.addCase(nodesChanged, (state, action) => {
// If a node was removed, we should remove any exposed fields that were associated with it. However, node changes
// may remove and then add the same node back. For example, when updating a workflow, we replace old nodes with
// updated nodes. In this case, we should not remove the exposed fields. To handle this, we find the last remove
// and add changes for each exposed field. If the remove change comes after the add change, we remove the exposed
// field.
const fieldsToRemove: FieldIdentifier[] = [];
state.exposedFields.forEach((field) => {
const removeIndex = action.payload.findLastIndex(
(change) => change.type === 'remove' && change.id === field.nodeId
);
const addIndex = action.payload.findLastIndex(
(change) => change.type === 'add' && change.item.id === field.nodeId
);
if (removeIndex > addIndex) {
fieldsToRemove.push({ nodeId: field.nodeId, fieldName: field.fieldName });
}
});
state.exposedFields = state.exposedFields.filter((field) => !fieldsToRemove.some((f) => isEqual(f, field)));
if (state.form) {
for (const el of Object.values(state.form?.elements || {})) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId } = el.data.fieldIdentifier;
const removeIndex = action.payload.findLastIndex(
(change) => change.type === 'remove' && change.id === nodeId
);
const addIndex = action.payload.findLastIndex((change) => change.type === 'add' && change.item.id === nodeId);
if (removeIndex > addIndex) {
removeElement({ form: state.form, id: el.id });
}
}
}
// Not all changes to nodes should result in the workflow being marked touched
const filteredChanges = action.payload.filter((change) => {
// We always want to mark the workflow as touched if a node is added, removed, or reset
if (['add', 'remove', 'reset'].includes(change.type)) {
return true;
}
// Position changes can change the position and the dragging status of the node - ignore if the change doesn't
// affect the position
if (change.type === 'position' && (change.position || change.positionAbsolute)) {
return true;
}
// This change isn't relevant
return false;
});
if (filteredChanges.length > 0 || fieldsToRemove.length > 0) {
state.isTouched = true;
}
});
builder.addMatcher(isAnyNodeOrEdgeMutation, (state) => {
state.isTouched = true;
});
},
});
export const {
workflowModeChanged,
workflowNameChanged,
workflowCategoryChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,
workflowNotesChanged,
workflowVersionChanged,
workflowContactChanged,
workflowIDChanged,
workflowIsPublishedChanged,
workflowSaved,
formReset,
formElementAdded,
formElementRemoved,
formElementReparented,
formElementHeadingDataChanged,
formElementTextDataChanged,
formElementNodeFieldDataChanged,
formElementContainerDataChanged,
formFieldInitialValuesChanged,
} = workflowSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateWorkflowState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};
export const workflowPersistConfig: PersistConfig<WorkflowState> = {
name: workflowSlice.name,
initialState: initialWorkflowState,
migrate: migrateWorkflowState,
persistDenylist: [],
};
export const selectWorkflowSlice = (state: RootState) => state.workflow;
const createWorkflowSelector = <T>(selector: Selector<WorkflowState, T>) =>
createSelector(selectWorkflowSlice, selector);
// The form builder's initial values are based on the current values of the node fields in the workflow.
export const getFormFieldInitialValues = (form: BuilderForm, nodes: NodesState['nodes']) => {
const formFieldInitialValues: Record<string, StatefulFieldValue> = {};
for (const el of Object.values(form.elements)) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId, fieldName } = el.data.fieldIdentifier;
const node = nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node)) {
continue;
}
const field = node.data.inputs[fieldName];
if (!field) {
continue;
}
formFieldInitialValues[el.id] = field.value;
}
return formFieldInitialValues;
};
export const selectWorkflowName = createWorkflowSelector((workflow) => workflow.name);
export const selectWorkflowId = createWorkflowSelector((workflow) => workflow.id);
export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.mode);
export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched);
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form);
export const selectWorkflowIsPublished = createWorkflowSelector((workflow) => workflow.is_published);
export const selectIsWorkflowSaved = createSelector(selectWorkflowId, selectWorkflowIsTouched, (id, isTouched) => {
return id !== undefined && !isTouched;
});
export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => {
const noNodes = !nodes.nodes.length;
const isTouched = workflow.isTouched;
const savedWorkflow = !!workflow.id;
return noNodes && !isTouched && !savedWorkflow;
});
export const selectFormRootElementId = createWorkflowSelector((workflow) => {
return workflow.form.rootElementId;
});
export const selectFormRootElement = createWorkflowSelector((workflow) => {
return getElement(workflow.form, workflow.form.rootElementId, isContainerElement);
});
export const selectIsFormEmpty = createWorkflowSelector((workflow) => {
const rootElement = workflow.form.elements[workflow.form.rootElementId];
if (!rootElement || !isContainerElement(rootElement)) {
return true;
}
return rootElement.data.children.length === 0;
});
export const selectFormInitialValues = createWorkflowSelector((workflow) => workflow.formFieldInitialValues);
export const selectNodeFieldElements = createWorkflowSelector((workflow) =>
Object.values(workflow.form.elements).filter(isNodeFieldElement)
);
export const selectWorkflowFormNodeFieldFieldIdentifiersDeduped = createSelector(
selectNodeFieldElements,
(nodeFieldElements) =>
uniqBy(nodeFieldElements, (el) => `${el.data.fieldIdentifier.nodeId}-${el.data.fieldIdentifier.fieldName}`).map(
(el) => el.data.fieldIdentifier
)
);
const buildSelectElement = (id: string) => createWorkflowSelector((workflow) => workflow.form?.elements[id]);
export const useElement = (id: string): FormElement | undefined => {
const selector = useMemo(() => buildSelectElement(id), [id]);
const element = useAppSelector(selector);
return element;
};

View File

@@ -258,8 +258,10 @@ const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElem
export type FormElement = z.infer<typeof zFormElement>;
const ROOT_ELEMENT_ID = 'root';
export const getDefaultForm = (): BuilderForm => {
const rootElement = buildContainer('column', []);
rootElement.id = ROOT_ELEMENT_ID;
return {
elements: {
[rootElement.id]: rootElement,

View File

@@ -1,4 +1,8 @@
import type { CanvasReferenceImageState, FLUXReduxConfig } from 'features/controlLayers/store/types';
import type {
CanvasReferenceImageState,
FLUXReduxConfig,
FLUXReduxImageInfluence,
} from 'features/controlLayers/store/types';
import { isFLUXReduxConfig } from 'features/controlLayers/store/types';
import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
@@ -36,6 +40,45 @@ export const addFLUXReduxes = ({ entities, g, collector, model }: AddFLUXReduxAr
return result;
};
/**
* To fine-tune the image influence, edit this object.
* - downsampling_factor: 1 to 9, where 1 is the most image influence and 9 is the least. 1 is FLUX redux in its original form.
* - downsampling_function: the function used to downsample the image. Defaults to 'area'. Dunno about how it affects the image.
* - weight: 0 to 1. the conditioning is multiplied by the square of this value. 1 means no change.
*
* See invokeai/app/invocations/flux_redux.py for more details.
*/
export const IMAGE_INFLUENCE_TO_SETTINGS: Record<
FLUXReduxImageInfluence,
Pick<Invocation<'flux_redux'>, 'downsampling_factor' | 'downsampling_function' | 'weight'>
> = {
lowest: {
downsampling_factor: 5,
// downsampling_function: 'area',
weight: 1,
},
low: {
downsampling_factor: 4,
// downsampling_function: 'area',
weight: 1,
},
medium: {
downsampling_factor: 3,
// downsampling_function: 'area',
weight: 1,
},
high: {
downsampling_factor: 2,
// downsampling_function: 'area',
weight: 1,
},
highest: {
downsampling_factor: 1,
// downsampling_function: 'area',
weight: 1,
},
};
const addFLUXRedux = (id: string, ipAdapter: FLUXReduxConfig, g: Graph, collector: Invocation<'collect'>) => {
const { model: fluxReduxModel, image } = ipAdapter;
assert(image, 'FLUX Redux image is required');
@@ -48,6 +91,7 @@ const addFLUXRedux = (id: string, ipAdapter: FLUXReduxConfig, g: Graph, collecto
image: {
image_name: image.image_name,
},
...IMAGE_INFLUENCE_TO_SETTINGS[ipAdapter.imageInfluence ?? 'highest'],
});
g.addEdge(node, 'redux_cond', collector, 'item');

View File

@@ -5,6 +5,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import type { CanvasRegionalGuidanceState, Rect } from 'features/controlLayers/store/types';
import { getRegionalGuidanceWarnings } from 'features/controlLayers/store/validators';
import { IMAGE_INFLUENCE_TO_SETTINGS } from 'features/nodes/util/graph/generation/addFLUXRedux';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
import { serializeError } from 'serialize-error';
import type { Invocation, MainModelConfig } from 'services/api/types';
@@ -313,6 +314,7 @@ export const addRegions = async ({
image: {
image_name: image.image_name,
},
...IMAGE_INFLUENCE_TO_SETTINGS[ipAdapter.imageInfluence ?? 'highest'],
});
// Connect the mask to the conditioning

View File

@@ -3,8 +3,7 @@ import { useAppStore } from 'app/store/storeHooks';
import { deepClone } from 'common/util/deepClone';
import { parseify } from 'common/util/serialize';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState, WorkflowsState } from 'features/nodes/store/types';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import type { NodesState } from 'features/nodes/store/types';
import { isInvocationNode, isNotesNode } from 'features/nodes/types/invocation';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { zWorkflowV3 } from 'features/nodes/types/workflow';
@@ -15,12 +14,6 @@ import { fromZodError } from 'zod-validation-error';
const log = logger('workflows');
type BuildWorkflowArg = {
nodes: NodesState['nodes'];
edges: NodesState['edges'];
workflow: WorkflowsState;
};
const workflowKeys = [
'name',
'author',
@@ -35,10 +28,9 @@ const workflowKeys = [
'form',
] satisfies (keyof WorkflowV3)[];
type BuildWorkflowFunction = (arg: BuildWorkflowArg) => WorkflowV3;
export const buildWorkflowFast: BuildWorkflowFunction = ({ nodes, edges, workflow }: BuildWorkflowArg): WorkflowV3 => {
const clonedWorkflow = pick(workflow, workflowKeys);
export const buildWorkflowFast = (nodesState: NodesState): WorkflowV3 => {
const { nodes, edges, ...rest } = nodesState;
const clonedWorkflow = pick(rest, workflowKeys);
const newWorkflow: WorkflowV3 = {
...clonedWorkflow,
@@ -69,9 +61,9 @@ export const buildWorkflowFast: BuildWorkflowFunction = ({ nodes, edges, workflo
return deepClone(newWorkflow);
};
export const buildWorkflowWithValidation = ({ nodes, edges, workflow }: BuildWorkflowArg): WorkflowV3 | null => {
export const buildWorkflowWithValidation = (nodesState: NodesState): WorkflowV3 | null => {
// builds what really, really should be a valid workflow
const workflowToValidate = buildWorkflowFast({ nodes, edges, workflow });
const workflowToValidate = buildWorkflowFast(nodesState);
// but bc we are storing this in the DB, let's be extra sure
const result = zWorkflowV3.safeParse(workflowToValidate);
@@ -91,10 +83,8 @@ export const buildWorkflowWithValidation = ({ nodes, edges, workflow }: BuildWor
export const useBuildWorkflowFast = (): (() => WorkflowV3) => {
const store = useAppStore();
const buildWorkflow = useCallback(() => {
const state = store.getState();
const { nodes, edges } = selectNodesSlice(state);
const workflow = selectWorkflowSlice(state);
return buildWorkflowFast({ nodes, edges, workflow });
const nodesState = selectNodesSlice(store.getState());
return buildWorkflowFast(nodesState);
}, [store]);
return buildWorkflow;

View File

@@ -6,10 +6,9 @@ import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsS
import { selectIterations } from 'features/controlLayers/store/paramsSlice';
import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
import { $isInPublishFlow, useIsWorkflowPublished } from 'features/nodes/components/sidePanel/workflow/publish';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import type { BatchSizeResult } from 'features/nodes/util/node/resolveBatchValue';
import { getBatchSize } from 'features/nodes/util/node/resolveBatchValue';
import type { Reason } from 'features/queue/store/readiness';
@@ -178,7 +177,7 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
const [_, enqueueMutation] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions);
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isPublished = useIsWorkflowPublished();
const text = useMemo(() => {
if (enqueueMutation.isLoading) {

View File

@@ -120,11 +120,11 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
)}
</Flex>
<Flex alignItems="center" w={COLUMN_WIDTHS.validationRun} flexShrink={0}>
{!isValidationRun && <Badge>{t('workflows.builder.publishingValidationRun')}</Badge>}
{isValidationRun && <Badge>{t('workflows.builder.publishingValidationRun')}</Badge>}
</Flex>
<Flex alignItems="center" w={COLUMN_WIDTHS.actions} pe={3}>
<ButtonGroup size="xs" variant="ghost">
{(!isFailed || !isRetryEnabled) && (
{(!isFailed || !isRetryEnabled || isValidationRun) && (
<IconButton
onClick={handleCancelQueueItem}
isDisabled={isCanceled}
@@ -133,7 +133,7 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
icon={<PiXBold />}
/>
)}
{isFailed && isRetryEnabled && (
{isFailed && isRetryEnabled && !isValidationRun && (
<IconButton
onClick={handleRetryQueueItem}
isLoading={isLoadingRetryQueueItem}

View File

@@ -26,14 +26,9 @@ export const useEnqueueWorkflows = () => {
dispatch(enqueueRequestedWorkflows());
const state = getState();
const nodesState = selectNodesSlice(state);
const workflow = state.workflow;
const templates = $templates.get();
const graph = buildNodesGraph(state, templates);
const builtWorkflow = buildWorkflowWithValidation({
nodes: nodesState.nodes,
edges: nodesState.edges,
workflow,
});
const builtWorkflow = buildWorkflowWithValidation(nodesState);
if (builtWorkflow) {
// embedded workflows don't have an id
@@ -134,10 +129,10 @@ export const useEnqueueWorkflows = () => {
} as const;
});
assert(workflow.id, 'Workflow without ID cannot be used for API validation run');
assert(nodesState.id, 'Workflow without ID cannot be used for API validation run');
batchConfig.validation_run_data = {
workflow_id: workflow.id,
workflow_id: nodesState.id,
input_fields: api_input_fields,
output_fields: api_output_fields,
};

View File

@@ -2,7 +2,7 @@ import { ReactFlowProvider } from '@xyflow/react';
import { useAppSelector } from 'app/store/storeHooks';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import NodeEditor from 'features/nodes/components/NodeEditor';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice';
import { memo } from 'react';
export const WorkflowsMainPanel = memo(() => {

View File

@@ -2,7 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { newSessionRequested } from 'features/controlLayers/store/actions';
import { workflowLoaded } from 'features/nodes/store/actions';
import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { atom } from 'nanostores';
import type { CanvasRightPanelTabName, TabName, UIState } from './uiTypes';

View File

@@ -1,9 +1,8 @@
import { ConfirmationAlertDialog, Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
import { useLoadWorkflowFromImage } from 'features/workflowLibrary/hooks/useLoadWorkflowFromImage';
@@ -97,7 +96,7 @@ const useLoadImmediate = () => {
* before loading the workflow.
*/
export const useLoadWorkflowWithDialog = () => {
const isTouched = useAppSelector(selectWorkflowIsTouched);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const loadImmediate = useLoadImmediate();
const loadWorkflowWithDialog = useCallback(
@@ -116,14 +115,14 @@ export const useLoadWorkflowWithDialog = () => {
(
data: LoadLibraryWorkflowData | LoadWorkflowFromObjectData | LoadWorkflowFromFileData | LoadWorkflowFromImageData
) => {
if (!isTouched) {
if (!doesWorkflowHaveUnsavedChanges) {
$dialogState.set({ ...data, isOpen: false });
loadImmediate();
} else {
$dialogState.set({ ...data, isOpen: true });
}
},
[loadImmediate, isTouched]
[doesWorkflowHaveUnsavedChanges, loadImmediate]
);
return loadWorkflowWithDialog;

View File

@@ -1,10 +1,11 @@
import { ConfirmationAlertDialog, Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { buildUseDisclosure } from 'common/hooks/useBoolean';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
import { selectWorkflowIsTouched, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,7 +16,7 @@ export const useNewWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const dialog = useDialogState();
const isTouched = useAppSelector(selectWorkflowIsTouched);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const workflowLibraryModal = useWorkflowLibraryModal();
const createImmediate = useCallback(() => {
@@ -33,12 +34,12 @@ export const useNewWorkflow = () => {
}, [dialog, dispatch, t, workflowLibraryModal]);
const createWithDialog = useCallback(() => {
if (!isTouched) {
if (!doesWorkflowHaveUnsavedChanges) {
createImmediate();
return;
}
dialog.open();
}, [dialog, createImmediate, isTouched]);
}, [doesWorkflowHaveUnsavedChanges, dialog, createImmediate]);
return {
createImmediate,

View File

@@ -1,6 +1,6 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowIsPublished, selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useIsWorkflowPublished } from 'features/nodes/components/sidePanel/workflow/publish';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,13 +9,13 @@ import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowMenuItem = () => {
const { t } = useTranslation();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
const isTouched = useAppSelector(selectWorkflowIsTouched);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
const isPublished = useIsWorkflowPublished();
return (
<MenuItem
as="button"
isDisabled={!isTouched || !!isPublished}
isDisabled={!doesWorkflowHaveUnsavedChanges || !!isPublished}
icon={<PiFloppyDiskBold />}
onClick={saveOrSaveAsWorkflow}
>

View File

@@ -5,10 +5,8 @@ import {
formFieldInitialValuesChanged,
workflowCategoryChanged,
workflowIDChanged,
workflowIsPublishedChanged,
workflowNameChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
} from 'features/nodes/store/nodesSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues';
import { newWorkflowSaved } from 'features/workflowLibrary/store/actions';
@@ -66,14 +64,12 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
meta: { category },
} = data.workflow;
dispatch(workflowIDChanged(id));
dispatch(workflowIsPublishedChanged(false));
dispatch(workflowNameChanged(name));
dispatch(workflowCategoryChanged(category));
dispatch(newWorkflowSaved({ category }));
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
updateOpenedAt({ workflow_id: id });
dispatch(workflowSaved());
onSuccess && onSuccess();
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),

View File

@@ -1,17 +1,13 @@
import { useAppStore } from 'app/store/nanostores/store';
import { getFormFieldInitialValues as _getFormFieldInitialValues } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import {
getFormFieldInitialValues as _getFormFieldInitialValues,
selectWorkflowForm,
} from 'features/nodes/store/workflowSlice';
import { useCallback } from 'react';
export const useGetFormFieldInitialValues = () => {
const store = useAppStore();
const getFormFieldInitialValues = useCallback(() => {
const form = selectWorkflowForm(store.getState());
const { nodes } = selectNodesSlice(store.getState());
const { nodes, form } = selectNodesSlice(store.getState());
return _getFormFieldInitialValues(form, nodes);
}, [store]);

View File

@@ -1,7 +1,7 @@
import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { formFieldInitialValuesChanged, workflowSaved } from 'features/nodes/store/workflowSlice';
import { formFieldInitialValuesChanged } from 'features/nodes/store/nodesSlice';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues';
import { workflowUpdated } from 'features/workflowLibrary/store/actions';
@@ -47,7 +47,6 @@ export const useSaveLibraryWorkflow = (): UseSaveLibraryWorkflowReturn => {
try {
await updateWorkflow(workflow).unwrap();
dispatch(workflowUpdated());
dispatch(workflowSaved());
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
toast.update(toastRef.current, {

View File

@@ -1,5 +1,4 @@
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import { useIsWorkflowPublished } from 'features/nodes/components/sidePanel/workflow/publish';
import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow';
import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
import { isLibraryWorkflow, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveLibraryWorkflow';
@@ -12,7 +11,7 @@ import { useCallback } from 'react';
*/
export const useSaveOrSaveAsWorkflow = () => {
const buildWorkflow = useBuildWorkflowFast();
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isPublished = useIsWorkflowPublished();
const { saveWorkflow } = useSaveLibraryWorkflow();
const saveOrSaveAsWorkflow = useCallback(() => {

View File

@@ -1,8 +1,7 @@
import { logger } from 'app/logging/logger';
import { useAppDispatch } from 'app/store/storeHooks';
import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState';
import { workflowLoaded } from 'features/nodes/store/actions';
import { $templates } from 'features/nodes/store/nodesSlice';
import { $templates, workflowLoaded } from 'features/nodes/store/nodesSlice';
import { $needsFit } from 'features/nodes/store/reactFlowInstance';
import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error';
import type { WorkflowV3 } from 'features/nodes/types/workflow';

View File

@@ -148,6 +148,13 @@ export const workflowsApi = api.injectEndpoints({
}),
invalidatesTags: (result, error, workflow_id) => [{ type: 'Workflow', id: workflow_id }],
}),
unpublishWorkflow: build.mutation<void, string>({
query: (workflow_id) => ({
url: buildWorkflowsUrl(`i/${workflow_id}/unpublish`),
method: 'POST',
}),
invalidatesTags: (result, error, workflow_id) => [{ type: 'Workflow', id: workflow_id }],
}),
}),
});
@@ -163,4 +170,5 @@ export const {
useListWorkflowsInfiniteInfiniteQuery,
useSetWorkflowThumbnailMutation,
useDeleteWorkflowThumbnailMutation,
useUnpublishWorkflowMutation,
} = workflowsApi;

View File

@@ -7823,6 +7823,25 @@ export type components = {
* @default null
*/
redux_model?: components["schemas"]["ModelIdentifierField"];
/**
* Downsampling Factor
* @description Redux Downsampling Factor (1-9)
* @default 1
*/
downsampling_factor?: number;
/**
* Downsampling Function
* @description Redux Downsampling Function
* @default area
* @enum {string}
*/
downsampling_function?: "nearest" | "bilinear" | "bicubic" | "area" | "nearest-exact";
/**
* Weight
* @description Redux weight (0.0-1.0)
* @default 1
*/
weight?: number;
/**
* type
* @default flux_redux

View File

@@ -1,5 +1,7 @@
import { ExternalLink } from '@invoke-ai/ui-library';
import { isAnyOf } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { listenerMiddleware } from 'app/store/middleware/listenerMiddleware';
import { socketConnected } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected';
import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId';
@@ -9,10 +11,9 @@ import { deepClone } from 'common/util/deepClone';
import {
$isInPublishFlow,
$outputNodeId,
$validationRunBatchId,
$validationRunData,
} from 'features/nodes/components/sidePanel/workflow/publish';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { workflowIsPublishedChanged } from 'features/nodes/store/workflowSlice';
import { zNodeStatus } from 'features/nodes/types/invocation';
import ErrorToastDescription, { getTitle } from 'features/toast/ErrorToastDescription';
import { toast } from 'features/toast/toast';
@@ -22,6 +23,7 @@ import type { ApiTagDescription } from 'services/api';
import { api, LIST_TAG } from 'services/api';
import { modelsApi } from 'services/api/endpoints/models';
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { workflowsApi } from 'services/api/endpoints/workflows';
import { buildOnInvocationComplete } from 'services/events/onInvocationComplete';
import { buildOnModelInstallError } from 'services/events/onModelInstallError';
import type { ClientToServerEvents, ServerToClientEvents } from 'services/events/types';
@@ -425,14 +427,43 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
$lastProgressEvent.set(null);
// When a validation run is completed, we want to clear the validation run batch ID & set the workflow as published
if (batch_status.batch_id === $validationRunBatchId.get()) {
$validationRunBatchId.set(null);
if (status === 'completed') {
dispatch(workflowIsPublishedChanged(true));
$isInPublishFlow.set(false);
$outputNodeId.set(null);
}
const validationRunData = $validationRunData.get();
if (!validationRunData || batch_status.batch_id !== validationRunData.batchId || status !== 'completed') {
return;
}
// The published status of a workflow is server state, provided to the client in by the getWorkflow query.
// After successfully publishing a workflow, we need to invalidate the query cache so that the published status is
// seen throughout the app. We also need to reset the publish flow state.
//
// But, there is a race condition! If we invalidate the query cache and then immediately clear the publish flow state,
// between the time when the publish state is cleared and the query is re-fetched, we will render the wrong UI.
//
// So, we really need to wait for the query re-fetch to complete before clearing the publish flow state. This isn't
// possible using the `invalidateTags()` API. But we can fudge it by adding a once-off listener for that query.
listenerMiddleware.startListening({
matcher: isAnyOf(
workflowsApi.endpoints.getWorkflow.matchFulfilled,
workflowsApi.endpoints.getWorkflow.matchRejected
),
effect: (action, listenerApi) => {
if (workflowsApi.endpoints.getWorkflow.matchFulfilled(action)) {
// If this query was re-fetching the workflow that was just published, we can clear the publish flow state and
// unsubscribe from the listener
if (action.payload.workflow_id === validationRunData.workflowId) {
listenerApi.unsubscribe();
$validationRunData.set(null);
$isInPublishFlow.set(false);
$outputNodeId.set(null);
}
} else if (workflowsApi.endpoints.getWorkflow.matchRejected(action)) {
// If the query failed, we can unsubscribe from the listener
listenerApi.unsubscribe();
}
},
});
dispatch(workflowsApi.util.invalidateTags([{ type: 'Workflow', id: validationRunData.workflowId }]));
}
});

View File

@@ -1 +1 @@
__version__ = "5.10.0dev4"
__version__ = "5.10.0a1"

View File

@@ -45,6 +45,7 @@ dependencies = [
"onnxruntime==1.19.2",
"opencv-python==4.9.0.80",
"safetensors",
"sentencepiece",
"spandrel",
"torch~=2.6.0", # torch and related dependencies are loosely pinned, will respect requirement of `diffusers[torch]`
"torchsde", # diffusers needs this for SDE solvers, but it is not an explicit dep of diffusers

Some files were not shown because too many files have changed in this diff Show More