Compare commits

..

33 Commits

Author SHA1 Message Date
psychedelicious
1f7d108cac chore: bump version to v5.10.0dev2
Doing a dev build so I can test the launcher.
2025-04-03 10:33:17 +10:00
psychedelicious
d06cc71cd9 build: downgrade python to 3.11 in pins 2025-04-03 10:31:56 +10:00
psychedelicious
ecbc1cf85d build: restore prev setuptools config to fix wheel build 2025-04-03 09:43:31 +10:00
psychedelicious
dcad306aee ci: use py3.12 to build installer 2025-04-03 09:30:17 +10:00
psychedelicious
bea9d037bc experiment: add pins.json to repo
The launcher will query this file to get the pins needed for installation
2025-04-03 08:55:45 +10:00
psychedelicious
eed4260975 chore: bump version to v5.10.0dev1
Doing a dev build so I can test the launcher.
2025-04-03 08:55:45 +10:00
psychedelicious
6a79b1c64c chore: update uv.lock for latest pydantic
Ran `uv lock --upgrade-package pydantic`
2025-04-03 08:55:45 +10:00
psychedelicious
4cdfe3e30d 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-03 08:55:45 +10:00
psychedelicious
df294db236 chore: typegen 2025-04-03 08:55:45 +10:00
psychedelicious
52e247cfe0 chore: remove pydantic pin 2025-04-03 08:55:45 +10:00
psychedelicious
0a13640bf3 chore(ui): typegen 2025-04-03 08:55:45 +10:00
psychedelicious
643b71f56c tests: update tests/test_object_serializer_disk.py 2025-04-03 08:55:45 +10:00
psychedelicious
b745411866 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-03 08:55:45 +10:00
Eugene Brodsky
c3ffb0feed resolve conflict between timm version needed by LLaVA and controlnet-aux 2025-04-03 08:55:45 +10:00
Eugene Brodsky
f53ff5fa3c reintroduce GPU_DRIVER build arg in CI container build, as it has apparently been removed 2025-04-03 08:55:45 +10:00
Eugene Brodsky
b2337b56bd remove obsoleted depenencies that were used by the CLI 2025-04-03 08:55:45 +10:00
Eugene Brodsky
fb777b4502 modify docs for python 3.12 2025-04-03 08:55:45 +10:00
Eugene Brodsky
4c12f5a011 update nodes schema / typegen 2025-04-03 08:55:44 +10:00
Eugene Brodsky
d4655ea21a update uv.lock 2025-04-03 08:55:44 +10:00
Eugene Brodsky
752b62d0b5 refactor Dockerfile; get rid of multi-stage build; upgrade to python 3.12 2025-04-03 08:55:44 +10:00
Eugene Brodsky
9a0efb308d use uv.lock to pin dependencies 2025-04-03 08:55:44 +10:00
Eugene Brodsky
b4c276b50f 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-03 08:55:44 +10:00
Riku
db03c196a1 translationBot(ui): update translation (German)
Currently translated at 66.8% (1230 of 1840 strings)

Co-authored-by: Riku <riku.block@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/de/
Translation: InvokeAI/Web UI
2025-04-03 07:42:43 +11:00
Riccardo Giovanetti
6bc36b697d translationBot(ui): update translation (Italian)
Currently translated at 98.8% (1818 of 1840 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.6% (1816 of 1840 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.7% (1816 of 1839 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-03 07:42:43 +11:00
Linos
b7d71d3028 translationBot(ui): update translation (Vietnamese)
Currently translated at 100.0% (1840 of 1840 strings)

translationBot(ui): update translation (Vietnamese)

Currently translated at 100.0% (1838 of 1838 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-03 07:42:43 +11:00
psychedelicious
fa1ebd9d2f fix(ui): do not switch between images when focused on a tab element
Arrow keys should only navigate between tabs, not gallery images.
2025-04-03 07:40:10 +11:00
psychedelicious
eed5d02069 fix(ui): handling for invalid edges when loading workflows
Previously, reactflow appears to have handled an edge case when using its `applyChanges` utility. If a change was provided without an item, it would skip that change. For example, an "add edge" change that somehow passed `null` as the edge, instead of a valid edge.

In our workflow loading and validation logic, invalid edges were removed from the array using `delete edges[i]`. This left "holes" in the array of edges. We then asked `reactflow` to add these edges to state. When it encountered one of the "holes", it skipped over it.

In a recent release (unsure which, somewhere between the latest v11 and ~v12.4) this seems to have changed. It no longer skips over the "holes" and instead trusts the data. This can cause a couple issues:
- Error when loading the workflow if `reactflow` attempt to do anything with the nonexistent edge.
- If somehow the workflow makes it into state with "holes" in the array of edges, all sorts of other stuff breaks when our code does anything with the nonexistent edge.

Two-part fix:
- Update the invalid edge handling to not use `delete edges[i]`. Instead, as we check each edge, we add invalid ones to a set. Then, after all the checks are finished, filter out the invalid edges. The resultant edges array has no holes.
- Simplify the logic around setting nodes and edges in redux. Previously we were using `reactflow`'s `applyChanges` utils, but this does literally nothing except take extra CPU cycles. We can simply set the loaded nodes and edges directly in redux. Perhaps we were using `applyChanges` because it addressed the "holes" issue? Not sure. But we don't need it now.

Closes #7868
2025-04-03 07:37:49 +11:00
psychedelicious
3650d91045 chore(ui): bump @xyflow/react to latest 2025-04-03 07:37:49 +11:00
Eugene Brodsky
6c7d08cacb Change timm and controlnet-aux pins to fix LLaVA model support (#7846)
## Summary

`timm` below 1.0.0 prevents llava models from working (broken in
transformers). but `controlnet-aux` pins `timm` to an earlier version
because otherwise it was breaking the ZoeDepth controlnet.

we don't use ZoeDepth (replaced by depthAnything), and downgrading
controlnet-aux seems to be acceptable.

more context here:

https://github.com/huggingface/controlnet_aux/issues/106
https://github.com/huggingface/controlnet_aux/pull/101


Note that this results in some warnings on startup, stemming from
controlnet-aux:

![image](https://github.com/user-attachments/assets/fa908837-6154-42a2-a93b-eb5e363f5783)

we can probably silence the warnings as a separate enhancement

## Related Issues / Discussions

<!--WHEN APPLICABLE: List any related issues or discussions on github or
discord. If this PR closes an issue, please use the "Closes #1234"
format, so that the issue will be automatically closed when the PR
merges.-->

## QA Instructions

<!--WHEN APPLICABLE: Describe how you have tested the changes in this
PR. Provide enough detail that a reviewer can reproduce your tests.-->

## Merge Plan

<!--WHEN APPLICABLE: Large PRs, or PRs that touch sensitive things like
DB schemas, may need some care when merging. For example, a careful
rebase by the change author, timing to not interfere with a pending
release, or a message to contributors on discord after merging.-->

## Checklist

- [x] _The PR has a short but descriptive title, suitable for a
changelog_
- [ ] _Tests added / updated (if applicable)_
- [ ] _Documentation added / updated (if applicable)_
- [ ] _Updated `What's New` copy (if doing a release after this PR)_
2025-04-01 21:16:40 -04:00
Eugene Brodsky
bb1c40f222 Merge branch 'main' into pin-timm-for-llava 2025-04-01 21:10:30 -04:00
Eugene Brodsky
d26b7a1a12 Merge branch 'main' into pin-timm-for-llava 2025-03-31 11:37:29 -04:00
Eugene Brodsky
c9992914d6 Merge branch 'main' into pin-timm-for-llava 2025-03-28 09:20:30 -04:00
Eugene Brodsky
3f12a43e75 remove pin for controlnet-aux and pin timm to a version that works with llava
timm < 1.0.0 prevents llava models from working (broken in transformers). but controlnet-aux pinned it to an earlier version because otherwise it was breaking the ZoeDepth controlnet.

we don't use ZoeDepth (replaced by depthAnything), and downgrading controlnet-aux seems to be acceptable.

more context here:

https://github.com/huggingface/controlnet_aux/issues/106
https://github.com/huggingface/controlnet_aux/pull/101
2025-03-26 16:58:18 -04:00
140 changed files with 5316 additions and 2476 deletions

View File

@@ -1,9 +1,11 @@
*
!invokeai
!pyproject.toml
!uv.lock
!docker/docker-entrypoint.sh
!LICENSE
**/dist
**/node_modules
**/__pycache__
**/*.egg-info
**/*.egg-info

View File

@@ -97,6 +97,8 @@ jobs:
context: .
file: docker/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
GPU_DRIVER=${{ matrix.gpu-driver }}
push: ${{ github.ref == 'refs/heads/main' || github.ref_type == 'tag' || github.event.inputs.push-to-registry }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -17,7 +17,7 @@ jobs:
- name: setup python
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.12'
cache: pip
cache-dependency-path: pyproject.toml

2
.nvmrc
View File

@@ -1 +1 @@
v22.12.0
v22.14.0

View File

@@ -1,77 +1,6 @@
# syntax=docker/dockerfile:1.4
## Builder stage
FROM library/ubuntu:24.04 AS builder
ARG DEBIAN_FRONTEND=noninteractive
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt update && apt-get install -y \
build-essential \
git
# Install `uv` for package management
COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV INVOKEAI_SRC=/opt/invokeai
ENV PYTHON_VERSION=3.11
ENV UV_PYTHON=3.11
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV"
ENV UV_INDEX="https://download.pytorch.org/whl/cu124"
ARG GPU_DRIVER=cuda
# unused but available
ARG BUILDPLATFORM
# Switch to the `ubuntu` user to work around dependency issues with uv-installed python
RUN mkdir -p ${VIRTUAL_ENV} && \
mkdir -p ${INVOKEAI_SRC} && \
chmod -R a+w /opt && \
mkdir ~ubuntu/.cache && chown ubuntu: ~ubuntu/.cache
USER ubuntu
# Install python
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
uv python install ${PYTHON_VERSION}
WORKDIR ${INVOKEAI_SRC}
# Install project's dependencies as a separate layer so they aren't rebuilt every commit.
# bind-mount instead of copy to defer adding sources to the image until next layer.
#
# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
# x86_64/CUDA is the default
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=invokeai/version,target=invokeai/version \
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.1"; \
fi && \
uv sync --no-install-project
# Now that the bulk of the dependencies have been installed, copy in the project files that change more frequently.
COPY invokeai invokeai
COPY pyproject.toml .
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
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.1"; \
fi && \
uv sync
#### Build the Web UI ------------------------------------
#### Web UI ------------------------------------
FROM docker.io/node:22-slim AS web-builder
ENV PNPM_HOME="/pnpm"
@@ -85,69 +14,89 @@ RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN npx vite build
#### Runtime stage ---------------------------------------
## Backend ---------------------------------------
FROM library/ubuntu:24.04 AS runtime
FROM library/ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt update && apt install -y --no-install-recommends \
ca-certificates \
git \
gosu \
libglib2.0-0 \
libgl1 \
libglx-mesa0 \
build-essential \
libopencv-dev \
libstdc++-10-dev
RUN apt update && apt install -y --no-install-recommends \
git \
curl \
vim \
tmux \
ncdu \
iotop \
bzip2 \
gosu \
magic-wormhole \
libglib2.0-0 \
libgl1 \
libglx-mesa0 \
build-essential \
libopencv-dev \
libstdc++-10-dev &&\
apt-get clean && apt-get autoclean
ENV \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
VIRTUAL_ENV=/opt/venv \
INVOKEAI_SRC=/opt/invokeai \
PYTHON_VERSION=3.12 \
UV_PYTHON=3.12 \
UV_COMPILE_BYTECODE=1 \
UV_MANAGED_PYTHON=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_INDEX="https://download.pytorch.org/whl/cu124" \
INVOKEAI_ROOT=/invokeai \
INVOKEAI_HOST=0.0.0.0 \
INVOKEAI_PORT=9090 \
PATH="/opt/venv/bin:$PATH" \
CONTAINER_UID=${CONTAINER_UID:-1000} \
CONTAINER_GID=${CONTAINER_GID:-1000}
ENV INVOKEAI_SRC=/opt/invokeai
ENV VIRTUAL_ENV=/opt/venv
ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV"
ENV PYTHON_VERSION=3.11
ENV INVOKEAI_ROOT=/invokeai
ENV INVOKEAI_HOST=0.0.0.0
ENV INVOKEAI_PORT=9090
ENV PATH="$VIRTUAL_ENV/bin:$INVOKEAI_SRC:$PATH"
ENV CONTAINER_UID=${CONTAINER_UID:-1000}
ENV CONTAINER_GID=${CONTAINER_GID:-1000}
ARG GPU_DRIVER=cuda
# Install `uv` for package management
# and install python for the ubuntu user (expected to exist on ubuntu >=24.x)
# this is too tiny to optimize with multi-stage builds, but maybe we'll come back to it
COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/
USER ubuntu
RUN uv python install ${PYTHON_VERSION}
USER root
COPY --from=ghcr.io/astral-sh/uv:0.6.9 /uv /uvx /bin/
# --link requires buldkit w/ dockerfile syntax 1.4
COPY --link --from=builder ${INVOKEAI_SRC} ${INVOKEAI_SRC}
COPY --link --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
# Link amdgpu.ids for ROCm builds
# contributed by https://github.com/Rubonnek
RUN mkdir -p "/opt/amdgpu/share/libdrm" &&\
ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids"
# Install python & allow non-root user to use it by traversing the /root dir without read permissions
RUN --mount=type=cache,target=/root/.cache/uv \
uv python install ${PYTHON_VERSION} && \
# chmod --recursive a+rX /root/.local/share/uv/python
chmod 711 /root
WORKDIR ${INVOKEAI_SRC}
# Install project's dependencies as a separate layer so they aren't rebuilt every commit.
# bind-mount instead of copy to defer adding sources to the image until next layer.
#
# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
# x86_64/CUDA is the default
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 \
# this is just to get the package manager to recognize that the project exists, without making changes to the docker layer
--mount=type=bind,source=invokeai/version,target=invokeai/version \
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 sync --frozen
# build patchmatch
RUN cd /usr/lib/$(uname -p)-linux-gnu/pkgconfig/ && ln -sf opencv4.pc opencv.pc
RUN python -c "from patchmatch import patch_match"
# Link amdgpu.ids for ROCm builds
# contributed by https://github.com/Rubonnek
RUN mkdir -p "/opt/amdgpu/share/libdrm" &&\
ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids"
RUN mkdir -p ${INVOKEAI_ROOT} && chown -R ${CONTAINER_UID}:${CONTAINER_GID} ${INVOKEAI_ROOT}
COPY docker/docker-entrypoint.sh ./
ENTRYPOINT ["/opt/invokeai/docker-entrypoint.sh"]
CMD ["invokeai-web"]
# --link requires buldkit w/ dockerfile syntax 1.4, does not work with podman
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

View File

@@ -41,7 +41,7 @@ If you just want to use Invoke, you should use the [launcher][launcher link].
With the modifications made, the install command should look something like this:
```sh
uv pip install -e ".[dev,test,docs,xformers]" --python 3.11 --python-preference only-managed --index=https://download.pytorch.org/whl/cu124 --reinstall
uv pip install -e ".[dev,test,docs,xformers]" --python 3.12 --python-preference only-managed --index=https://download.pytorch.org/whl/cu124 --reinstall
```
6. At this point, you should have Invoke installed, a venv set up and activated, and the server running. But you will see a warning in the terminal that no UI was found. If you go to the URL for the server, you won't get a UI.

View File

@@ -43,10 +43,10 @@ The following commands vary depending on the version of Invoke being installed a
3. Create a virtual environment in that directory:
```sh
uv venv --relocatable --prompt invoke --python 3.11 --python-preference only-managed .venv
uv venv --relocatable --prompt invoke --python 3.12 --python-preference only-managed .venv
```
This command creates a portable virtual environment at `.venv` complete with a portable python 3.11. It doesn't matter if your system has no python installed, or has a different version - `uv` will handle everything.
This command creates a portable virtual environment at `.venv` complete with a portable python 3.12. It doesn't matter if your system has no python installed, or has a different version - `uv` will handle everything.
4. Activate the virtual environment:
@@ -88,13 +88,13 @@ The following commands vary depending on the version of Invoke being installed a
8. Install the `invokeai` package. Substitute the package specifier and version.
```sh
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.11 --python-preference only-managed --force-reinstall
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.12 --python-preference only-managed --force-reinstall
```
If you determined you needed to use a `PyPI` index URL in the previous step, you'll need to add `--index=<INDEX_URL>` like this:
```sh
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.11 --python-preference only-managed --index=<INDEX_URL> --force-reinstall
uv pip install <PACKAGE_SPECIFIER>==<VERSION> --python 3.12 --python-preference only-managed --index=<INDEX_URL> --force-reinstall
```
9. Deactivate and reactivate your venv so that the invokeai-specific commands become available in the environment:

View File

@@ -41,7 +41,7 @@ The requirements below are rough guidelines for best performance. GPUs with less
You don't need to do this if you are installing with the [Invoke Launcher](./quick_start.md).
Invoke requires python 3.10 or 3.11. If you don't already have one of these versions installed, we suggest installing 3.11, as it will be supported for longer.
Invoke requires python 3.10 through 3.12. If you don't already have one of these versions installed, we suggest installing 3.12, as it will be supported for longer.
Check that your system has an up-to-date Python installed by running `python3 --version` in the terminal (Linux, macOS) or cmd/powershell (Windows).
@@ -49,19 +49,19 @@ Check that your system has an up-to-date Python installed by running `python3 --
=== "Windows"
- Install python 3.11 with [an official installer].
- Install python with [an official installer].
- The installer includes an option to add python to your PATH. Be sure to enable this. If you missed it, re-run the installer, choose to modify an existing installation, and tick that checkbox.
- You may need to install [Microsoft Visual C++ Redistributable].
=== "macOS"
- Install python 3.11 with [an official installer].
- Install python with [an official installer].
- If model installs fail with a certificate error, you may need to run this command (changing the python version to match what you have installed): `/Applications/Python\ 3.10/Install\ Certificates.command`
- If you haven't already, you will need to install the XCode CLI Tools by running `xcode-select --install` in a terminal.
=== "Linux"
- Installing python varies depending on your system. On Ubuntu, you can use the [deadsnakes PPA](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa).
- Installing python varies depending on your system. We recommend [using `uv` to manage your python installation](https://docs.astral.sh/uv/concepts/python-versions/#installing-a-python-version).
- You'll need to install `libglib2.0-0` and `libgl1-mesa-glx` for OpenCV to work. For example, on a Debian system: `sudo apt update && sudo apt install -y libglib2.0-0 libgl1-mesa-glx`
## Drivers

View File

@@ -37,7 +37,13 @@ from invokeai.app.services.style_preset_records.style_preset_records_sqlite impo
from invokeai.app.services.urls.urls_default import LocalUrlService
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
BasicConditioningInfo,
ConditioningFieldData,
FLUXConditioningInfo,
SD3ConditioningInfo,
SDXLConditioningInfo,
)
from invokeai.backend.util.logging import InvokeAILogger
from invokeai.version.invokeai_version import __version__
@@ -101,10 +107,25 @@ class ApiDependencies:
images = ImageService()
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
tensors = ObjectSerializerForwardCache(
ObjectSerializerDisk[torch.Tensor](output_folder / "tensors", ephemeral=True)
ObjectSerializerDisk[torch.Tensor](
output_folder / "tensors",
safe_globals=[torch.Tensor],
ephemeral=True,
),
max_cache_size=0,
)
conditioning = ObjectSerializerForwardCache(
ObjectSerializerDisk[ConditioningFieldData](output_folder / "conditioning", ephemeral=True)
ObjectSerializerDisk[ConditioningFieldData](
output_folder / "conditioning",
safe_globals=[
ConditioningFieldData,
BasicConditioningInfo,
SDXLConditioningInfo,
FLUXConditioningInfo,
SD3ConditioningInfo,
],
ephemeral=True,
),
)
download_queue_service = DownloadQueueService(app_config=configuration, event_bus=events)
model_images_service = ModelImageFileStorageDisk(model_images_folder / "model_images")

View File

@@ -1,13 +1,10 @@
import json
from typing import Any, Optional
from typing import Optional
from fastapi import Body, Path, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from pydantic import BaseModel
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.invocations.fields import BoardField
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_common import (
QUEUE_ITEM_STATUS,
@@ -18,7 +15,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
CancelByDestinationResult,
ClearResult,
EnqueueBatchResult,
FieldIdentifier,
PruneResult,
RetryItemsResult,
SessionQueueCountsByDestination,
@@ -26,7 +22,6 @@ from invokeai.app.services.session_queue.session_queue_common import (
SessionQueueItemDTO,
SessionQueueStatus,
)
from invokeai.app.services.shared.compose_pydantic_model import compose_model_from_fields
from invokeai.app.services.shared.pagination import CursorPaginatedResults
session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"])
@@ -39,17 +34,6 @@ class SessionQueueAndProcessorStatus(BaseModel):
processor: SessionProcessorStatus
class SimpleModelIdentifer(BaseModel):
id: str = Field(description="The model id")
model_field_overrides = {ModelIdentifierField: (SimpleModelIdentifer, Field(description="The model identifier"))}
def model_field_filter(field_type: type[Any]) -> bool:
return field_type not in {BoardField, Optional[BoardField]}
@session_queue_router.post(
"/{queue_id}/enqueue_batch",
operation_id="enqueue_batch",
@@ -61,52 +45,9 @@ async def enqueue_batch(
queue_id: str = Path(description="The queue id to perform this operation on"),
batch: Batch = Body(description="Batch to process"),
prepend: bool = Body(default=False, description="Whether or not to prepend this batch in the queue"),
is_api_validation_run: bool = Body(
default=False,
description="Whether or not this is a validation run.",
),
api_input_fields: Optional[list[FieldIdentifier]] = Body(
default=None, description="The fields that were used as input to the API"
),
api_output_fields: Optional[list[FieldIdentifier]] = Body(
default=None, description="The fields that were used as output from the API"
),
) -> EnqueueBatchResult:
"""Processes a batch and enqueues the output graphs for execution."""
if is_api_validation_run:
session_count = batch.get_session_count()
assert session_count == 1, "API validation run only supports single session batches"
if api_input_fields:
composed_model = compose_model_from_fields(
g=batch.graph,
field_identifiers=api_input_fields,
composed_model_class_name="APIInputModel",
model_field_overrides=model_field_overrides,
model_field_filter=model_field_filter,
)
json_schema = composed_model.model_json_schema(mode="validation")
print("API Input Model")
print(json.dumps(json_schema))
if api_output_fields:
composed_model = compose_model_from_fields(
g=batch.graph,
field_identifiers=api_output_fields,
composed_model_class_name="APIOutputModel",
)
json_schema = composed_model.model_json_schema(mode="validation")
print("API Output Model")
print(json.dumps(json_schema))
print("graph")
print(batch.graph.model_dump_json())
if batch.workflow is not None:
print("workflow")
print(batch.workflow.model_dump_json())
return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
queue_id=queue_id, batch=batch, prepend=prepend
)

View File

@@ -1,5 +1,4 @@
import io
import random
import traceback
from typing import Optional
@@ -25,37 +24,6 @@ from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import
IMAGE_MAX_AGE = 31536000
workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"])
ids = {
"6614752a-0420-4d81-98fc-e110069d4f38": random.choice([True, False]),
"default_5e8b008d-c697-45d0-8883-085a954c6ace": random.choice([True, False]),
"4b2b297a-0d47-4f43-8113-ebbf3f403089": random.choice([True, False]),
"d0ce602a-049e-4368-97ae-977b49eed042": random.choice([True, False]),
"f170a187-fd74-40b8-ba9c-00de173ea4b9": random.choice([True, False]),
"default_f96e794f-eb3e-4d01-a960-9b4e43402bcf": random.choice([True, False]),
"default_cbf0e034-7b54-4b2c-b670-3b1e2e4b4a88": random.choice([True, False]),
"default_dec5a2e9-f59c-40d9-8869-a056751d79b8": random.choice([True, False]),
"default_dbe46d95-22aa-43fb-9c16-94400d0ce2fd": random.choice([True, False]),
"default_d7a1c60f-ca2f-4f90-9e33-75a826ca6d8f": random.choice([True, False]),
"default_e71d153c-2089-43c7-bd2c-f61f37d4c1c1": random.choice([True, False]),
"default_7dde3e36-d78f-4152-9eea-00ef9c8124ed": random.choice([True, False]),
"default_444fe292-896b-44fd-bfc6-c0b5d220fffc": random.choice([True, False]),
"default_2d05e719-a6b9-4e64-9310-b875d3b2f9d2": random.choice([True, False]),
"acae7e87-070b-4999-9074-c5b593c86618": random.choice([True, False]),
"3008fc77-1521-49c7-ba95-94c5a4508d1d": random.choice([True, False]),
"default_686bb1d0-d086-4c70-9fa3-2f600b922023": random.choice([True, False]),
"36905c46-e768-4dc3-8ecd-e55fe69bf03c": random.choice([True, False]),
"7c3e4951-183b-40ef-a890-28eef4d50097": random.choice([True, False]),
"7a053b2f-64e4-4152-80e9-296006e77131": random.choice([True, False]),
"27d4f1be-4156-46e9-8d22-d0508cd72d4f": random.choice([True, False]),
"e881dc06-70d2-438f-b007-6f3e0c3c0e78": random.choice([True, False]),
"265d2244-a1d7-495c-a2eb-88217f5eae37": random.choice([True, False]),
"caebcbc7-2bf0-41c4-b553-106b585fddda": random.choice([True, False]),
"a7998705-474e-417d-bd37-a2a9480beedf": random.choice([True, False]),
"554d94b5-94b3-4d8e-8aed-51ebfc9deea5": random.choice([True, False]),
"e6898540-c1bc-408b-b944-c1e242cddbcd": random.choice([True, False]),
"363b0960-ab2c-4902-8df3-f592d6194bb3": random.choice([True, False]),
}
@workflows_router.get(
"/i/{workflow_id}",
@@ -71,8 +39,6 @@ async def get_workflow(
try:
thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
workflow.is_published = ids.get(workflow_id, False)
workflow.workflow.is_published = ids.get(workflow_id, False)
return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
@@ -140,11 +106,10 @@ async def list_workflows(
tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
is_published: Optional[bool] = Query(default=None, description="Whether to include/exclude published workflows"),
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
workflow_record_list_items = ApiDependencies.invoker.services.workflow_records.get_many(
workflows = ApiDependencies.invoker.services.workflow_records.get_many(
order_by=order_by,
direction=direction,
page=page,
@@ -153,23 +118,20 @@ async def list_workflows(
categories=categories,
tags=tags,
has_been_opened=has_been_opened,
is_published=is_published,
)
for item in workflow_record_list_items.items:
data = item.model_dump()
data["is_published"] = ids.get(item.workflow_id, False)
for workflow in workflows.items:
workflows_with_thumbnails.append(
WorkflowRecordListItemWithThumbnailDTO(
thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(item.workflow_id),
**data,
thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id),
**workflow.model_dump(),
)
)
return PaginatedResults[WorkflowRecordListItemWithThumbnailDTO](
items=workflows_with_thumbnails,
total=workflow_record_list_items.total,
page=workflow_record_list_items.page,
pages=workflow_record_list_items.pages,
per_page=workflow_record_list_items.per_page,
total=workflows.total,
page=workflows.page,
pages=workflows.pages,
per_page=workflows.per_page,
)

View File

@@ -302,10 +302,7 @@ class ModelRecordServiceSQL(ModelRecordServiceBase):
# We catch this error so that the app can still run if there are invalid model configs in the database.
# One reason that an invalid model config might be in the database is if someone had to rollback from a
# newer version of the app that added a new model type.
row_data = f"{row[0][:64]}..." if len(row[0]) > 64 else row[0]
self._logger.warning(
f"Found an invalid model config in the database. Ignoring this model. ({row_data})"
)
self._logger.warning(f"Found an invalid model config in the database. Ignoring this model. ({row[0]})")
else:
results.append(model_config)

View File

@@ -21,10 +21,16 @@ class ObjectSerializerDisk(ObjectSerializerBase[T]):
"""Disk-backed storage for arbitrary python objects. Serialization is handled by `torch.save` and `torch.load`.
:param output_dir: The folder where the serialized objects will be stored
:param safe_globals: A list of types to be added to the safe globals for torch serialization
:param ephemeral: If True, objects will be stored in a temporary directory inside the given output_dir and cleaned up on exit
"""
def __init__(self, output_dir: Path, ephemeral: bool = False):
def __init__(
self,
output_dir: Path,
safe_globals: list[type],
ephemeral: bool = False,
) -> None:
super().__init__()
self._ephemeral = ephemeral
self._base_output_dir = output_dir
@@ -42,6 +48,8 @@ class ObjectSerializerDisk(ObjectSerializerBase[T]):
self._output_dir = Path(self._tempdir.name) if self._tempdir else self._base_output_dir
self.__obj_class_name: Optional[str] = None
torch.serialization.add_safe_globals(safe_globals) if safe_globals else None
def load(self, name: str) -> T:
file_path = self._get_path(name)
try:

View File

@@ -33,12 +33,7 @@ class SessionQueueBase(ABC):
pass
@abstractmethod
def enqueue_batch(
self,
queue_id: str,
batch: Batch,
prepend: bool,
) -> Coroutine[Any, Any, EnqueueBatchResult]:
def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> Coroutine[Any, Any, EnqueueBatchResult]:
"""Enqueues all permutations of a batch for execution."""
pass

View File

@@ -157,28 +157,6 @@ class Batch(BaseModel):
v.validate_self()
return v
def get_session_count(self) -> int:
"""
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
creating them, as is done in `create_session_nfv_tuples()`.
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
many were _actually_ created (which may be less due to the maximum number of sessions).
If the session count has already been calculated, return the cached value.
"""
if not self.data:
return self.runs
data = []
for batch_datum_list in self.data:
to_zip = []
for batch_datum in batch_datum_list:
batch_data_items = range(len(batch_datum.items))
to_zip.append(batch_data_items)
data.append(list(zip(*to_zip, strict=True)))
data_product = list(product(*data))
return len(data_product) * self.runs
model_config = ConfigDict(
json_schema_extra={
"required": [
@@ -223,12 +201,6 @@ def get_workflow(queue_item_dict: dict) -> Optional[WorkflowWithoutID]:
return None
class FieldIdentifier(BaseModel):
kind: Literal["input", "output"] = Field(description="The kind of field")
node_id: str = Field(description="The ID of the node")
field_name: str = Field(description="The name of the field")
class SessionQueueItemWithoutGraph(BaseModel):
"""Session queue item without the full graph. Used for serialization."""
@@ -265,16 +237,6 @@ class SessionQueueItemWithoutGraph(BaseModel):
retried_from_item_id: Optional[int] = Field(
default=None, description="The item_id of the queue item that this item was retried from"
)
is_api_validation_run: bool = Field(
default=False,
description="Whether this queue item is an API validation run.",
)
api_input_fields: Optional[list[FieldIdentifier]] = Field(
default=None, description="The fields that were used as input to the API"
)
api_output_fields: Optional[list[FieldIdentifier]] = Field(
default=None, description="The nodes that were used as output from the API"
)
@classmethod
def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
@@ -574,6 +536,28 @@ def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str
count += 1
def calc_session_count(batch: Batch) -> int:
"""
Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
creating them, as is done in `create_session_nfv_tuples()`.
The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
many were _actually_ created (which may be less due to the maximum number of sessions).
"""
# TODO: Should this be a class method on Batch?
if not batch.data:
return batch.runs
data = []
for batch_datum_list in batch.data:
to_zip = []
for batch_datum in batch_datum_list:
batch_data_items = range(len(batch_datum.items))
to_zip.append(batch_data_items)
data.append(list(zip(*to_zip, strict=True)))
data_product = list(product(*data))
return len(data_product) * batch.runs
ValueToInsertTuple: TypeAlias = tuple[
str, # queue_id
str, # session (as stringified JSON)

View File

@@ -28,6 +28,7 @@ from invokeai.app.services.session_queue.session_queue_common import (
SessionQueueItemNotFoundError,
SessionQueueStatus,
ValueToInsertTuple,
calc_session_count,
prepare_values_to_insert,
)
from invokeai.app.services.shared.graph import GraphExecutionState
@@ -117,8 +118,7 @@ class SqliteSessionQueue(SessionQueueBase):
if prepend:
priority = self._get_highest_priority(queue_id) + 1
requested_count = batch.get_session_count()
requested_count = calc_session_count(batch)
values_to_insert = prepare_values_to_insert(
queue_id=queue_id,
batch=batch,

View File

@@ -1,204 +0,0 @@
from copy import deepcopy
from typing import Any, Callable, TypeAlias, get_args
from pydantic import BaseModel, ConfigDict, create_model
from pydantic.fields import FieldInfo
from invokeai.app.services.session_queue.session_queue_common import FieldIdentifier
from invokeai.app.services.shared.graph import Graph
DictOfFieldsMetadata: TypeAlias = dict[str, tuple[type[Any], FieldInfo]]
class ComposedFieldMetadata(BaseModel):
node_id: str
field_name: str
field_type_class_name: str
def dedupe_field_name(field_metadata: DictOfFieldsMetadata, field_name: str) -> str:
"""Given a field name, return a name that is not already in the field metadata.
If the field name is not in the field metadata, return the field name.
If the field name is in the field metadata, generate a new name by appending an underscore and integer to the field name, starting with 2.
"""
if field_name not in field_metadata:
return field_name
i = 2
while True:
new_field_name = f"{field_name}_{i}"
if new_field_name not in field_metadata:
return new_field_name
i += 1
def compose_model_from_fields(
g: Graph,
field_identifiers: list[FieldIdentifier],
composed_model_class_name: str = "ComposedModel",
model_field_overrides: dict[type[Any], tuple[type[Any], FieldInfo]] | None = None,
model_field_filter: Callable[[type[Any]], bool] | None = None,
) -> type[BaseModel]:
"""Given a graph and a list of field identifiers, create a new pydantic model composed of the fields of the nodes in the graph.
The resultant model can be used to validate a JSON payload that contains the fields of the nodes in the graph, or generate an
OpenAPI schema for the model.
Args:
g: The graph containing the nodes whose fields will be composed into the new model.
field_identifiers: A list of FieldIdentifier instances, each representing a field on a node in the graph.
model_name: The name of the composed model.
kind: The kind of model to create. Must be "input" or "output". Defaults to "input".
model_field_overrides: A dictionary mapping type annotations to tuples of (new_type_annotation, new_field_info).
This can be used to override the type annotation and field info of a field in the composed model. For example,
if `ModelIdentifierField` should be replaced by a string, the dictionary would look like this:
```python
{ModelIdentifierField: (str, Field(description="The model id."))}
```
model_field_filter: A function that takes a type annotation and returns True if the field should be included in the composed model.
If None, all fields will be included. For example, to omit `BoardField` fields, the filter would look like this:
```python
def model_field_filter(field_type: type[Any]) -> bool:
return field_type not in {BoardField}
```
Optional fields - or any other complex field types like unions - must be explicitly included in the filter. For example,
to omit `BoardField` _and_ `Optional[BoardField]`:
```python
def model_field_filter(field_type: type[Any]) -> bool:
return field_type not in {BoardField, Optional[BoardField]}
```
Note that the filter is applied to the type annotation of the field, not the field itself.
Example usage:
```python
# Create some nodes.
add_node = AddInvocation()
sub_node = SubtractInvocation()
color_node = ColorInvocation()
# Create a graph with the nodes.
g = Graph(
nodes={
add_node.id: add_node,
sub_node.id: sub_node,
color_node.id: color_node,
}
)
# Select the fields to compose.
fields_to_compose = [
FieldIdentifier(node_id=add_node.id, field_name="a"),
FieldIdentifier(node_id=sub_node.id, field_name="a"), # this will be deduped to "a_2"
FieldIdentifier(node_id=add_node.id, field_name="b"),
FieldIdentifier(node_id=color_node.id, field_name="color"),
]
# Compose the model from the fields.
composed_model = compose_model_from_fields(g, fields_to_compose, model_name="ComposedModel")
# Generate the OpenAPI schema for the model.
json_schema = composed_model.model_json_schema(mode="validation")
```
"""
# Temp storage for the composed fields. Pydantic needs a type annotation and instance of FieldInfo to create a model.
field_metadata: DictOfFieldsMetadata = {}
model_field_overrides = model_field_overrides or {}
# The list of required fields. This is used to ensure the composed model's fields retain their required state.
required: list[str] = []
for field_identifier in field_identifiers:
node_id = field_identifier.node_id
field_name = field_identifier.field_name
# Pull the node instance from the graph so we can introspect it.
node_instance = g.nodes[node_id]
if field_identifier.kind == "input":
# Get the class of the node. This will be a BaseInvocation subclass, e.g. AddInvocation, DenoiseLatentsInvocation, etc.
pydantic_model = type(node_instance)
else:
# Otherwise the the type of the node's output class. This will be a BaseInvocationOutput subclass, e.g. IntegerOutput, ImageOutput, etc.
pydantic_model = type(node_instance).get_output_annotation()
# Get the FieldInfo instance for the field. For example:
# a: int = Field(..., description="The first number to add.")
# ^^^^^ The return value of this Field call is the FieldInfo instance (Field is a function).
og_field_info = pydantic_model.model_fields[field_name]
# Get the type annotation of the field. For example:
# a: int = Field(..., description="The first number to add.")
# ^^^ this is the type annotation
og_field_type = og_field_info.annotation
# Apparently pydantic allows fields without type annotations. We don't support that.
assert og_field_type is not None, (
f"{field_identifier.kind.capitalize()} field {field_name} on node {node_id} has no type annotation."
)
# Now that we have the type annotation, we can apply the filter to see if we should include the field in the composed model.
if model_field_filter and not model_field_filter(og_field_type):
continue
# Ok, we want this type of field. Retrieve any overrides for the field type. This is a dictionary mapping
# type annotations to tuples of (override_type_annotation, override_field_info).
(override_field_type, override_field_info) = model_field_overrides.get(og_field_type, (None, None))
# The override tuple's first element is the new type annotation, if it exists.
composed_field_type = override_field_type if override_field_type is not None else og_field_type
# Create a deep copy of the FieldInfo instance (or override it if it exists) so we can modify it without
# affecting the original. This is important because we are going to modify the FieldInfo instance and
# don't want to affect the original model's schema.
composed_field_info = deepcopy(override_field_info if override_field_info is not None else og_field_info)
json_schema_extra = og_field_info.json_schema_extra if isinstance(og_field_info.json_schema_extra, dict) else {}
# The field's original required state is stored in the json_schema_extra dict. For more information about why,
# see the definition of `InputField` in invokeai/app/invocations/fields.py.
#
# Add the field to the required list if it is required, which we will use when creating the composed model.
if json_schema_extra.get("orig_required", False):
required.append(field_name)
# Invocation fields have some extra metadata, used by the UI to render the field in the frontend. This data is
# included in the OpenAPI schema for each field. For example, we add a "ui_order" field, which the UI uses to
# sort fields when rendering them.
#
# The composed model's OpenAPI schema should not have this information. It should only have a standard OpenAPI
# schema for the field. We need to strip out the UI-specific metadata from the FieldInfo instance before adding
# it to the composed model.
#
# We will replace this metadata with some custom metadata:
# - node_id: The id of the node that this field belongs to.
# - field_name: The name of the field on the node.
# - original_data_type: The original data type of the field.
field_type_class = get_args(og_field_type)[0] if hasattr(og_field_type, "__args__") else og_field_type
field_type_class_name = field_type_class.__name__
composed_field_metadata = ComposedFieldMetadata(
node_id=node_id,
field_name=field_name,
field_type_class_name=field_type_class_name,
)
composed_field_info.json_schema_extra = {
"composed_field_extra": composed_field_metadata.model_dump(),
}
# Override the name, title and description if overrides are provided. Dedupe the field name if necessary.
final_field_name = dedupe_field_name(field_metadata, field_name)
# Store the field metadata.
field_metadata.update({final_field_name: (composed_field_type, composed_field_info)})
# Splat in the composed fields to create the new model. There are type errors here because create_model's kwargs are not typed,
# and for some reason pydantic's ConfigDict doesn't like lists in `json_schema_extra`. Anyways, the inputs here are correct.
return create_model(
composed_model_class_name,
**field_metadata,
__config__=ConfigDict(json_schema_extra={"required": required}),
)

View File

@@ -47,7 +47,6 @@ class WorkflowRecordsStorageBase(ABC):
query: Optional[str],
tags: Optional[list[str]],
has_been_opened: Optional[bool],
is_published: Optional[bool],
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
@@ -57,7 +56,6 @@ class WorkflowRecordsStorageBase(ABC):
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided categories."""
pass
@@ -68,7 +66,6 @@ class WorkflowRecordsStorageBase(ABC):
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided tags."""
pass

View File

@@ -67,7 +67,6 @@ class WorkflowWithoutID(BaseModel):
# This is typed as optional to prevent errors when pulling workflows from the DB. The frontend adds a default form if
# it is None.
form: dict[str, JsonValue] | None = Field(default=None, description="The form of the workflow.")
is_published: bool | None = Field(default=None, description="Whether the workflow is published or not.")
model_config = ConfigDict(extra="ignore")
@@ -102,7 +101,6 @@ class WorkflowRecordDTOBase(BaseModel):
opened_at: Optional[Union[datetime.datetime, str]] = Field(
default=None, description="The opened timestamp of the workflow."
)
is_published: bool | None = Field(default=None, description="Whether the workflow is published or not.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):

View File

@@ -119,7 +119,6 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
query: Optional[str] = None,
tags: Optional[list[str]] = None,
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
# sanitize!
assert order_by in WorkflowRecordOrderBy
@@ -242,7 +241,6 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
if not tags:
return {}
@@ -294,7 +292,6 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
is_published: Optional[bool] = None,
) -> dict[str, int]:
cursor = self._conn.cursor()
result: dict[str, int] = {}

View File

@@ -69,6 +69,9 @@ class SD3ConditioningInfo:
@dataclass
class ConditioningFieldData:
# If you change this class, adding more types, you _must_ update the instantiation of ObjectSerializerDisk in
# invokeai/app/api/dependencies.py, adding the types to the list of safe globals. If you do not, torch will be
# unable to deserialize the object and will raise an error.
conditionings: (
List[BasicConditioningInfo]
| List[SDXLConditioningInfo]

View File

@@ -62,7 +62,7 @@
"@nanostores/react": "^0.7.3",
"@reduxjs/toolkit": "2.6.1",
"@roarr/browser-log-writer": "^1.3.0",
"@xyflow/react": "^12.5.1",
"@xyflow/react": "^12.5.3",
"async-mutex": "^0.5.0",
"chakra-react-select": "^4.9.2",
"cmdk": "^1.0.0",
@@ -150,7 +150,7 @@
"prettier": "^3.3.3",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "^8.3.4",
"tsafe": "^1.8.5",
"tsafe": "^1.7.5",
"type-fest": "^4.26.1",
"typescript": "^5.6.2",
"vite": "^6.1.0",
@@ -162,5 +162,6 @@
},
"engines": {
"pnpm": "8"
}
},
"packageManager": "pnpm@8.15.9+sha512.499434c9d8fdd1a2794ebf4552b3b25c0a633abcee5bb15e7b5de90f32f47b513aca98cd5cfd001c31f0db454bc3804edccd578501e4ca293a6816166bbd9f81"
}

View File

@@ -36,8 +36,8 @@ dependencies:
specifier: ^1.3.0
version: 1.3.0
'@xyflow/react':
specifier: ^12.5.1
version: 12.5.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
specifier: ^12.5.3
version: 12.5.3(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1)
async-mutex:
specifier: ^0.5.0
version: 0.5.0
@@ -284,8 +284,8 @@ devDependencies:
specifier: ^8.3.4
version: 8.3.4
tsafe:
specifier: ^1.8.5
version: 1.8.5
specifier: ^1.7.5
version: 1.7.5
type-fest:
specifier: ^4.26.1
version: 4.26.1
@@ -3951,8 +3951,8 @@ packages:
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
dev: false
/@xyflow/react@12.5.1(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-jMKQVqGwCz0x6pUyvxTIuCMbyehfua7CfEEWDj29zQSHigQpCy0/5d8aOmZrqK4cwur/pVHLQomT6Rm10gXfHg==}
/@xyflow/react@12.5.3(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-saovy/aQRoW8qQoIqMFUtmC3F6oEV7n6+J1pVbhSG45NI/hOFvK0qozsIPKqX5Va6lGQnkl/o53NHLja3NiweQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
@@ -8791,8 +8791,8 @@ packages:
resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==}
dev: false
/tsafe@1.8.5:
resolution: {integrity: sha512-LFWTWQrW6rwSY+IBNFl2ridGfUzVsPwrZ26T4KUJww/py8rzaQ/SY+MIz6YROozpUCaRcuISqagmlwub9YT9kw==}
/tsafe@1.7.5:
resolution: {integrity: sha512-tbNyyBSbwfbilFfiuXkSOj82a6++ovgANwcoqBAcO9/REPoZMEQoE8kWPeO0dy5A2D/2Lajr8Ohue5T0ifIvLQ==}
dev: true
/tsconfck@3.1.5(typescript@5.6.2):
@@ -9123,8 +9123,8 @@ packages:
react: 18.3.1
dev: false
/use-sync-external-store@1.4.0(react@18.3.1):
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
/use-sync-external-store@1.5.0(react@18.3.1):
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
@@ -9592,5 +9592,5 @@ packages:
dependencies:
'@types/react': 18.3.11
react: 18.3.1
use-sync-external-store: 1.4.0(react@18.3.1)
use-sync-external-store: 1.5.0(react@18.3.1)
dev: false

View File

@@ -116,7 +116,10 @@
"combinatorial": "Kombinatorisch",
"saveChanges": "Änderungen speichern",
"error_withCount_one": "{{count}} Fehler",
"error_withCount_other": "{{count}} Fehler"
"error_withCount_other": "{{count}} Fehler",
"value": "Wert",
"label": "Label",
"systemInformation": "Systeminformationen"
},
"gallery": {
"galleryImageSize": "Bildgröße",
@@ -695,7 +698,10 @@
"guidance": "Führung",
"coherenceMode": "Modus",
"recallMetadata": "Metadaten abrufen",
"gaussianBlur": "Gaußsche Unschärfe"
"gaussianBlur": "Gaußsche Unschärfe",
"sendToUpscale": "An Hochskalieren senden",
"useCpuNoise": "CPU-Rauschen verwenden",
"sendToCanvas": "An Leinwand senden"
},
"settings": {
"displayInProgress": "Zwischenbilder anzeigen",
@@ -1328,7 +1334,8 @@
"loadWorkflowDesc2": "Ihr aktueller Arbeitsablauf enthält nicht gespeicherte Änderungen.",
"loadingTemplates": "Lade {{name}}",
"missingSourceOrTargetHandle": "Fehlender Quell- oder Zielgriff",
"missingSourceOrTargetNode": "Fehlender Quell- oder Zielknoten"
"missingSourceOrTargetNode": "Fehlender Quell- oder Zielknoten",
"showEdgeLabelsHelp": "Beschriftungen an Kanten anzeigen, um die verknüpften Knoten zu kennzeichnen"
},
"hrf": {
"enableHrf": "Korrektur für hohe Auflösungen",

View File

@@ -1783,37 +1783,7 @@
"textPlaceholder": "Empty Text",
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release.",
"minimum": "Minimum",
"maximum": "Maximum",
"publish": "Publish",
"published": "Published",
"unpublish": "Unpublish",
"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.",
"selectOutputNode": "Select Output Node",
"changeOutputNode": "Change Output Node",
"publishedWorkflowOutputs": "Outputs",
"publishedWorkflowInputs": "Inputs",
"unpublishableInputs": "These unpublishable inputs will be omitted",
"noPublishableInputs": "No publishable inputs",
"noOutputNodeSelected": "No output node selected",
"cannotPublish": "Cannot publish workflow",
"publishWarnings": "Warnings",
"errorWorkflowHasUnsavedChanges": "Workflow has unsaved changes",
"errorWorkflowHasBatchOrGeneratorNodes": "Workflow has batch and/or generator nodes",
"errorWorkflowHasInvalidGraph": "Workflow graph invalid (hover Invoke button for details)",
"errorWorkflowHasNoOutputNode": "No output node selected",
"warningWorkflowHasNoPublishableInputFields": "No publishable input fields selected - published workflow will run with only default values",
"warningWorkflowHasUnpublishableInputFields": "Workflow has some unpublishable inputs - these will be omitted from the published workflow",
"publishFailed": "Publish failed",
"publishFailedDesc": "There was a problem publishing the workflow. Please try again.",
"publishSuccess": "Your workflow is being published",
"publishSuccessDesc": "Check your <LinkComponent>Project Dashboard</LinkComponent> to see its progress.",
"publishInProgress": "Publishing in progress",
"publishedWorkflowIsLocked": "Published workflow is locked",
"publishingValidationRun": "Publishing Validation Run",
"publishingValidationRunInProgress": "Publishing validation run in progress.",
"publishedWorkflowsLocked": "Published workflows are locked and cannot be edited or run. Either unpublish the workflow or save a copy to edit or run this workflow."
"maximum": "Maximum"
}
},
"controlLayers": {

View File

@@ -115,7 +115,8 @@
"error_withCount_many": "{{count}} errori",
"error_withCount_other": "{{count}} errori",
"value": "Valore",
"label": "Etichetta"
"label": "Etichetta",
"systemInformation": "Informazioni di sistema"
},
"gallery": {
"galleryImageSize": "Dimensione dell'immagine",
@@ -715,7 +716,8 @@
"collectionNumberLTMin": "{{value}} < {{minimum}} (incr min)",
"collectionNumberGTExclusiveMax": "{{value}} >= {{exclusiveMaximum}} (excl max)",
"collectionNumberLTExclusiveMin": "{{value}} <= {{exclusiveMinimum}} (excl min)",
"collectionEmpty": "raccolta vuota"
"collectionEmpty": "raccolta vuota",
"batchNodeCollectionSizeMismatchNoGroupId": "Dimensione della raccolta di gruppo nel Lotto non corrisponde"
},
"useCpuNoise": "Usa la CPU per generare rumore",
"iterations": "Iterazioni",
@@ -2365,8 +2367,9 @@
"watchRecentReleaseVideos": "Guarda i video su questa versione",
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
"items": [
"Flussi di lavoro: nuova e migliorata libreria dei flussi di lavoro.",
"FLUX: supporto per FLUX Redux e FLUX Fill in Flussi di lavoro e Tela."
"Flussi di lavoro: supporto per menu a discesa di stringhe personalizzate nel Generatore di Flussi di lavoro.",
"FLUX: supporto per FLUX Fill in Flussi di lavoro e Tela.",
"LLaVA OneVision VLLM: supporto beta nei flussi di lavoro."
]
},
"system": {

View File

@@ -237,7 +237,10 @@
"row": "Hàng",
"board": "Bảng",
"saveChanges": "Lưu Thay Đổi",
"error_withCount_other": "{{count}} lỗi"
"error_withCount_other": "{{count}} lỗi",
"value": "Giá Trị",
"label": "Nhãn Tên",
"systemInformation": "Thông Tin Hệ Thống"
},
"prompt": {
"addPromptTrigger": "Thêm Prompt Trigger",
@@ -2300,7 +2303,10 @@
"minimum": "Tối Thiểu",
"maximum": "Tối Đa",
"containerRowLayout": "Hộp Chứa (bố cục hàng)",
"containerColumnLayout": "Hộp Chứa (bố cục cột)"
"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"
},
"yourWorkflows": "Workflow Của Bạn",
"browseWorkflows": "Khám Phá Workflow",
@@ -2316,7 +2322,8 @@
"view": "Xem",
"deselectAll": "Huỷ Chọn Tất Cả",
"noRecentWorkflows": "Không Có Workflows Gần Đây",
"recommended": "Có Thể Bạn Sẽ Cần"
"recommended": "Có Thể Bạn Sẽ Cần",
"emptyStringPlaceholder": "<xâu ký tự trống>"
},
"upscaling": {
"missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale",
@@ -2352,8 +2359,9 @@
"watchRecentReleaseVideos": "Xem Video Phát Hành Mới Nhất",
"watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng",
"items": [
"Workflow: Thư Viện Workflow mới và đã được cải tiến.",
"FLUX: Hỗ trợ FLUX Redux & FLUX Fill trong Workflow và Canvas."
"Workflow: Hỗ trợ xâu ký tự thả xuống tùy chỉnh trong Trình Tạo Vùng Nhập.",
"FLUX: Hỗ trợ FLUX Fill trong Workflow và Canvas.",
"LLaVA OneVision VLLM: Hỗ trợ phiên bản Beta trong Workflow."
]
},
"upsell": {

View File

@@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import type { TabName } from 'features/ui/store/uiTypes';
export const enqueueRequested = createAction<{
tabName: TabName;
prepend: boolean;
}>('app/enqueueRequested');

View File

@@ -10,6 +10,7 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged';
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
@@ -62,6 +63,7 @@ addGalleryImageClickedListener(startAppListening);
addGalleryOffsetChangedListener(startAppListening);
// User Invoked
addEnqueueRequestedNodes(startAppListening);
addEnqueueRequestedLinear(startAppListening);
addEnqueueRequestedUpscale(startAppListening);
addAnyEnqueuedListener(startAppListening);

View File

@@ -5,7 +5,7 @@ import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAd
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import type { EnqueueBatchArg, ImageDTO } from 'services/api/types';
import type { BatchConfig, ImageDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
const log = logger('queue');
@@ -19,7 +19,7 @@ export const addAdHocPostProcessingRequestedListener = (startAppListening: AppSt
const { imageDTO } = action.payload;
const state = getState();
const enqueueBatchArg: EnqueueBatchArg = {
const enqueueBatchArg: BatchConfig = {
prepend: true,
batch: {
graph: await buildAdHocPostProcessingGraph({

View File

@@ -1,5 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError';
import { withResult, withResultAsync } from 'common/util/result';
@@ -17,11 +17,10 @@ import { assert, AssertionError } from 'tsafe';
const log = logger('generation');
export const enqueueRequestedCanvas = createAction<{ prepend: boolean }>('app/enqueueRequestedCanvas');
export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: enqueueRequestedCanvas,
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
enqueueRequested.match(action) && action.payload.tabName === 'canvas',
effect: async (action, { getState, dispatch }) => {
log.debug('Enqueue requested');
const state = getState();

View File

@@ -1,25 +1,25 @@
import { useAppStore } from 'app/store/nanostores/store';
import {
$outputNodeId,
getPublishInputs,
selectFieldIdentifiersWithInvocationTypes,
} from 'features/nodes/components/sidePanel/workflow/publish';
import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { parseify } from 'common/util/serialize';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
import { groupBy } from 'lodash-es';
import { useCallback } from 'react';
import { serializeError } from 'serialize-error';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import type { Batch, EnqueueBatchArg } from 'services/api/types';
import { assert } from 'tsafe';
import type { Batch, BatchConfig } from 'services/api/types';
export const useEnqueueWorkflows = () => {
const { getState, dispatch } = useAppStore();
const enqueue = useCallback(
async (prepend: boolean, isApiValidationRun: boolean) => {
const log = logger('generation');
export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) => {
startAppListening({
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
enqueueRequested.match(action) && action.payload.tabName === 'workflows',
effect: async (action, { getState, dispatch }) => {
const state = getState();
const nodesState = selectNodesSlice(state);
const workflow = state.workflow;
@@ -91,7 +91,7 @@ export const useEnqueueWorkflows = () => {
}
}
const batchConfig: EnqueueBatchArg = {
const batchConfig: BatchConfig = {
batch: {
graph,
workflow: builtWorkflow,
@@ -100,53 +100,18 @@ export const useEnqueueWorkflows = () => {
destination: 'gallery',
data,
},
prepend,
prepend: action.payload.prepend,
};
if (isApiValidationRun) {
// Derive the input fields from the builder's selected node field elements
const fieldIdentifiers = selectFieldIdentifiersWithInvocationTypes(state);
const inputs = getPublishInputs(fieldIdentifiers, templates);
const api_input_fields = inputs.publishable.map(({ nodeId, fieldName }) => {
return {
kind: 'input',
node_id: nodeId,
field_name: fieldName,
} as const;
});
// Derive the output fields from the builder's selected output node
const outputNodeId = $outputNodeId.get();
assert(outputNodeId !== null, 'Output node not selected');
const outputNodeType = selectNodeData(selectNodesSlice(state), outputNodeId).type;
const outputNodeTemplate = templates[outputNodeType];
assert(outputNodeTemplate, `Template for node type ${outputNodeType} not found`);
const outputFieldNames = Object.keys(outputNodeTemplate.outputs);
const api_output_fields = outputFieldNames.map((fieldName) => {
return {
kind: 'output',
node_id: outputNodeId,
field_name: fieldName,
} as const;
});
batchConfig.is_api_validation_run = true;
batchConfig.api_input_fields = api_input_fields;
batchConfig.api_output_fields = api_output_fields;
// If the batch is an API validation run, we only want to run it once
batchConfig.batch.runs = 1;
const req = dispatch(queueApi.endpoints.enqueueBatch.initiate(batchConfig, enqueueMutationFixedCacheKeyOptions));
try {
await req.unwrap();
log.debug(parseify({ batchConfig }), 'Enqueued batch');
} catch (error) {
log.error({ error: serializeError(error) }, 'Failed to enqueue batch');
} finally {
req.reset();
}
const req = dispatch(
queueApi.endpoints.enqueueBatch.initiate(batchConfig, { ...enqueueMutationFixedCacheKeyOptions, track: false })
);
const enqueueResult = await req.unwrap();
return { batchConfig, enqueueResult };
},
[dispatch, getState]
);
return enqueue;
});
};

View File

@@ -1,5 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { parseify } from 'common/util/serialize';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
@@ -9,11 +9,10 @@ import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endp
const log = logger('generation');
export const enqueueRequestedUpscaling = createAction<{ prepend: boolean }>('app/enqueueRequestedUpscaling');
export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: enqueueRequestedUpscaling,
predicate: (action): action is ReturnType<typeof enqueueRequested> =>
enqueueRequested.match(action) && action.payload.tabName === 'upscaling',
effect: async (action, { getState, dispatch }) => {
const state = getState();
const { prepend } = action.payload;

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';
@@ -176,7 +175,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

@@ -28,8 +28,7 @@ export type AppFeature =
| 'starterModels'
| 'hfToken'
| 'retryQueueItem'
| 'cancelAndClearAll'
| 'deployWorkflow';
| 'cancelAndClearAll';
/**
* A disable-able Stable Diffusion feature
*/

View File

@@ -14,7 +14,7 @@ export const useGlobalHotkeys = () => {
useRegisteredHotkeys({
id: 'invoke',
category: 'app',
callback: queue.enqueueBack,
callback: queue.queueBack,
options: {
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,
@@ -26,7 +26,7 @@ export const useGlobalHotkeys = () => {
useRegisteredHotkeys({
id: 'invokeFront',
category: 'app',
callback: queue.enqueueFront,
callback: queue.queueFront,
options: {
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,

View File

@@ -54,7 +54,7 @@ import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { getImageDTO } from 'services/api/endpoints/images';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
import type { EnqueueBatchArg, ImageDTO, S } from 'services/api/types';
import type { BatchConfig, ImageDTO, S } from 'services/api/types';
import { QueueError } from 'services/events/errors';
import type { Param0 } from 'tsafe';
import { assert } from 'tsafe';
@@ -291,7 +291,7 @@ export class CanvasStateApiModule extends CanvasModuleBase {
*/
const origin = getPrefixedId(graph.id);
const batch: EnqueueBatchArg = {
const batch: BatchConfig = {
prepend,
batch: {
graph: graph.getGraph(),

View File

@@ -49,7 +49,11 @@ export const useGalleryHotkeys = () => {
useRegisteredHotkeys({
id: 'galleryNavLeft',
category: 'gallery',
callback: () => {
callback: (e) => {
// Skip the hotkey if the user is focused on a tab element - the arrow keys are used to navigate between tabs.
if (e.target instanceof HTMLElement && e.target.getAttribute('role') === 'tab') {
return;
}
if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) {
goPrev('arrow');
return;
@@ -71,7 +75,11 @@ export const useGalleryHotkeys = () => {
useRegisteredHotkeys({
id: 'galleryNavRight',
category: 'gallery',
callback: () => {
callback: (e) => {
// Skip the hotkey if the user is focused on a tab element - the arrow keys are used to navigate between tabs.
if (e.target instanceof HTMLElement && e.target.getAttribute('role') === 'tab') {
return;
}
if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) {
goNext('arrow');
return;

View File

@@ -2,9 +2,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk';
import { TopCenterPanel } from 'features/nodes/components/flow/panels/TopPanel/TopCenterPanel';
import { TopLeftPanel } from 'features/nodes/components/flow/panels/TopPanel/TopLeftPanel';
import { TopRightPanel } from 'features/nodes/components/flow/panels/TopPanel/TopRightPanel';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -34,9 +32,7 @@ const NodeEditor = () => {
<>
<Flow />
<AddNodeCmdk />
<TopLeftPanel />
<TopCenterPanel />
<TopRightPanel />
<TopPanel />
<BottomLeftPanel />
<MinimapPanel />
</>

View File

@@ -18,7 +18,6 @@ import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import {
$addNodeCmdk,
$cursorPos,
@@ -147,7 +146,6 @@ export const AddNodeCmdk = memo(() => {
const [searchTerm, setSearchTerm] = useState('');
const addNode = useAddNode();
const tab = useAppSelector(selectActiveTab);
const isLocked = useIsWorkflowEditorLocked();
// Filtering the list is expensive - debounce the search term to avoid stutters
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const isOpen = useStore($addNodeCmdk);
@@ -162,8 +160,8 @@ export const AddNodeCmdk = memo(() => {
id: 'addNode',
category: 'workflows',
callback: open,
options: { enabled: tab === 'workflows' && !isLocked, preventDefault: true },
dependencies: [open, tab, isLocked],
options: { enabled: tab === 'workflows', preventDefault: true },
dependencies: [open, tab],
});
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {

View File

@@ -4,7 +4,6 @@ import type {
EdgeChange,
HandleType,
NodeChange,
NodeMouseHandler,
OnEdgesChange,
OnInit,
OnMoveEnd,
@@ -17,10 +16,8 @@ import type {
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
import { $isSelectingOutputNode, $outputNodeId } from 'features/nodes/components/sidePanel/workflow/publish';
import { useConnection } from 'features/nodes/hooks/useConnection';
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import {
@@ -47,7 +44,7 @@ import {
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import { type AnyEdge, type AnyNode, isInvocationNode } from 'features/nodes/types/invocation';
import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import type { CSSProperties, MouseEvent } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -95,8 +92,6 @@ export const Flow = memo(() => {
const updateNodeInternals = useUpdateNodeInternals();
const store = useAppStore();
const isWorkflowsFocused = useIsRegionFocused('workflows');
const isLocked = useIsWorkflowEditorLocked();
useFocusRegion('workflows', flowWrapper);
useSyncExecutionState();
@@ -220,7 +215,7 @@ export const Flow = memo(() => {
id: 'copySelection',
category: 'workflows',
callback: copySelection,
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
options: { preventDefault: true },
dependencies: [copySelection],
});
@@ -249,24 +244,24 @@ export const Flow = memo(() => {
id: 'selectAll',
category: 'workflows',
callback: selectAll,
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [selectAll, isWorkflowsFocused, isLocked],
options: { enabled: isWorkflowsFocused, preventDefault: true },
dependencies: [selectAll, isWorkflowsFocused],
});
useRegisteredHotkeys({
id: 'pasteSelection',
category: 'workflows',
callback: pasteSelection,
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [pasteSelection, isLocked, isWorkflowsFocused],
options: { enabled: isWorkflowsFocused, preventDefault: true },
dependencies: [pasteSelection],
});
useRegisteredHotkeys({
id: 'pasteSelectionWithEdges',
category: 'workflows',
callback: pasteSelectionWithEdges,
options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [pasteSelectionWithEdges, isLocked, isWorkflowsFocused],
options: { enabled: isWorkflowsFocused, preventDefault: true },
dependencies: [pasteSelectionWithEdges],
});
useRegisteredHotkeys({
@@ -275,8 +270,8 @@ export const Flow = memo(() => {
callback: () => {
dispatch(undo());
},
options: { enabled: isWorkflowsFocused && !isLocked && mayUndo, preventDefault: true },
dependencies: [mayUndo, isLocked, isWorkflowsFocused],
options: { enabled: isWorkflowsFocused && mayUndo, preventDefault: true },
dependencies: [mayUndo],
});
useRegisteredHotkeys({
@@ -285,8 +280,8 @@ export const Flow = memo(() => {
callback: () => {
dispatch(redo());
},
options: { enabled: isWorkflowsFocused && !isLocked && mayRedo, preventDefault: true },
dependencies: [mayRedo, isLocked, isWorkflowsFocused],
options: { enabled: isWorkflowsFocused && mayRedo, preventDefault: true },
dependencies: [mayRedo],
});
const onEscapeHotkey = useCallback(() => {
@@ -323,22 +318,10 @@ export const Flow = memo(() => {
id: 'deleteSelection',
category: 'workflows',
callback: deleteSelection,
options: { preventDefault: true, enabled: isWorkflowsFocused && !isLocked },
dependencies: [deleteSelection, isWorkflowsFocused, isLocked],
options: { preventDefault: true, enabled: isWorkflowsFocused },
dependencies: [deleteSelection, isWorkflowsFocused],
});
const onNodeClick = useCallback<NodeMouseHandler<AnyNode>>((e, node) => {
if (!$isSelectingOutputNode.get()) {
return;
}
if (!isInvocationNode(node)) {
return;
}
const { id } = node.data;
$outputNodeId.set(id);
$isSelectingOutputNode.set(false);
}, []);
return (
<ReactFlow<AnyNode, AnyEdge>
id="workflow-editor"
@@ -349,7 +332,6 @@ export const Flow = memo(() => {
nodes={nodes}
edges={edges}
onInit={onInit}
onNodeClick={onNodeClick}
onMouseMove={onMouseMove}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
@@ -362,12 +344,6 @@ export const Flow = memo(() => {
onMoveEnd={handleMoveEnd}
connectionLineComponent={CustomConnectionLine}
isValidConnection={isValidConnection}
edgesFocusable={!isLocked}
edgesReconnectable={!isLocked}
nodesDraggable={!isLocked}
nodesConnectable={!isLocked}
nodesFocusable={!isLocked}
elementsSelectable={!isLocked}
minZoom={0.1}
snapToGrid={shouldSnapToGrid}
snapGrid={snapGrid}

View File

@@ -1,5 +1,5 @@
import { Handle, Position } from '@xyflow/react';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { map } from 'lodash-es';
import type { CSSProperties } from 'react';
import { memo } from 'react';
@@ -19,7 +19,7 @@ const collapsedHandleStyles: CSSProperties = {
};
const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
if (!template) {
return null;

View File

@@ -1,9 +1,9 @@
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { compare } from 'compare-versions';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,9 +27,9 @@ InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon';
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
const notes = useInvocationNodeNotes(nodeId);
const label = useNodeUserTitleSafe(nodeId);
const label = useNodeLabel(nodeId);
const version = useNodeVersion(nodeId);
const nodeTemplate = useNodeTemplateOrThrow(nodeId);
const nodeTemplate = useNodeTemplate(nodeId);
const { t } = useTranslation();
const title = useMemo(() => {

View File

@@ -8,7 +8,7 @@ import {
Textarea,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
@@ -48,7 +48,7 @@ InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
const Content = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const description = useInputFieldUserDescriptionSafe(nodeId, fieldName);
const description = useInputFieldDescriptionSafe(nodeId, fieldName);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));

View File

@@ -7,7 +7,7 @@ import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/componen
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { memo, useRef } from 'react';
@@ -100,7 +100,7 @@ const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemp
const draggableRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const isDragging = useNodeFieldDnd(nodeId, fieldName, fieldTemplate, draggableRef, dragHandleRef);
const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef);
return (
<InputFieldWrapper>

View File

@@ -7,8 +7,7 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
@@ -106,16 +105,9 @@ type HandleCommonProps = {
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
const isLocked = useIsWorkflowEditorLocked();
return (
<Tooltip label={fieldTypeName} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle
type="target"
id={fieldTemplate.name}
position={Position.Left}
style={handleStyles}
isConnectable={!isLocked}
>
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
@@ -138,7 +130,6 @@ const ConnectionInProgressHandle = memo(
const { t } = useTranslation();
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');
const isLocked = useIsWorkflowEditorLocked();
const tooltip = useMemo(() => {
if (connectionError !== null) {
@@ -149,13 +140,7 @@ const ConnectionInProgressHandle = memo(
return (
<Tooltip label={tooltip} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle
type="target"
id={fieldTemplate.name}
position={Position.Left}
style={handleStyles}
isConnectable={!isLocked}
>
<Handle type="target" id={fieldTemplate.name} position={Position.Left} style={handleStyles}>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}

View File

@@ -17,7 +17,7 @@ import { StringFieldDropdown } from 'features/nodes/components/flow/nodes/Invoca
import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import {
isBoardFieldInputInstance,
isBoardFieldInputTemplate,

View File

@@ -9,8 +9,8 @@ import {
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import type { MouseEvent } from 'react';
@@ -43,8 +43,8 @@ interface Props {
export const InputFieldTitle = memo((props: Props) => {
const { nodeId, fieldName, isInvalid, isDragging } = props;
const inputRef = useRef<HTMLInputElement>(null);
const label = useInputFieldUserTitleSafe(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const label = useInputFieldLabelSafe(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
const { t } = useTranslation();
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');

View File

@@ -1,7 +1,7 @@
import { Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { useInputFieldErrors } from 'features/nodes/hooks/useInputFieldErrors';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react';

View File

@@ -7,7 +7,6 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
@@ -106,17 +105,9 @@ type HandleCommonProps = {
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
const isLocked = useIsWorkflowEditorLocked();
return (
<Tooltip label={fieldTypeName} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle
type="source"
id={fieldTemplate.name}
position={Position.Right}
style={handleStyles}
isConnectable={!isLocked}
>
<Handle type="source" id={fieldTemplate.name} position={Position.Right} style={handleStyles}>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
@@ -139,7 +130,6 @@ const ConnectionInProgressHandle = memo(
const { t } = useTranslation();
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const connectionErrorTKey = useConnectionErrorTKey(nodeId, fieldName, 'target');
const isLocked = useIsWorkflowEditorLocked();
const tooltip = useMemo(() => {
if (connectionErrorTKey !== null) {
@@ -150,13 +140,7 @@ const ConnectionInProgressHandle = memo(
return (
<Tooltip label={tooltip} placement="start" openDelay={HANDLE_TOOLTIP_OPEN_DELAY}>
<Handle
type="source"
id={fieldTemplate.name}
position={Position.Right}
style={handleStyles}
isConnectable={!isLocked}
>
<Handle type="source" id={fieldTemplate.name} position={Position.Right} style={handleStyles}>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}

View File

@@ -3,8 +3,8 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { useBatchGroupColorToken } from 'features/nodes/hooks/useBatchGroupColorToken';
import { useBatchGroupId } from 'features/nodes/hooks/useBatchGroupId';
import { useNodeTemplateTitleSafe } from 'features/nodes/hooks/useNodeTemplateTitleSafe';
import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -17,10 +17,10 @@ type Props = {
const NodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const label = useNodeUserTitleSafe(nodeId);
const label = useNodeLabel(nodeId);
const batchGroupId = useBatchGroupId(nodeId);
const batchGroupColorToken = useBatchGroupColorToken(batchGroupId);
const templateTitle = useNodeTemplateTitleSafe(nodeId);
const templateTitle = useNodeTemplateTitle(nodeId);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);

View File

@@ -1,7 +1,6 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
@@ -63,12 +62,6 @@ const containerSx: SystemStyleObject = {
display: 'block',
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
},
'&[data-is-editor-locked="true"]': {
'& *': {
cursor: 'not-allowed',
pointerEvents: 'none',
},
},
};
const shadowsSx: SystemStyleObject = {
@@ -105,8 +98,7 @@ const NodeWrapper = (props: NodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const mouseOverNode = useMouseOverNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
const zoomToNode = useZoomToNode(nodeId);
const isLocked = useIsWorkflowEditorLocked();
const zoomToNode = useZoomToNode();
const executionState = useNodeExecutionState(nodeId);
const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS;
@@ -134,9 +126,9 @@ const NodeWrapper = (props: NodeWrapperProps) => {
// This target is marked as not fitting the view on double click
return;
}
zoomToNode();
zoomToNode(nodeId);
},
[zoomToNode]
[nodeId, zoomToNode]
);
return (
@@ -149,7 +141,6 @@ const NodeWrapper = (props: NodeWrapperProps) => {
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-editor-locked={isLocked}
data-is-selected={selected}
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
>

View File

@@ -1,15 +0,0 @@
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 { memo } from 'react';
export const TopCenterPanel = memo(() => {
const name = useAppSelector(selectWorkflowName);
return (
<Flex gap={2} top={2} left="50%" transform="translateX(-50%)" position="absolute" pointerEvents="none">
{!!name.length && <WorkflowName />}
</Flex>
);
});
TopCenterPanel.displayName = 'TopCenterPanel';

View File

@@ -1,54 +0,0 @@
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, useIsValidationRunInProgress } 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 isValidationRunInProgress = useIsValidationRunInProgress();
const { t } = useTranslation();
return (
<Flex gap={2} top={2} left={2} position="absolute" alignItems="flex-start" pointerEvents="none">
{!isLocked && (
<Flex gap="2">
<AddNodeButton />
<UpdateNodesButton />
</Flex>
)}
{isLocked && (
<Alert status="info" borderRadius="base" fontSize="sm" shadow="md" w="fit-content">
<AlertIcon />
<Box>
<AlertTitle>{t('workflows.builder.workflowLocked')}</AlertTitle>
{isValidationRunInProgress && (
<AlertDescription whiteSpace="pre-wrap">
{t('workflows.builder.publishingValidationRunInProgress')}
</AlertDescription>
)}
{isInPublishFlow && !isValidationRunInProgress && (
<AlertDescription whiteSpace="pre-wrap">
{t('workflows.builder.workflowLockedDuringPublishing')}
</AlertDescription>
)}
{isPublished && (
<AlertDescription whiteSpace="pre-wrap">
{t('workflows.builder.workflowLockedPublished')}
</AlertDescription>
)}
</Box>
</Alert>
)}
</Flex>
);
});
TopLeftPanel.displayName = 'TopLeftPanel';

View File

@@ -0,0 +1,40 @@
import { Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixFill } from 'react-icons/pi';
const TopCenterPanel = () => {
const name = useAppSelector(selectWorkflowName);
const modal = useWorkflowEditorSettingsModal();
const { t } = useTranslation();
return (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex gap="2">
<AddNodeButton />
<UpdateNodesButton />
</Flex>
<Spacer />
{!!name.length && <WorkflowName />}
<Spacer />
<ClearFlowButton />
<SaveWorkflowButton />
<IconButton
pointerEvents="auto"
aria-label={t('workflows.workflowEditorMenu')}
icon={<PiGearSixFill />}
onClick={modal.setTrue}
/>
</Flex>
);
};
export default memo(TopCenterPanel);

View File

@@ -1,34 +0,0 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixFill } from 'react-icons/pi';
export const TopRightPanel = memo(() => {
const modal = useWorkflowEditorSettingsModal();
const isLocked = useIsWorkflowEditorLocked();
const { t } = useTranslation();
if (isLocked) {
return null;
}
return (
<Flex gap={2} top={2} right={2} position="absolute" alignItems="flex-end" pointerEvents="none">
<ClearFlowButton />
<SaveWorkflowButton />
<IconButton
pointerEvents="auto"
aria-label={t('workflows.workflowEditorMenu')}
icon={<PiGearSixFill />}
onClick={modal.setTrue}
/>
</Flex>
);
});
TopRightPanel.displayName = 'TopRightPanel';

View File

@@ -1,4 +1,5 @@
import { Box } from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef } from 'react';
@@ -22,21 +23,23 @@ export const EditModeLeftPanelContent = memo(() => {
return (
<Box position="relative" w="full" h="full">
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowFieldsLinearViewPanel />
</Panel>
<HorizontalResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
<ScrollableContent>
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
autoSaveId="workflow-panel-group"
direction="vertical"
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowFieldsLinearViewPanel />
</Panel>
<HorizontalResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
</ScrollableContent>
</Box>
);
});

View File

@@ -1,25 +0,0 @@
import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiLockOpenBold } from 'react-icons/pi';
export const PublishedWorkflowPanelContent = memo(() => {
const { t } = useTranslation();
const saveAs = useSaveOrSaveAsWorkflow();
return (
<Flex flexDir="column" w="full" h="full" gap={2} alignItems="center">
<Heading size="md" pt={32}>
{t('workflows.builder.workflowLocked')}
</Heading>
<Text fontSize="md">{t('workflows.builder.publishedWorkflowsLocked')}</Text>
<Button size="md" onClick={saveAs} variant="ghost" leftIcon={<PiCopyBold />}>
{t('common.saveAs')}
</Button>
<Button size="md" onClick={undefined} variant="ghost" leftIcon={<PiLockOpenBold />}>
{t('workflows.builder.unpublish')}
</Button>
</Flex>
);
});
PublishedWorkflowPanelContent.displayName = 'PublishedWorkflowPanelContent';

View File

@@ -2,7 +2,7 @@ import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
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/workflowSlice';
import { WorkflowLibraryMenu } from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
@@ -10,13 +10,12 @@ import SaveWorkflowButton from './SaveWorkflowButton';
export const ActiveWorkflowNameAndActions = memo(() => {
const mode = useAppSelector(selectWorkflowMode);
const isPublished = useAppSelector(selectWorkflowIsPublished);
return (
<Flex w="full" alignItems="center" gap={1} minW={0}>
<WorkflowListMenuTrigger />
<Spacer />
{mode === 'edit' && !isPublished && <SaveWorkflowButton />}
{mode === 'edit' && <SaveWorkflowButton />}
<WorkflowViewEditToggleButton />
<WorkflowLibraryMenu />
</Flex>

View File

@@ -1,30 +1,22 @@
import { Flex } from '@invoke-ai/ui-library';
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 { 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/workflowSlice';
import { memo } from 'react';
import { ViewModeLeftPanelContent } from './viewMode/ViewModeLeftPanelContent';
const WorkflowsTabLeftPanel = () => {
const mode = useAppSelector(selectWorkflowMode);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isInPublishFlow = useStore($isInPublishFlow);
return (
<Flex w="full" h="full" gap={2} flexDir="column">
{isInPublishFlow && <PublishWorkflowPanelContent />}
{!isInPublishFlow && <ActiveWorkflowNameAndActions />}
{!isInPublishFlow && !isPublished && mode === 'view' && <ActiveWorkflowDescription />}
{!isInPublishFlow && !isPublished && mode === 'view' && <ViewModeLeftPanelContent />}
{!isInPublishFlow && !isPublished && mode === 'edit' && <EditModeLeftPanelContent />}
{isPublished && <PublishedWorkflowPanelContent />}
<ActiveWorkflowNameAndActions />
{mode === 'view' && <ActiveWorkflowDescription />}
{mode === 'view' && <ViewModeLeftPanelContent />}
{mode === 'edit' && <EditModeLeftPanelContent />}
</Flex>
);
};

View File

@@ -67,8 +67,11 @@ FormElementEditModeHeader.displayName = 'FormElementEditModeHeader';
const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => {
const { t } = useTranslation();
const { nodeId } = element.data.fieldIdentifier;
const zoomToNode = useZoomToNode(nodeId);
const zoomToNode = useZoomToNode();
const mouseOverFormField = useMouseOverFormField(nodeId);
const onClick = useCallback(() => {
zoomToNode(nodeId);
}, [nodeId, zoomToNode]);
return (
<IconButton
@@ -76,7 +79,7 @@ const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => {
onMouseOut={mouseOverFormField.handleMouseOut}
tooltip={t('workflows.builder.zoomToNode')}
aria-label={t('workflows.builder.zoomToNode')}
onClick={zoomToNode}
onClick={onClick}
icon={<PiGpsFixBold />}
variant="link"
size="sm"

View File

@@ -2,8 +2,8 @@ import { FormHelperText, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { linkifyOptions, linkifySx } from 'common/components/linkify';
import { useEditable } from 'common/hooks/useEditable';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
@@ -13,7 +13,7 @@ export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeField
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLTextAreaElement>(null);

View File

@@ -39,7 +39,7 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement })
return (
<Flex ref={draggableRef} id={id} className={NODE_FIELD_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
<NodeFieldElementEditModeContent dragHandleRef={dragHandleRef} el={el} isDragging={isDragging} />
<NodeFieldElementOverlay nodeId={el.data.fieldIdentifier.nodeId} />
<NodeFieldElementOverlay element={el} />
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
</Flex>
);
@@ -105,9 +105,9 @@ const nodeFieldOverlaySx: SystemStyleObject = {
},
};
export const NodeFieldElementOverlay = memo(({ nodeId }: { nodeId: string }) => {
const mouseOverNode = useMouseOverNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
const NodeFieldElementOverlay = memo(({ element }: { element: NodeFieldElement }) => {
const mouseOverNode = useMouseOverNode(element.data.fieldIdentifier.nodeId);
const mouseOverFormField = useMouseOverFormField(element.data.fieldIdentifier.nodeId);
return (
<Box

View File

@@ -1,14 +1,14 @@
import { Flex, FormLabel, Spacer } from '@invoke-ai/ui-library';
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo, useMemo } from 'react';
export const NodeFieldElementLabel = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier } = data;
const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]);

View File

@@ -2,8 +2,8 @@ import { Flex, FormLabel, Input, Spacer } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo, useCallback, useRef } from 'react';
@@ -12,7 +12,7 @@ export const NodeFieldElementLabelEditable = memo(({ el }: { el: NodeFieldElemen
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLInputElement>(null);

View File

@@ -15,7 +15,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings';
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 { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import {
isFloatFieldInputTemplate,

View File

@@ -5,9 +5,8 @@ import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { NodeFieldElementLabel } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabel';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplateSafe';
import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { useInputFieldTemplateOrThrow, useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplate';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
@@ -37,7 +36,7 @@ const useFormatFallbackLabel = () => {
export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const containerCtx = useContainerContext();
const formatFallbackLabel = useFormatFallbackLabel();
@@ -70,7 +69,7 @@ NodeFieldElementViewMode.displayName = 'NodeFieldElementViewMode';
const NodeFieldElementViewModeContent = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const _description = useMemo(

View File

@@ -1,6 +1,4 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
import type { ElementDragPayload } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
draggable,
dropTargetForElements,
@@ -35,7 +33,7 @@ import {
selectFormRootElementId,
selectWorkflowSlice,
} from 'features/nodes/store/workflowSlice';
import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { FieldIdentifier, FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { ElementId, FormElement } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, isContainerElement } from 'features/nodes/types/workflow';
import type { RefObject } from 'react';
@@ -60,27 +58,6 @@ const isFormElementDndData = (data: Record<string | symbol, unknown>): data is F
return uniqueFormElementDndKey in data;
};
const uniqueNodeFieldDndKey = Symbol('node-field');
type NodeFieldDndData = {
[uniqueNodeFieldDndKey]: true;
nodeId: string;
fieldName: string;
fieldTemplate: FieldInputTemplate;
};
export const buildNodeFieldDndData = (
nodeId: string,
fieldName: string,
fieldTemplate: FieldInputTemplate
): NodeFieldDndData => ({
[uniqueNodeFieldDndKey]: true,
nodeId,
fieldName,
fieldTemplate,
});
const isNodeFieldDndData = (data: Record<string | symbol, unknown>): data is NodeFieldDndData => {
return uniqueNodeFieldDndKey in data;
};
/**
* Flashes an element by changing its background color. Used to indicate that an element has been moved.
* @param elementId The id of the element to flash
@@ -156,27 +133,6 @@ const useGetInitialValue = () => {
return _getInitialValue;
};
const getSourceElement = (source: ElementDragPayload) => {
if (isNodeFieldDndData(source.data)) {
const { nodeId, fieldName, fieldTemplate } = source.data;
return buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
}
if (isFormElementDndData(source.data)) {
return source.data.element;
}
return null;
};
const getTargetElement = (target: DropTargetRecord) => {
if (isFormElementDndData(target.data)) {
return target.data.element;
}
return null;
};
/**
* Singleton hook that monitors for builder dnd events and dispatches actions accordingly.
*/
@@ -200,20 +156,20 @@ export const useBuilderDndMonitor = () => {
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) => isFormElementDndData(source.data) || isNodeFieldDndData(source.data),
canMonitor: ({ source }) => isFormElementDndData(source.data),
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
const sourceElement = getSourceElement(source);
const targetElement = getTargetElement(target);
if (!sourceElement || !targetElement) {
if (!isFormElementDndData(source.data) || !isFormElementDndData(target.data)) {
return;
}
const sourceElement = source.data.element;
const targetElement = target.data.element;
if (sourceElement.id === targetElement.id) {
// Dropping on self is a no-op
return;
@@ -403,15 +359,8 @@ export const useFormElementDnd = (
element: draggableElement,
// TODO(psyche): This causes a kinda jittery behaviour - need a better heuristic to determine stickiness
getIsSticky: () => false,
canDrop: ({ source }) => {
if (isNodeFieldDndData(source.data)) {
return true;
}
if (isFormElementDndData(source.data)) {
return source.data.element.id !== getElement(elementId).parentId;
}
return false;
},
canDrop: ({ source }) =>
isFormElementDndData(source.data) && source.data.element.id !== getElement(elementId).parentId,
getData: ({ input }) => {
const element = getElement(elementId);
@@ -474,16 +423,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>
dropTargetForElements({
element: droppableElement,
getIsSticky: () => false,
canDrop: ({ source }) => {
const rootElement = getElement(rootElementId, isContainerElement);
if (rootElement.data.children.length !== 0) {
return false;
}
if (isNodeFieldDndData(source.data) || isFormElementDndData(source.data)) {
return true;
}
return false;
},
canDrop: ({ source }) =>
getElement(rootElementId, isContainerElement).data.children.length === 0 && isFormElementDndData(source.data),
getData: ({ input }) => {
const element = getElement(rootElementId, isContainerElement);
@@ -514,8 +455,7 @@ export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>
/**
* Hook that provides dnd functionality for node fields.
*
* @param nodeId: The id of the node
* @param fieldName: The name of the field
* @param fieldIdentifier The identifier of the node field
* @param fieldTemplate The template of the node field, required to build the form element
* @param draggableRef The ref of the draggable HTML element
* @param dragHandleRef The ref of the drag handle HTML element
@@ -523,8 +463,7 @@ export const useRootElementDropTarget = (droppableRef: RefObject<HTMLDivElement>
* @returns Whether the node field is currently being dragged
*/
export const useNodeFieldDnd = (
nodeId: string,
fieldName: string,
fieldIdentifier: FieldIdentifier,
fieldTemplate: FieldInputTemplate,
draggableRef: RefObject<HTMLElement>,
dragHandleRef: RefObject<HTMLElement>
@@ -542,7 +481,12 @@ export const useNodeFieldDnd = (
draggable({
element: draggableElement,
dragHandle: dragHandleElement,
getInitialData: () => buildNodeFieldDndData(nodeId, fieldName, fieldTemplate),
getInitialData: () => {
const { nodeId, fieldName } = fieldIdentifier;
const { type } = fieldTemplate;
const element = buildNodeFieldElement(nodeId, fieldName, type);
return buildFormElementDndData(element);
},
onDragStart: () => {
setIsDragging(true);
},
@@ -551,7 +495,7 @@ export const useNodeFieldDnd = (
},
})
);
}, [dragHandleRef, draggableRef, fieldName, fieldTemplate, nodeId]);
}, [dragHandleRef, draggableRef, fieldIdentifier, fieldTemplate]);
return isDragging;
};

View File

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

View File

@@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import { InvocationNodeNotesTextarea } from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeNotesTextarea';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
@@ -36,7 +36,7 @@ export default memo(InspectorDetailsTab);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const version = useNodeVersion(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const needsUpdate = useNodeNeedsUpdate(nodeId);
return (

View File

@@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -37,7 +37,7 @@ const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}`
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const nes = useNodeExecutionState(nodeId);
if (!nes || nes.outputs.length === 0) {

View File

@@ -1,8 +1,8 @@
import { Flex, Input, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { useNodeTemplateTitleSafe } from 'features/nodes/hooks/useNodeTemplateTitleSafe';
import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,8 +14,8 @@ type Props = {
const InspectorTabEditableNodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const label = useNodeUserTitleSafe(nodeId);
const templateTitle = useNodeTemplateTitleSafe(nodeId);
const label = useNodeLabel(nodeId);
const templateTitle = useNodeTemplateTitle(nodeId);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(

View File

@@ -2,7 +2,7 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,7 +29,7 @@ export default memo(NodeTemplateInspector);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
return <DataViewer data={template} label={t('nodes.nodeTemplate')} bg="base.850" color="base.200" />;
});

View File

@@ -1,4 +1,4 @@
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplate';
import type { PropsWithChildren, ReactNode } from 'react';
import { memo } from 'react';

View File

@@ -1,429 +0,0 @@
import {
Button,
ButtonGroup,
Divider,
Flex,
ListItem,
Spacer,
Text,
Tooltip,
UnorderedList,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { logger } from 'app/logging/logger';
import { $projectUrl } from 'app/store/nanostores/projectId';
import { useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
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 {
$isInPublishFlow,
$isReadyToDoValidationRun,
$isSelectingOutputNode,
$outputNodeId,
$validationRunBatchId,
usePublishInputs,
} from 'features/nodes/components/sidePanel/workflow/publish';
import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
import { useInputFieldUserTitleOrThrow } from 'features/nodes/hooks/useInputFieldUserTitleOrThrow';
import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeTemplateTitleOrThrow } from 'features/nodes/hooks/useNodeTemplateTitleOrThrow';
import { useNodeUserTitleOrThrow } from 'features/nodes/hooks/useNodeUserTitleOrThrow';
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 { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { toast } from 'features/toast/toast';
import type { PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiLightningFill, PiSignOutBold, PiXBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
import { assert } from 'tsafe';
const log = logger('generation');
export const PublishWorkflowPanelContent = memo(() => {
return (
<Flex flexDir="column" gap={2} h="full">
<ButtonGroup isAttached={false} size="sm" variant="ghost">
<SelectOutputNodeButton />
<Spacer />
<CancelPublishButton />
<PublishWorkflowButton />
</ButtonGroup>
<ScrollableContent>
<Flex flexDir="column" gap={2} w="full" h="full">
<OutputFields />
<PublishableInputFields />
<UnpublishableInputFields />
</Flex>
</ScrollableContent>
</Flex>
);
});
PublishWorkflowPanelContent.displayName = 'DeployWorkflowPanelContent';
const OutputFields = memo(() => {
const { t } = useTranslation();
const outputNodeId = useStore($outputNodeId);
if (!outputNodeId) {
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold" color="error.300">
{t('workflows.builder.noOutputNodeSelected')}
</Text>
</Flex>
);
}
return <OutputFieldsContent outputNodeId={outputNodeId} />;
});
OutputFields.displayName = 'OutputFields';
const OutputFieldsContent = memo(({ outputNodeId }: { outputNodeId: string }) => {
const { t } = useTranslation();
const outputFieldNames = useOutputFieldNames(outputNodeId);
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold">{t('workflows.builder.publishedWorkflowOutputs')}</Text>
<Divider />
{outputFieldNames.map((fieldName) => (
<NodeOutputFieldPreview key={`${outputNodeId}-${fieldName}`} nodeId={outputNodeId} fieldName={fieldName} />
))}
</Flex>
);
});
OutputFieldsContent.displayName = 'OutputFieldsContent';
const PublishableInputFields = memo(() => {
const { t } = useTranslation();
const inputs = usePublishInputs();
if (inputs.publishable.length === 0) {
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold" color="warning.300">
{t('workflows.builder.noPublishableInputs')}
</Text>
</Flex>
);
}
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold">{t('workflows.builder.publishedWorkflowInputs')}</Text>
<Divider />
{inputs.publishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
})}
</Flex>
);
});
PublishableInputFields.displayName = 'PublishableInputFields';
const UnpublishableInputFields = memo(() => {
const { t } = useTranslation();
const inputs = usePublishInputs();
if (inputs.unpublishable.length === 0) {
return null;
}
return (
<Flex flexDir="column" borderWidth={1} borderRadius="base" gap={2} p={2}>
<Text fontWeight="semibold" color="warning.300">
{t('workflows.builder.unpublishableInputs')}
</Text>
<Divider />
{inputs.unpublishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
})}
</Flex>
);
});
UnpublishableInputFields.displayName = 'UnpublishableInputFields';
const SelectOutputNodeButton = memo(() => {
const { t } = useTranslation();
const outputNodeId = useStore($outputNodeId);
const isSelectingOutputNode = useStore($isSelectingOutputNode);
const onClick = useCallback(() => {
$outputNodeId.set(null);
$isSelectingOutputNode.set(true);
}, []);
return (
<Button leftIcon={<PiSignOutBold />} isDisabled={isSelectingOutputNode} onClick={onClick}>
{outputNodeId ? t('workflows.builder.changeOutputNode') : t('workflows.builder.selectOutputNode')}
</Button>
);
});
SelectOutputNodeButton.displayName = 'SelectOutputNodeButton';
const CancelPublishButton = memo(() => {
const { t } = useTranslation();
const onClick = useCallback(() => {
$isInPublishFlow.set(false);
$isSelectingOutputNode.set(false);
$outputNodeId.set(null);
}, []);
return (
<Button leftIcon={<PiXBold />} onClick={onClick}>
{t('common.cancel')}
</Button>
);
});
CancelPublishButton.displayName = 'CancelDeployButton';
const PublishWorkflowButton = memo(() => {
const { t } = useTranslation();
const isReadyToDoValidationRun = useStore($isReadyToDoValidationRun);
const isReadyToEnqueue = useStore($isReadyToEnqueue);
const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
const outputNodeId = useStore($outputNodeId);
const isSelectingOutputNode = useStore($isSelectingOutputNode);
const inputs = usePublishInputs();
const projectUrl = useStore($projectUrl);
const enqueue = useEnqueueWorkflows();
const onClick = useCallback(async () => {
const result = await withResultAsync(() => enqueue(true, true));
if (result.isErr()) {
toast({
id: 'TOAST_PUBLISH_FAILED',
status: 'error',
title: t('workflows.builder.publishFailed'),
description: t('workflows.builder.publishFailedDesc'),
duration: null,
});
log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch');
} else {
toast({
id: 'TOAST_PUBLISH_SUCCESSFUL',
status: 'success',
title: t('workflows.builder.publishSuccess'),
description: (
<Trans
i18nKey="workflows.builder.publishSuccessDesc"
components={{
LinkComponent: <ExternalLink href={projectUrl ?? ''} />,
}}
/>
),
duration: null,
});
assert(result.value.enqueueResult.batch.batch_id);
$validationRunBatchId.set(result.value.enqueueResult.batch.batch_id);
log.debug(parseify(result.value), 'Enqueued batch');
}
}, [enqueue, projectUrl, t]);
return (
<PublishTooltip
isWorkflowSaved={isWorkflowSaved}
hasBatchOrGeneratorNodes={hasBatchOrGeneratorNodes}
isReadyToEnqueue={isReadyToEnqueue}
hasOutputNode={outputNodeId !== null && !isSelectingOutputNode}
hasPublishableInputs={inputs.publishable.length > 0}
hasUnpublishableInputs={inputs.unpublishable.length > 0}
>
<Button
leftIcon={<PiLightningFill />}
isDisabled={
!isReadyToDoValidationRun ||
!isReadyToEnqueue ||
hasBatchOrGeneratorNodes ||
!(outputNodeId !== null && !isSelectingOutputNode)
}
onClick={onClick}
>
{t('workflows.builder.publish')}
</Button>
</PublishTooltip>
);
});
PublishWorkflowButton.displayName = 'DoValidationRunButton';
const NodeInputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
const mouseOverFormField = useMouseOverFormField(nodeId);
const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
const fieldUserTitle = useInputFieldUserTitleOrThrow(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const zoomToNode = useZoomToNode(nodeId);
return (
<Flex
flexDir="column"
position="relative"
p={2}
borderRadius="base"
onMouseOver={mouseOverFormField.handleMouseOver}
onMouseOut={mouseOverFormField.handleMouseOut}
onClick={zoomToNode}
>
<Text fontWeight="semibold">{`${nodeUserTitle || nodeTemplateTitle} -> ${fieldUserTitle || fieldTemplateTitle}`}</Text>
<Text variant="subtext">{`${nodeId} -> ${fieldName}`}</Text>
<NodeFieldElementOverlay nodeId={nodeId} />
</Flex>
);
});
NodeInputFieldPreview.displayName = 'NodeInputFieldPreview';
const NodeOutputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
const mouseOverFormField = useMouseOverFormField(nodeId);
const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const zoomToNode = useZoomToNode(nodeId);
return (
<Flex
flexDir="column"
position="relative"
p={2}
borderRadius="base"
onMouseOver={mouseOverFormField.handleMouseOver}
onMouseOut={mouseOverFormField.handleMouseOut}
onClick={zoomToNode}
>
<Text fontWeight="semibold">{`${nodeUserTitle || nodeTemplateTitle} -> ${fieldTemplate.title}`}</Text>
<Text variant="subtext">{`${nodeId} -> ${fieldName}`}</Text>
<NodeFieldElementOverlay nodeId={nodeId} />
</Flex>
);
});
NodeOutputFieldPreview.displayName = 'NodeOutputFieldPreview';
export const StartPublishFlowButton = memo(() => {
const { t } = useTranslation();
const deployWorkflowIsEnabled = useFeatureStatus('deployWorkflow');
const isReadyToEnqueue = useStore($isReadyToEnqueue);
const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
const inputs = usePublishInputs();
const onClick = useCallback(() => {
$isInPublishFlow.set(true);
}, []);
return (
<PublishTooltip
isWorkflowSaved={isWorkflowSaved}
hasBatchOrGeneratorNodes={hasBatchOrGeneratorNodes}
isReadyToEnqueue={isReadyToEnqueue}
hasOutputNode={true}
hasPublishableInputs={inputs.publishable.length > 0}
hasUnpublishableInputs={inputs.unpublishable.length > 0}
>
<Button
onClick={onClick}
leftIcon={<PiLightningFill />}
variant="ghost"
size="sm"
isDisabled={!deployWorkflowIsEnabled || !isWorkflowSaved || hasBatchOrGeneratorNodes}
>
{t('workflows.builder.publish')}
</Button>
</PublishTooltip>
);
});
StartPublishFlowButton.displayName = 'StartPublishFlowButton';
const PublishTooltip = memo(
({
isWorkflowSaved,
hasBatchOrGeneratorNodes,
isReadyToEnqueue,
hasOutputNode,
hasPublishableInputs,
hasUnpublishableInputs,
children,
}: PropsWithChildren<{
isWorkflowSaved: boolean;
hasBatchOrGeneratorNodes: boolean;
isReadyToEnqueue: boolean;
hasOutputNode: boolean;
hasPublishableInputs: boolean;
hasUnpublishableInputs: boolean;
}>) => {
const { t } = useTranslation();
const warnings = useMemo(() => {
const _warnings: string[] = [];
if (!hasPublishableInputs) {
_warnings.push(t('workflows.builder.warningWorkflowHasNoPublishableInputFields'));
}
if (hasUnpublishableInputs) {
_warnings.push(t('workflows.builder.warningWorkflowHasUnpublishableInputFields'));
}
return _warnings;
}, [hasPublishableInputs, hasUnpublishableInputs, t]);
const errors = useMemo(() => {
const _errors: string[] = [];
if (!isWorkflowSaved) {
_errors.push(t('workflows.builder.errorWorkflowHasUnsavedChanges'));
}
if (hasBatchOrGeneratorNodes) {
_errors.push(t('workflows.builder.errorWorkflowHasBatchOrGeneratorNodes'));
}
if (!isReadyToEnqueue) {
_errors.push(t('workflows.builder.errorWorkflowHasInvalidGraph'));
}
if (!hasOutputNode) {
_errors.push(t('workflows.builder.errorWorkflowHasNoOutputNode'));
}
return _errors;
}, [hasBatchOrGeneratorNodes, hasOutputNode, isReadyToEnqueue, isWorkflowSaved, t]);
if (errors.length === 0 && warnings.length === 0) {
return children;
}
return (
<Tooltip
label={
<Flex flexDir="column">
{errors.length > 0 && (
<>
<Text color="error.700" fontWeight="semibold">
{t('workflows.builder.cannotPublish')}:
</Text>
<UnorderedList>
{errors.map((problem, index) => (
<ListItem key={index}>{problem}</ListItem>
))}
</UnorderedList>
</>
)}
{warnings.length > 0 && (
<>
<Text color="warning.700" fontWeight="semibold">
{t('workflows.builder.publishWarnings')}:
</Text>
<UnorderedList>
{warnings.map((problem, index) => (
<ListItem key={index}>{problem}</ListItem>
))}
</UnorderedList>
</>
)}
</Flex>
}
>
{children}
</Tooltip>
);
}
);
PublishTooltip.displayName = 'PublishTooltip';

View File

@@ -1,23 +0,0 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiLockBold } from 'react-icons/pi';
export const LockedWorkflowIcon = memo(() => {
const { t } = useTranslation();
return (
<Tooltip label={t('workflows.builder.publishedWorkflowsLocked')} closeOnScroll>
<IconButton
size="sm"
cursor='not-allowed'
variant="link"
alignSelf="stretch"
aria-label={t('workflows.builder.publishedWorkflowsLocked')}
icon={<PiLockBold />}
/>
</Tooltip>
);
});
LockedWorkflowIcon.displayName = 'LockedWorkflowIcon';

View File

@@ -26,7 +26,6 @@ import {
workflowLibraryTagToggled,
workflowLibraryViewChanged,
} from 'features/nodes/store/workflowLibrarySlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@@ -40,12 +39,13 @@ export const WorkflowLibrarySideNav = () => {
const { t } = useTranslation();
const categoryOptions = useStore($workflowLibraryCategoriesOptions);
const view = useAppSelector(selectWorkflowLibraryView);
const deployWorkflow = useFeatureStatus('deployWorkflow');
return (
<Flex h="full" minH={0} overflow="hidden" flexDir="column" w={64} gap={0}>
<Flex flexDir="column" w="full" pb={2} gap={2}>
<Flex flexDir="column" w="full" pb={2}>
<WorkflowLibraryViewButton view="recent">{t('workflows.recentlyOpened')}</WorkflowLibraryViewButton>
</Flex>
<Flex flexDir="column" w="full" pb={2}>
<WorkflowLibraryViewButton view="yours">{t('workflows.yourWorkflows')}</WorkflowLibraryViewButton>
{categoryOptions.includes('project') && (
<Collapse in={view === 'yours' || view === 'shared' || view === 'private'}>
@@ -60,9 +60,6 @@ export const WorkflowLibrarySideNav = () => {
</Flex>
</Collapse>
)}
{deployWorkflow && (
<WorkflowLibraryViewButton view="published">{t('workflows.publishedWorkflows')}</WorkflowLibraryViewButton>
)}
</Flex>
<Flex h="full" minH={0} overflow="hidden" flexDir="column">
<BrowseWorkflowsButton />

View File

@@ -36,8 +36,6 @@ const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => {
return ['user'];
case 'shared':
return ['project'];
case 'published':
return ['user', 'project', 'default'];
default:
assert<Equals<typeof view, never>>(false);
}
@@ -68,7 +66,6 @@ const useInfiniteQueryAry = () => {
query: debouncedSearchTerm,
tags: view === 'defaults' ? selectedTags : [],
has_been_opened: getHasBeenOpened(view),
is_published: view === 'published' ? true : undefined,
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[0];
}, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);

View File

@@ -1,7 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
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 { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
@@ -55,6 +54,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
position="relative"
role="button"
onClick={handleClickLoad}
cursor="pointer"
bg="base.750"
borderRadius="base"
w="full"
@@ -81,7 +81,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
<Flex gap={2} alignItems="flex-start" justifyContent="space-between" w="full">
<Text noOfLines={2}>{workflow.name}</Text>
<Flex gap={2} alignItems="center">
{isActive && !workflow.is_published && (
{isActive && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
@@ -93,18 +93,6 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.opened')}
</Badge>
)}
{workflow.is_published && (
<Badge
color="invokeGreen.400"
borderColor="invokeGreen.700"
borderWidth={1}
bg="transparent"
flexShrink={0}
variant="subtle"
>
{t('workflows.builder.published')}
</Badge>
)}
{workflow.category === 'project' && <Icon as={PiUsersBold} color="base.200" />}
{workflow.category === 'default' && (
<Image
@@ -131,10 +119,8 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
</Text>
)}
<Spacer />
{workflow.category === 'default' && !workflow.is_published && (
<ViewWorkflow workflowId={workflow.workflow_id} />
)}
{workflow.category !== 'default' && !workflow.is_published && (
{workflow.category === 'default' && <ViewWorkflow workflowId={workflow.workflow_id} />}
{workflow.category !== 'default' && (
<>
<EditWorkflow workflowId={workflow.workflow_id} />
<DownloadWorkflow workflowId={workflow.workflow_id} />
@@ -142,7 +128,6 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
</>
)}
{workflow.category === 'project' && <ShareWorkflowButton workflow={workflow} />}
{workflow.is_published && <LockedWorkflowIcon />}
</Flex>
</Flex>
</Flex>

View File

@@ -1,7 +1,5 @@
import { Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { WorkflowBuilder } from 'features/nodes/components/sidePanel/builder/WorkflowBuilder';
import { StartPublishFlowButton } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,15 +8,12 @@ import WorkflowJSONTab from './WorkflowJSONTab';
const WorkflowFieldsLinearViewPanel = () => {
const { t } = useTranslation();
const deployWorkflowIsEnabled = useFeatureStatus('deployWorkflow');
return (
<Tabs variant="enclosed" display="flex" w="full" h="full" flexDir="column">
<TabList>
<Tab>{t('workflows.builder.builder')}</Tab>
<Tab>{t('common.details')}</Tab>
<Tab>JSON</Tab>
<Spacer />
{deployWorkflowIsEnabled && <StartPublishFlowButton />}
</TabList>
<TabPanels h="full" pt={2}>

View File

@@ -1,90 +0,0 @@
import { useStore } from '@nanostores/react';
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 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 { assert } from 'tsafe';
export const $isInPublishFlow = atom(false);
export const $outputNodeId = atom<string | null>(null);
export const $isSelectingOutputNode = atom(false);
export const $isReadyToDoValidationRun = computed(
[$isInPublishFlow, $outputNodeId, $isSelectingOutputNode],
(isInPublishFlow, outputNodeId, isSelectingOutputNode) => {
return isInPublishFlow && outputNodeId !== null && !isSelectingOutputNode;
}
);
export const $validationRunBatchId = atom<string | null>(null);
export const useIsValidationRunInProgress = () => {
const validationRunBatchId = useStore($validationRunBatchId);
const { isValidationRunInProgress } = useGetBatchStatusQuery(
validationRunBatchId ? { batch_id: validationRunBatchId } : skipToken,
{
selectFromResult: ({ currentData }) => {
if (!currentData) {
return { isValidationRunInProgress: false };
}
if (currentData && currentData.in_progress > 0) {
return { isValidationRunInProgress: true };
}
return { isValidationRunInProgress: false };
},
}
);
return validationRunBatchId !== null || isValidationRunInProgress;
};
export const selectFieldIdentifiersWithInvocationTypes = createSelector(
selectWorkflowFormNodeFieldFieldIdentifiersDeduped,
selectNodesSlice,
(fieldIdentifiers, nodes) => {
const result: { nodeId: string; fieldName: string; type: string }[] = [];
for (const fieldIdentifier of fieldIdentifiers) {
const node = nodes.nodes.find((node) => node.id === fieldIdentifier.nodeId);
assert(isInvocationNode(node), `Node ${fieldIdentifier.nodeId} not found`);
result.push({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, type: node.data.type });
}
return result;
}
);
export const getPublishInputs = (fieldIdentifiers: (FieldIdentifier & { type: string })[], templates: Templates) => {
// Certain field types are not allowed to be input fields on a published workflow
const publishable: FieldIdentifier[] = [];
const unpublishable: FieldIdentifier[] = [];
for (const fieldIdentifier of fieldIdentifiers) {
const fieldTemplate = templates[fieldIdentifier.type]?.inputs[fieldIdentifier.fieldName];
if (!fieldTemplate) {
unpublishable.push(fieldIdentifier);
continue;
}
if (isBoardFieldType(fieldTemplate.type)) {
unpublishable.push(fieldIdentifier);
continue;
}
publishable.push(fieldIdentifier);
}
return { publishable, unpublishable };
};
export const usePublishInputs = () => {
const templates = useStore($templates);
const fieldIdentifiersWithInvocationTypes = useAppSelector(selectFieldIdentifiersWithInvocationTypes);
const fieldIdentifiers = useMemo(
() => getPublishInputs(fieldIdentifiersWithInvocationTypes, templates),
[fieldIdentifiersWithInvocationTypes, templates]
);
return fieldIdentifiers;
};

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';

View File

@@ -11,7 +11,7 @@ import { useMemo } from 'react';
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserDescriptionSafe = (nodeId: string, fieldName: string) => {
export const useInputFieldDescriptionSafe = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(

View File

@@ -4,21 +4,21 @@ import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/s
import { useMemo } from 'react';
/**
* Gets the user-defined title of an input field for a given node.
* Gets the user-defined label of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an empty string is returned.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserTitleSafe = (nodeId: string, fieldName: string): string => {
export const useInputFieldLabelSafe = (nodeId: string, fieldName: string): string => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => selectFieldInputInstanceSafe(nodes, nodeId, fieldName)?.label ?? ''),
[fieldName, nodeId]
);
const title = useAppSelector(selector);
const label = useAppSelector(selector);
return title;
return label;
};

View File

@@ -1,11 +1,10 @@
import { useNodeData } from 'features/nodes/hooks/useNodeData';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
const isConnectionInputField = (field: FieldInputTemplate) => {
return (
(field.input === 'connection' && !isSingleOrCollection(field.type)) || !(field.type.name in TEMPLATE_BUILDER_MAP)
@@ -20,7 +19,7 @@ const isAnyOrDirectInputField = (field: FieldInputTemplate) => {
};
export const useInputFieldNamesMissing = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const node = useNodeData(nodeId);
const fieldNames = useMemo(() => {
const instanceFields = new Set(Object.keys(node.inputs));
@@ -31,7 +30,7 @@ export const useInputFieldNamesMissing = (nodeId: string) => {
};
export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const fieldNames = useMemo(() => {
const anyOrDirectFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {
@@ -45,7 +44,7 @@ export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
};
export const useInputFieldNamesConnection = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const fieldNames = useMemo(() => {
const connectionFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {

View File

@@ -1,9 +1,8 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
/**
* Returns the template for a specific input field of a node.
*
@@ -14,7 +13,7 @@ import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
* @throws Will throw an error if the template for the input field is not found.
*/
export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string): FieldInputTemplate => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.inputs[fieldName];
assert(_fieldTemplate, `Template for input field ${fieldName} not found.`);
@@ -22,3 +21,17 @@ export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string):
}, [fieldName, template.inputs]);
return fieldTemplate;
};
/**
* Returns the template for a specific input field of a node.
*
* **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found.
*
* @param nodeId - The ID of the node.
* @param fieldName - The name of the input field.
*/
export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
const template = useNodeTemplate(nodeId);
const fieldTemplate = useMemo(() => template.inputs[fieldName] ?? null, [fieldName, template.inputs]);
return fieldTemplate;
};

View File

@@ -1,17 +0,0 @@
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
/**
* Returns the template for a specific input field of a node.
*
* **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found.
*
* @param nodeId - The ID of the node.
* @param fieldName - The name of the input field.
*/
export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
const template = useNodeTemplateSafe(nodeId);
const fieldTemplate = useMemo(() => template?.inputs[fieldName] ?? null, [fieldName, template?.inputs]);
return fieldTemplate;
};

View File

@@ -1,10 +1,9 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useInputFieldTemplateTitleOrThrow = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplateOrThrow(nodeId);
export const useInputFieldTemplateTitle = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplate(nodeId);
const title = useMemo(() => {
const fieldTemplate = template.inputs[fieldName];

View File

@@ -1,9 +0,0 @@
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useInputFieldTemplateTitleSafe = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplateOrThrow(nodeId);
const title = useMemo(() => template.inputs[fieldName]?.title ?? '', [fieldName, template.inputs]);
return title;
};

View File

@@ -1,22 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
/**
* Gets the user-defined description of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an error is thrown.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserDescriptionOrThrow = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).description),
[fieldName, nodeId]
);
const description = useAppSelector(selector);
return description;
};

View File

@@ -1,23 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
/**
* Gets the user-defined title of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an error is thrown.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserTitleOrThrow = (nodeId: string, fieldName: string): string => {
const selector = useMemo(
() => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).label),
[fieldName, nodeId]
);
const title = useAppSelector(selector);
return title;
};

View File

@@ -1,10 +1,9 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { isBatchNodeType, isGeneratorNodeType } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useIsExecutableNode = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const isExecutableNode = useMemo(
() => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type),
[template]

View File

@@ -1,13 +0,0 @@
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';
export const useIsWorkflowEditorLocked = () => {
const isInPublishFlow = useStore($isInPublishFlow);
const isPublished = useAppSelector(selectWorkflowIsPublished);
const isValidationRunInProgress = useIsValidationRunInProgress();
const isLocked = isInPublishFlow || isPublished || isValidationRunInProgress;
return isLocked;
};

View File

@@ -1,10 +1,9 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { Classification } from 'features/nodes/types/common';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeClassification = (nodeId: string): Classification => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const classification = useMemo(() => template.classification, [template]);
return classification;
};

View File

@@ -1,10 +1,9 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { some } from 'lodash-es';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeHasImageOutput = (nodeId: string): boolean => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const hasImageOutput = useMemo(
() =>
some(

View File

@@ -3,16 +3,16 @@ import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useNodeUserTitleSafe = (nodeId: string) => {
export const useNodeLabel = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
return node?.data.label ?? null;
return node?.data.label;
}),
[nodeId]
);
const title = useAppSelector(selector);
return title;
const label = useAppSelector(selector);
return label;
};

View File

@@ -1,13 +1,12 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeNeedsUpdate = (nodeId: string) => {
const type = useNodeType(nodeId);
const version = useNodeVersion(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplate(nodeId);
const needsUpdate = useMemo(() => {
if (type !== template.type) {
return true;

View File

@@ -5,7 +5,7 @@ import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeTemplateOrThrow = (nodeId: string): InvocationTemplate => {
export const useNodeTemplate = (nodeId: string): InvocationTemplate => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => {
@@ -15,3 +15,10 @@ export const useNodeTemplateOrThrow = (nodeId: string): InvocationTemplate => {
}, [templates, type]);
return template;
};
export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => templates[type] ?? null, [templates, type]);
return template;
};

View File

@@ -1,12 +0,0 @@
import { useStore } from '@nanostores/react';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { $templates } from 'features/nodes/store/nodesSlice';
import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => templates[type] ?? null, [templates, type]);
return template;
};

View File

@@ -6,7 +6,7 @@ import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeTemplateTitleSafe = (nodeId: string): string | null => {
export const useNodeTemplateTitle = (nodeId: string): string | null => {
const templates = useStore($templates);
const selector = useMemo(
() =>

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