From 3848e1926bb3dcdf4ced15676b58066e50c67283 Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Sat, 18 Jan 2025 09:10:54 -0800 Subject: [PATCH 001/102] chore(docker): reduce size between docker builds by adding a layer with all the pytorch dependencies that don't change most of the time. --- docker/Dockerfile | 56 +++++++++++++++++++++++++++-------------------- pyproject.toml | 3 +-- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 657cf1d30db..60d2095a7da 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,52 +13,62 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ git # Install `uv` for package management -COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.5.21 /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_INDEX="https://download.pytorch.org/whl/cu124" ARG GPU_DRIVER=cuda -ARG TARGETPLATFORM="linux/amd64" # 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} && \ +RUN mkdir -p ${INVOKEAI_SRC} && \ chmod -R a+w /opt USER ubuntu -# Install python and create the venv -RUN uv python install ${PYTHON_VERSION} && \ - uv venv --relocatable --prompt "invoke" --python ${PYTHON_VERSION} ${VIRTUAL_ENV} +# Install python +RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \ + uv python install ${PYTHON_VERSION} WORKDIR ${INVOKEAI_SRC} -COPY invokeai ./invokeai -COPY pyproject.toml ./ -# Editable mode helps use the same image for development: -# the local working copy can be bind-mounted into the image -# at path defined by ${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,target=invokeai \ + 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 \ - extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cpu"; \ + UV_INDEX="https://download.pytorch.org/whl/cpu"; \ elif [ "$GPU_DRIVER" = "rocm" ]; then \ - extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/rocm6.1"; \ - else \ - extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cu124"; \ + UV_INDEX="https://download.pytorch.org/whl/rocm6.1"; \ fi && \ - uv pip install --python ${PYTHON_VERSION} $extra_index_url_arg -e "." + uv sync + #### Build the Web UI ------------------------------------ -FROM node:20-slim AS web-builder +FROM docker.io/node:20-slim AS web-builder ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack use pnpm@8.x @@ -97,26 +107,24 @@ RUN apt update && apt install -y --no-install-recommends \ apt-get clean && apt-get autoclean ENV INVOKEAI_SRC=/opt/invokeai -ENV VIRTUAL_ENV=/opt/venv 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 PATH="$INVOKEAI_SRC/.venv/bin:$INVOKEAI_SRC:$PATH" ENV CONTAINER_UID=${CONTAINER_UID:-1000} ENV CONTAINER_GID=${CONTAINER_GID:-1000} # 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.5.5 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.5.21 /uv /uvx /bin/ USER ubuntu RUN uv python install ${PYTHON_VERSION} USER root # --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 diff --git a/pyproject.toml b/pyproject.toml index 1a989e93f17..cc86a7e5ff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,8 +101,7 @@ dependencies = [ "xformers" = [ # Core generation dependencies, pinned for reproducible builds. "xformers>=0.0.28.post1; sys_platform!='darwin'", - # Auxiliary dependencies, pinned only if necessary. - "triton; sys_platform=='linux'", + # torch 2.4+cu carries its own triton dependency ] "onnx" = ["onnxruntime"] "onnx-cuda" = ["onnxruntime-gpu"] From 22362350dc2a7e93167a8f920164cb7288ae089b Mon Sep 17 00:00:00 2001 From: Kevin Turner <566360-keturn@users.noreply.gitlab.com> Date: Sun, 16 Feb 2025 11:26:06 -0800 Subject: [PATCH 002/102] chore(docker): revert to keeping venv in /opt/venv --- docker/Dockerfile | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a88688bb584..12f2c09a0fc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,13 +13,16 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ git # Install `uv` for package management -COPY --from=ghcr.io/astral-sh/uv:0.5.21 /uv /uvx /bin/ +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 @@ -27,8 +30,10 @@ ARG GPU_DRIVER=cuda ARG BUILDPLATFORM # Switch to the `ubuntu` user to work around dependency issues with uv-installed python -RUN mkdir -p ${INVOKEAI_SRC} && \ - chmod -R a+w /opt +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 @@ -107,24 +112,27 @@ RUN apt update && apt install -y --no-install-recommends \ apt-get clean && apt-get autoclean 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="$INVOKEAI_SRC/.venv/bin:$INVOKEAI_SRC:$PATH" +ENV PATH="$VIRTUAL_ENV/bin:$INVOKEAI_SRC:$PATH" ENV CONTAINER_UID=${CONTAINER_UID:-1000} ENV CONTAINER_GID=${CONTAINER_GID:-1000} # 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.5.21 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/ USER ubuntu RUN uv python install ${PYTHON_VERSION} USER root # --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 From 80d38c0e477e40dd1d6e6fc603914eb911bf93f3 Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Sun, 16 Feb 2025 12:31:14 -0800 Subject: [PATCH 003/102] chore(docker): include fewer files while installing dependencies including just invokeai/version seems sufficient to appease uv sync here. including everything else would invalidate the cache we're trying to establish. --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 12f2c09a0fc..bd10db84177 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -49,7 +49,7 @@ WORKDIR ${INVOKEAI_SRC} # 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,target=invokeai \ + --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 \ From ea2320c57b18eb54c8dfeca5f1e20c123d640d20 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 5 Mar 2025 06:44:18 +1000 Subject: [PATCH 004/102] feat(ui): add button ref image layer empty state to pull bbox --- invokeai/frontend/web/public/locales/en.json | 2 +- .../IPAdapter/IPAdapterSettingsEmptyState.tsx | 7 +++- ...nalGuidanceIPAdapterSettingsEmptyState.tsx | 35 ++++++++++--------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8e8c0c0298f..6985758b34a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1911,7 +1911,7 @@ "resetGenerationSettings": "Reset Generation Settings", "replaceCurrent": "Replace Current", "controlLayerEmptyState": "Upload an image, drag an image from the gallery onto this layer, or draw on the canvas to get started.", - "referenceImageEmptyState": "Upload an image or drag an image from the gallery onto this layer to get started.", + "referenceImageEmptyState": "Upload an image, drag an image from the gallery onto this layer, or pull the bounding box into this layer to get started.", "warnings": { "problemsFound": "Problems found", "unsupportedModel": "layer not supported for selected base model", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx index 4928cf7cb73..12b059e2924 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx @@ -2,6 +2,7 @@ import { Button, Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; @@ -27,6 +28,7 @@ export const IPAdapterSettingsEmptyState = memo(() => { const onClickGalleryButton = useCallback(() => { dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); + const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); const dndTargetData = useMemo( () => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }), @@ -41,8 +43,11 @@ export const IPAdapterSettingsEmptyState = memo(() => { GalleryButton: ( + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx similarity index 94% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx index 6398d1b7fca..1eb1a2e3c1a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx @@ -10,7 +10,7 @@ export const WorkflowThumbnailField = ({ onChange, }: { imageUrl: string | null; - onChange: (localImageUrl: string | null) => void; + onChange: (localThumbnailUrl: string | null) => void; }) => { const [thumbnail, setThumbnail] = useState(null); @@ -48,7 +48,8 @@ export const WorkflowThumbnailField = ({ const handleResetImage = useCallback(() => { setThumbnail(null); - }, []); + onChange(null); + }, [onChange]); const { getInputProps, getRootProps } = useDropzone({ accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, @@ -64,9 +65,8 @@ export const WorkflowThumbnailField = ({ src={URL.createObjectURL(thumbnail)} objectFit="cover" objectPosition="50% 50%" - w={65} - h={65} - minWidth={65} + w={100} + h={100} borderRadius="base" /> ) => { - state.thumbnail = action.payload; - state.isTouched = true; - }, workflowCategoryChanged: (state, action: PayloadAction) => { if (action.payload) { state.meta.category = action.payload; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts index 2dbeb5b1bef..53119c051e5 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts @@ -1,20 +1,13 @@ import type { ToastId } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; -import { - formFieldInitialValuesChanged, - selectWorkflowThumbnail, - workflowIDChanged, - workflowSaved, -} from 'features/nodes/store/workflowSlice'; +import { formFieldInitialValuesChanged, workflowIDChanged, workflowSaved } from 'features/nodes/store/workflowSlice'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues'; import { workflowUpdated } from 'features/workflowLibrary/store/actions'; import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import { useCreateWorkflowMutation, useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; import type { SetRequired } from 'type-fest'; @@ -33,7 +26,6 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const getFormFieldInitialValues = useGetFormFieldInitialValues(); - const thumbnail = useSelector(selectWorkflowThumbnail); const [updateWorkflow, updateWorkflowResult] = useUpdateWorkflowMutation(); const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); const toast = useToast(); @@ -50,13 +42,11 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { isClosable: false, }); try { - const blob = thumbnail ? await convertImageUrlToBlob(thumbnail) : null; - const image = blob ? new File([blob], 'thumbnail.png', { type: 'image/png' }) : null; if (isWorkflowWithID(workflow)) { - await updateWorkflow({ workflow, image }).unwrap(); + await updateWorkflow(workflow).unwrap(); dispatch(workflowUpdated()); } else { - const data = await createWorkflow({ workflow, image }).unwrap(); + const data = await createWorkflow(workflow).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); } dispatch(workflowSaved()); @@ -83,7 +73,7 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { toast.close(toastRef.current); } } - }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow, thumbnail]); + }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow]); return { saveWorkflow, isLoading: updateWorkflowResult.isLoading || createWorkflowResult.isLoading, diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts index 01710bb6577..2c18fcdd909 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts @@ -1,11 +1,9 @@ import type { ToastId } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; import { formFieldInitialValuesChanged, - selectWorkflowThumbnail, workflowCategoryChanged, workflowIDChanged, workflowNameChanged, @@ -16,7 +14,6 @@ import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/use import { newWorkflowSaved } from 'features/workflowLibrary/store/actions'; import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; type SaveWorkflowAsArg = { @@ -40,7 +37,6 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); const getFormFieldInitialValues = useGetFormFieldInitialValues(); - const thumbnail = useSelector(selectWorkflowThumbnail); const toast = useToast(); const toastRef = useRef(); const saveWorkflowAs = useCallback( @@ -59,10 +55,8 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { workflow.id = undefined; workflow.name = newName; workflow.meta.category = category; - const blob = thumbnail ? await convertImageUrlToBlob(thumbnail) : null; - const image = blob ? new File([blob], 'thumbnail.png', { type: 'image/png' }) : null; - const data = await createWorkflow({ workflow, image }).unwrap(); + const data = await createWorkflow(workflow).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); dispatch(workflowNameChanged(data.workflow.name)); dispatch(workflowCategoryChanged(data.workflow.meta.category)); @@ -92,7 +86,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { } } }, - [toast, t, createWorkflow, dispatch, getFormFieldInitialValues, thumbnail] + [toast, t, createWorkflow, dispatch, getFormFieldInitialValues] ); return { saveWorkflowAs, diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts index 5d6e204b73c..23d4763f856 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts @@ -1,7 +1,6 @@ import type { paths } from 'services/api/schema'; import { api, buildV1Url, LIST_TAG } from '..'; -import { Workflow, WorkflowWithoutID } from '../types'; /** * Builds an endpoint URL for the workflows router @@ -42,22 +41,13 @@ export const workflowsApi = api.injectEndpoints({ }), createWorkflow: build.mutation< paths['/api/v1/workflows/']['post']['responses']['200']['content']['application/json'], - { workflow: WorkflowWithoutID; image: File | null } + paths['/api/v1/workflows/']['post']['requestBody']['content']['application/json']['workflow'] >({ - query: ({ workflow, image }) => { - const formData = new FormData(); - if (image) { - formData.append('image', image); - } - - formData.append('workflow', JSON.stringify(workflow)); - - return { - url: buildWorkflowsUrl(), - method: 'POST', - body: formData, - }; - }, + query: (workflow) => ({ + url: buildWorkflowsUrl(), + method: 'POST', + body: { workflow }, + }), invalidatesTags: [ { type: 'Workflow', id: LIST_TAG }, { type: 'WorkflowsRecent', id: LIST_TAG }, @@ -65,22 +55,14 @@ export const workflowsApi = api.injectEndpoints({ }), updateWorkflow: build.mutation< paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'], - { workflow: Workflow; image: File | null } + paths['/api/v1/workflows/i/{workflow_id}']['patch']['requestBody']['content']['application/json']['workflow'] >({ - query: ({ workflow, image }) => { - const formData = new FormData(); - if (image) { - formData.append('image', image); - } - formData.append('workflow', JSON.stringify(workflow)); - - return { - url: buildWorkflowsUrl(`i/${workflow.id}`), - method: 'PATCH', - body: formData, - }; - }, - invalidatesTags: (response, error, { workflow }) => [ + query: (workflow) => ({ + url: buildWorkflowsUrl(`i/${workflow.id}`), + method: 'PATCH', + body: { workflow }, + }), + invalidatesTags: (response, error, workflow) => [ { type: 'WorkflowsRecent', id: LIST_TAG }, { type: 'Workflow', id: LIST_TAG }, { type: 'Workflow', id: workflow.id }, @@ -96,13 +78,41 @@ export const workflowsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }], }), + setWorkflowThumbnail: build.mutation({ + query: ({ workflow_id, image }) => { + const formData = new FormData(); + formData.append('image', image); + return { + url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`), + method: 'PUT', + body: formData, + }; + }, + invalidatesTags: (result, error, { workflow_id }) => [ + { type: 'Workflow', id: workflow_id }, + { type: 'WorkflowsRecent', id: LIST_TAG }, + ], + }), + deleteWorkflowThumbnail: build.mutation({ + query: (workflow_id) => ({ + url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`), + method: 'DELETE', + }), + invalidatesTags: (result, error, workflow_id) => [ + { type: 'Workflow', id: workflow_id }, + { type: 'WorkflowsRecent', id: LIST_TAG }, + ], + }), }), }); export const { useLazyGetWorkflowQuery, + useGetWorkflowQuery, useCreateWorkflowMutation, useDeleteWorkflowMutation, useUpdateWorkflowMutation, useListWorkflowsQuery, + useSetWorkflowThumbnailMutation, + useDeleteWorkflowThumbnailMutation, } = workflowsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b1401e37075..98bb7326f57 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1410,7 +1410,7 @@ export type paths = { patch?: never; trace?: never; }; - "/api/v1/workflows/{workflow_id}/thumbnail": { + "/api/v1/workflows/i/{workflow_id}/thumbnail": { parameters: { query?: never; header?: never; @@ -1418,33 +1418,17 @@ export type paths = { cookie?: never; }; get?: never; - put?: never; /** - * Upload Workflow Thumbnail - * @description Uploads a thumbnail for a workflow + * Set Workflow Thumbnail + * @description Sets a workflow's thumbnail image */ - post: operations["upload_workflow_thumbnail"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workflowsi/{workflow_id}/thumbnail": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; + put: operations["set_workflow_thumbnail"]; + post?: never; /** - * Get Workflow Thumbnail - * @description Gets the thumbnail for a workflow + * Delete Workflow Thumbnail + * @description Removes a workflow's thumbnail image */ - get: operations["get_workflow_thumbnail"]; - put?: never; - post?: never; - delete?: never; + delete: operations["delete_workflow_thumbnail"]; options?: never; head?: never; patch?: never; @@ -2251,16 +2235,8 @@ export type components = { }; /** Body_create_workflow */ Body_create_workflow: { - /** - * Workflow - * @description The workflow to create - */ - workflow: string; - /** - * Image - * @description The image file to upload - */ - image?: Blob | null; + /** @description The workflow to create */ + workflow: components["schemas"]["WorkflowWithoutID"]; }; /** Body_delete_images_from_list */ Body_delete_images_from_list: { @@ -2377,6 +2353,15 @@ export type components = { */ image_names: string[]; }; + /** Body_set_workflow_thumbnail */ + Body_set_workflow_thumbnail: { + /** + * Image + * Format: binary + * @description The image file to upload + */ + image: Blob; + }; /** Body_star_images_in_list */ Body_star_images_in_list: { /** @@ -2416,16 +2401,8 @@ export type components = { }; /** Body_update_workflow */ Body_update_workflow: { - /** - * Workflow - * @description The updated workflow - */ - workflow: string; - /** - * Image - * @description The image file to upload - */ - image?: Blob | null; + /** @description The updated workflow */ + workflow: components["schemas"]["Workflow"]; }; /** Body_upload_image */ Body_upload_image: { @@ -2437,15 +2414,6 @@ export type components = { /** @description The metadata to associate with the image */ metadata?: components["schemas"]["JsonValue"] | null; }; - /** Body_upload_workflow_thumbnail */ - Body_upload_workflow_thumbnail: { - /** - * File - * Format: binary - * @description The image file to upload - */ - file: Blob; - }; /** * Boolean Collection Primitive * @description A collection of boolean primitive values @@ -16367,8 +16335,8 @@ export type components = { /** Ui Order */ ui_order: number | null; }; - /** PaginatedResults[WorkflowRecordListItemDTO] */ - PaginatedResults_WorkflowRecordListItemDTO_: { + /** PaginatedResults[WorkflowRecordListItemWithThumbnailDTO] */ + PaginatedResults_WorkflowRecordListItemWithThumbnailDTO_: { /** * Page * @description Current Page @@ -16393,7 +16361,7 @@ export type components = { * Items * @description Items */ - items: components["schemas"]["WorkflowRecordListItemDTO"][]; + items: components["schemas"]["WorkflowRecordListItemWithThumbnailDTO"][]; }; /** * Pair Tile with Image @@ -21152,16 +21120,11 @@ export type components = { * @description The opened timestamp of the workflow. */ opened_at: string; - /** - * Thumbnail Url - * @description The URL of the workflow thumbnail. - */ - thumbnail_url?: string | null; /** @description The workflow. */ workflow: components["schemas"]["Workflow"]; }; - /** WorkflowRecordListItemDTO */ - WorkflowRecordListItemDTO: { + /** WorkflowRecordListItemWithThumbnailDTO */ + WorkflowRecordListItemWithThumbnailDTO: { /** * Workflow Id * @description The id of the workflow. @@ -21187,11 +21150,6 @@ export type components = { * @description The opened timestamp of the workflow. */ opened_at: string; - /** - * Thumbnail Url - * @description The URL of the workflow thumbnail. - */ - thumbnail_url?: string | null; /** * Description * @description The description of the workflow. @@ -21199,6 +21157,11 @@ export type components = { description: string; /** @description The description of the workflow. */ category: components["schemas"]["WorkflowCategory"]; + /** + * Thumbnail Url + * @description The URL of the workflow thumbnail. + */ + thumbnail_url?: string | null; }; /** * WorkflowRecordOrderBy @@ -21206,6 +21169,41 @@ export type components = { * @enum {string} */ WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name"; + /** WorkflowRecordWithThumbnailDTO */ + WorkflowRecordWithThumbnailDTO: { + /** + * Workflow Id + * @description The id of the workflow. + */ + workflow_id: string; + /** + * Name + * @description The name of the workflow. + */ + name: string; + /** + * Created At + * @description The created timestamp of the workflow. + */ + created_at: string; + /** + * Updated At + * @description The updated timestamp of the workflow. + */ + updated_at: string; + /** + * Opened At + * @description The opened timestamp of the workflow. + */ + opened_at: string; + /** @description The workflow. */ + workflow: components["schemas"]["Workflow"]; + /** + * Thumbnail Url + * @description The URL of the workflow thumbnail. + */ + thumbnail_url?: string | null; + }; /** WorkflowWithoutID */ WorkflowWithoutID: { /** @@ -24163,7 +24161,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowRecordDTO"]; + "application/json": components["schemas"]["WorkflowRecordWithThumbnailDTO"]; }; }; /** @description Validation Error */ @@ -24218,7 +24216,7 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_update_workflow"]; + "application/json": components["schemas"]["Body_update_workflow"]; }; }; responses: { @@ -24270,7 +24268,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedResults_WorkflowRecordListItemDTO_"]; + "application/json": components["schemas"]["PaginatedResults_WorkflowRecordListItemWithThumbnailDTO_"]; }; }; /** @description Validation Error */ @@ -24293,7 +24291,7 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_create_workflow"]; + "application/json": components["schemas"]["Body_create_workflow"]; }; }; responses: { @@ -24317,36 +24315,30 @@ export interface operations { }; }; }; - upload_workflow_thumbnail: { + set_workflow_thumbnail: { parameters: { query?: never; header?: never; path: { + /** @description The workflow to update */ workflow_id: string; }; cookie?: never; }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_upload_workflow_thumbnail"]; + "multipart/form-data": components["schemas"]["Body_set_workflow_thumbnail"]; }; }; responses: { - /** @description Thumbnail uploaded successfully */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": unknown; - }; - }; - /** @description Invalid image format */ - 415: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["WorkflowRecordDTO"]; }; - content?: never; }; /** @description Validation Error */ 422: { @@ -24359,32 +24351,26 @@ export interface operations { }; }; }; - get_workflow_thumbnail: { + delete_workflow_thumbnail: { parameters: { query?: never; header?: never; path: { + /** @description The workflow to update */ workflow_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Thumbnail retrieved successfully */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": unknown; - }; - }; - /** @description Thumbnail not found */ - 404: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["WorkflowRecordDTO"]; }; - content?: never; }; /** @description Validation Error */ 422: { diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 4f0ff8fd0b7..9d12fb159c2 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -20,9 +20,6 @@ export type UpdateBoardArg = paths['/api/v1/boards/{board_id}']['patch']['parame export type GraphAndWorkflowResponse = paths['/api/v1/images/i/{image_name}/workflow']['get']['responses']['200']['content']['application/json']; -export type WorkflowWithoutID = S['WorkflowWithoutID']; -export type Workflow = S['Workflow']; - export type BatchConfig = paths['/api/v1/queue/{queue_id}/enqueue_batch']['post']['requestBody']['content']['application/json']; From 9acb24914f7dd8bf34d67b51b7cc88e7b722de7d Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 24 Feb 2025 19:58:09 -0500 Subject: [PATCH 036/102] tsc fix --- .../sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx | 6 +++--- .../sidePanel/WorkflowListMenu/WorkflowListItem.tsx | 4 ++-- .../sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx | 4 ++-- .../DeleteLibraryWorkflowConfirmationAlertDialog.tsx | 6 +++--- invokeai/frontend/web/src/services/api/types.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx index 34a3eabd6c5..b88a877e3dd 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx @@ -21,13 +21,13 @@ import { atom } from 'nanostores'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCopyBold } from 'react-icons/pi'; -import type { WorkflowRecordListItemDTO } from 'services/api/types'; +import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; -const $workflowToShare = atom(null); +const $workflowToShare = atom(null); const clearWorkflowToShare = () => $workflowToShare.set(null); export const useShareWorkflow = () => { - const copyWorkflowLink = useCallback((workflow: WorkflowRecordListItemDTO) => { + const copyWorkflowLink = useCallback((workflow: WorkflowRecordListItemWithThumbnailDTO) => { $workflowToShare.set(workflow); }, []); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx index c2bfba35534..4d97e4e45b5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx @@ -11,12 +11,12 @@ import type { MouseEvent } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiDownloadSimpleBold, PiPencilBold, PiShareFatBold, PiTrashBold } from 'react-icons/pi'; -import type { WorkflowRecordListItemDTO } from 'services/api/types'; +import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; import { useShareWorkflow } from './ShareWorkflowModal'; import { WorkflowListItemTooltip } from './WorkflowListItemTooltip'; -export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemDTO }) => { +export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { const { t } = useTranslation(); const projectUrl = useStore($projectUrl); const [isHovered, setIsHovered] = useState(false); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx index 5b2fb752409..5b3f77a70f5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx @@ -1,9 +1,9 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import dateFormat, { masks } from 'dateformat'; import { useTranslation } from 'react-i18next'; -import type { WorkflowRecordListItemDTO } from 'services/api/types'; +import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; -export const WorkflowListItemTooltip = ({ workflow }: { workflow: WorkflowRecordListItemDTO }) => { +export const WorkflowListItemTooltip = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { const { t } = useTranslation(); return ( diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx index e5518e43e94..de46c659997 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx @@ -6,13 +6,13 @@ import { atom } from 'nanostores'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useDeleteWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; -import type { WorkflowRecordListItemDTO } from 'services/api/types'; +import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; -const $workflowToDelete = atom(null); +const $workflowToDelete = atom(null); const clearWorkflowToDelete = () => $workflowToDelete.set(null); export const useDeleteWorkflow = () => { - const deleteWorkflow = useCallback((workflow: WorkflowRecordListItemDTO) => { + const deleteWorkflow = useCallback((workflow: WorkflowRecordListItemWithThumbnailDTO) => { $workflowToDelete.set(workflow); }, []); diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 9d12fb159c2..ac33115ba05 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -263,7 +263,7 @@ export type Batch = S['Batch']; export type SessionQueueItemDTO = S['SessionQueueItemDTO']; export type WorkflowRecordOrderBy = S['WorkflowRecordOrderBy']; export type SQLiteDirection = S['SQLiteDirection']; -export type WorkflowRecordListItemDTO = S['WorkflowRecordListItemDTO']; +export type WorkflowRecordListItemWithThumbnailDTO = S['WorkflowRecordListItemWithThumbnailDTO']; type KeysOfUnion = T extends T ? keyof T : never; From b0593eda92160a1255b3079a5860eeb4e0daf001 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 24 Feb 2025 19:58:42 -0500 Subject: [PATCH 037/102] ruff --- invokeai/app/api/routers/workflows.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index fce98254a22..54d22e25eba 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -1,7 +1,8 @@ -from typing import Optional import io import traceback -from fastapi import APIRouter, Body, HTTPException, Path, Query, File, UploadFile +from typing import Optional + +from fastapi import APIRouter, Body, File, HTTPException, Path, Query, UploadFile from fastapi.responses import FileResponse from PIL import Image @@ -15,8 +16,8 @@ WorkflowRecordDTO, WorkflowRecordListItemWithThumbnailDTO, WorkflowRecordOrderBy, - WorkflowWithoutID, WorkflowRecordWithThumbnailDTO, + WorkflowWithoutID, ) IMAGE_MAX_AGE = 31536000 @@ -205,4 +206,4 @@ async def get_workflow_thumbnail( response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}" return response except Exception: - raise HTTPException(status_code=404) \ No newline at end of file + raise HTTPException(status_code=404) From a409aec00fb19fe2e0540721ea964593971bb2a7 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 24 Feb 2025 20:05:21 -0500 Subject: [PATCH 038/102] update schema --- .../frontend/web/src/services/api/schema.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 98bb7326f57..fedbba2938a 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1417,7 +1417,11 @@ export type paths = { path?: never; cookie?: never; }; - get?: never; + /** + * Get Workflow Thumbnail + * @description Gets a workflow's thumbnail image + */ + get: operations["get_workflow_thumbnail"]; /** * Set Workflow Thumbnail * @description Sets a workflow's thumbnail image @@ -24315,6 +24319,52 @@ export interface operations { }; }; }; + get_workflow_thumbnail: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the workflow thumbnail to get */ + workflow_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The workflow thumbnail was fetched successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The workflow thumbnail could not be found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; set_workflow_thumbnail: { parameters: { query?: never; From 17a5b1bd28b8414ed56c147aff0d56fcb721e24e Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 24 Feb 2025 20:06:04 -0500 Subject: [PATCH 039/102] fix test --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 80c66252c96..eb25b8f3329 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,7 @@ def mock_services() -> InvocationServices: conditioning=None, # type: ignore style_preset_records=None, # type: ignore style_preset_image_files=None, # type: ignore + workflow_thumbnail_image_records=None, # type: ignore ) From 201d7f1fdb7130c2660684bf180330a510350ffa Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 24 Feb 2025 20:23:24 -0500 Subject: [PATCH 040/102] fix test --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index eb25b8f3329..a5489888da0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,7 +58,7 @@ def mock_services() -> InvocationServices: conditioning=None, # type: ignore style_preset_records=None, # type: ignore style_preset_image_files=None, # type: ignore - workflow_thumbnail_image_records=None, # type: ignore + workflow_thumbnails=None, # type: ignore ) From 9da116fd3d970d60c5e3711d143ca6cad240a7e2 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 28 Feb 2025 15:55:12 -0500 Subject: [PATCH 041/102] how to only show thumbnail for saved non-default workflows --- .../components/sidePanel/workflow/WorkflowGeneralTab.tsx | 3 +++ .../workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx | 7 ++++++- .../workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx index 16b0f850d18..5618b9bd5ca 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx @@ -96,10 +96,13 @@ const WorkflowGeneralTab = () => { {t('nodes.workflowName')} + {/* we should not show this field if default workflow or not saved to DB yet */} + {/* {data?.meta.category !== 'default' || !data.workflow_id && ( */} {t('workflows.workflowThumbnail')} + {/* )} */} {t('nodes.workflowVersion')} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx index e926c0263ae..7bd9eb87d54 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx @@ -55,7 +55,12 @@ export const WorkflowThumbnailEditor = ({ - diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx index 1eb1a2e3c1a..dd1a84afee7 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx @@ -20,7 +20,7 @@ export const WorkflowThumbnailField = ({ try { const blob = await convertImageUrlToBlob(imageUrl); if (blob) { - file = new File([blob], 'style_preset.png', { type: 'image/png' }); + file = new File([blob], 'workflow.png', { type: 'image/png' }); } } catch (error) { // do nothing From c88b835373ec29a925d51361f25f49dc199b595c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 3 Mar 2025 09:03:35 +1000 Subject: [PATCH 042/102] fix(ui): remove unused redux action & selector --- invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index 8bad1d2d429..7dce3bfb26b 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -309,7 +309,6 @@ export const { formElementNodeFieldDataChanged, formElementContainerDataChanged, formFieldInitialValuesChanged, - workflowThumbnailChanged, } = workflowSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -368,7 +367,6 @@ export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workfl export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection); export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description); export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form); -export const selectWorkflowThumbnail = createWorkflowSelector((workflow) => workflow.thumbnail); export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => { const noNodes = !nodes.nodes.length; From aac456527e27316aabaad607818bea855526a80a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:29:46 +1000 Subject: [PATCH 043/102] refactor(ui): make workflow thumbnail rendering more explicit --- .../WorkflowThumbnailField.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx index dd1a84afee7..f27d97b8310 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx @@ -14,24 +14,25 @@ export const WorkflowThumbnailField = ({ }) => { const [thumbnail, setThumbnail] = useState(null); - const createThumbnailFromURL = useCallback(async () => { - let file = null; - if (imageUrl) { - try { - const blob = await convertImageUrlToBlob(imageUrl); - if (blob) { - file = new File([blob], 'workflow.png', { type: 'image/png' }); - } - } catch (error) { - // do nothing + const syncThumbnail = useCallback(async (imageUrl: string | null) => { + if (!imageUrl) { + setThumbnail(null); + return; + } + try { + const blob = await convertImageUrlToBlob(imageUrl); + if (blob) { + const file = new File([blob], 'workflow.png', { type: 'image/png' }); + setThumbnail(file); } + } catch (error) { + setThumbnail(null); } - return file; - }, [imageUrl]); + }, []); useEffect(() => { - createThumbnailFromURL().then(setThumbnail); - }, [createThumbnailFromURL]); + syncThumbnail(imageUrl); + }, [imageUrl, syncThumbnail]); const { t } = useTranslation(); From 79b2c688538c718d2134b2b15cda7420f7773b3b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 3 Mar 2025 12:49:33 +1000 Subject: [PATCH 044/102] fix(ui): hide workflow thumbnail for unsaved and default workflows --- .../sidePanel/workflow/WorkflowGeneralTab.tsx | 18 +++++++++++------- .../WorkflowThumbnailEditor.tsx | 8 ++------ .../WorkflowThumbnailField.tsx | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx index 5618b9bd5ca..c4103033d15 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx @@ -96,13 +96,17 @@ const WorkflowGeneralTab = () => { {t('nodes.workflowName')} - {/* we should not show this field if default workflow or not saved to DB yet */} - {/* {data?.meta.category !== 'default' || !data.workflow_id && ( */} - - {t('workflows.workflowThumbnail')} - - - {/* )} */} + {/* + * Only saved and non-default workflows can have a thumbnail. + * - Unsaved workflows have no id. + * - Default workflows have a category of 'default'. + */} + {id && data && data.workflow.meta.category !== 'default' && ( + + {t('workflows.workflowThumbnail')} + + + )} {t('nodes.workflowVersion')} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx index 7bd9eb87d54..b2694a7ae07 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx @@ -11,8 +11,8 @@ export const WorkflowThumbnailEditor = ({ workflowId, thumbnailUrl, }: { - workflowId?: string; - thumbnailUrl: string | null; + workflowId: string; + thumbnailUrl?: string | null; }) => { const { t } = useTranslation(); @@ -28,10 +28,6 @@ export const WorkflowThumbnailEditor = ({ }, []); const handleSaveChanges = useCallback(async () => { - if (!workflowId) { - return; - } - try { if (localThumbnailUrl) { const blob = await convertImageUrlToBlob(localThumbnailUrl); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx index f27d97b8310..34380849043 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx @@ -9,12 +9,12 @@ export const WorkflowThumbnailField = ({ imageUrl, onChange, }: { - imageUrl: string | null; + imageUrl?: string | null; onChange: (localThumbnailUrl: string | null) => void; }) => { const [thumbnail, setThumbnail] = useState(null); - const syncThumbnail = useCallback(async (imageUrl: string | null) => { + const syncThumbnail = useCallback(async (imageUrl?: string | null) => { if (!imageUrl) { setThumbnail(null); return; From 04b96dd7b447e3da8299692a48441e720f4bd475 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:19:02 +1000 Subject: [PATCH 045/102] feat(app): stable default workflows There was a bit of wonk with default workflows. On every app startup, we wiped them all out and recreated them with new IDs. This is a quick-and-dirty way to ensure default workflows are always in sync. Unfortunately, it also means default workflows are newly-created entities on every app load. Any thumbnails associated to them will be lost (bc they have new IDs), and `updated_at` doesn't work. This changes makes default workflows stable entities. The workflows we bundle in the python package in JSON format are still the source of truth for default workflows, but the startup logic that syncs them to the user DB is a bit smarter. - All bundled workflows have an ID. It is prefixed with "default_" for clarity. - Any default workflows in the user's DB that are not in the bundled default workflows are deleted from the DB. - Any bundled default workflows that are not in the user's DB are added to the DB. - If a default workflow in the user's DB does not match the content of its corresponding bundled workflow, it is updated in the DB. The end result is that default workflows are still kept in sync for the user, but they don't change their identity. We may now add thumbnails to default workflows, and sorting by `updated_at` is now meaningful. --- ...SRGAN Upscaling with Canny ControlNet.json | 1 + .../FLUX Image to Image.json | 1 + ...Adapter & Canny (See Note in Details).json | 3 +- .../default_workflows/Flux Text to Image.json | 1 + .../Multi ControlNet (Canny & Depth).json | 3 +- .../MultiDiffusion SD1.5.json | 4 +- .../MultiDiffusion SDXL.json | 4 +- .../default_workflows/Prompt from File.json | 1 + .../default_workflows/README.md | 1 + .../SD3.5 Text to Image.json | 706 +++++++++--------- .../Text to Image - SD1.5.json | 1 + .../Text to Image - SDXL.json | 1 + .../Text to Image with LoRA.json | 1 + .../Tiled Upscaling (Beta).json | 1 + .../workflow_records_sqlite.py | 85 ++- 15 files changed, 439 insertions(+), 375 deletions(-) diff --git a/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json index 2cadcae9617..13bd0c0bc6d 100644 --- a/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json +++ b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json @@ -1,4 +1,5 @@ { + "id": "default_686bb1d0-d086-4c70-9fa3-2f600b922023", "name": "ESRGAN Upscaling with Canny ControlNet", "author": "InvokeAI", "description": "Sample workflow for using Upscaling with ControlNet with SD1.5", diff --git a/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json index 1500f04af2b..68fbe9297b0 100644 --- a/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json +++ b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json @@ -1,4 +1,5 @@ { + "id": "default_cbf0e034-7b54-4b2c-b670-3b1e2e4b4a88", "name": "FLUX Image to Image", "author": "InvokeAI", "description": "A simple image-to-image workflow using a FLUX dev model. ", diff --git a/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json b/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json index 481ba85e64e..7a82829cf2e 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json +++ b/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json @@ -1,4 +1,5 @@ { + "id": "default_dec5a2e9-f59c-40d9-8869-a056751d79b8", "name": "Face Detailer with IP-Adapter & Canny (See Note in Details)", "author": "kosmoskatten", "description": "A workflow to add detail to and improve faces. This workflow is most effective when used with a model that creates realistic outputs. ", @@ -1445,4 +1446,4 @@ "targetHandle": "vae" } ] -} \ No newline at end of file +} diff --git a/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json index 0320cfd30dc..b62f9144784 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json +++ b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json @@ -1,4 +1,5 @@ { + "id": "default_444fe292-896b-44fd-bfc6-c0b5d220fffc", "name": "FLUX Text to Image", "author": "InvokeAI", "description": "A simple text-to-image workflow using FLUX dev or schnell models.", diff --git a/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json b/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json index 3ff99b5eb36..1cf14371327 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json +++ b/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json @@ -1,4 +1,5 @@ { + "id": "default_2d05e719-a6b9-4e64-9310-b875d3b2f9d2", "name": "Multi ControlNet (Canny & Depth)", "author": "InvokeAI", "description": "A sample workflow using canny & depth ControlNets to guide the generation process. ", @@ -1014,4 +1015,4 @@ "targetHandle": "image_resolution" } ] -} \ No newline at end of file +} diff --git a/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json index 7bc2810c75b..bf7b0ef0050 100644 --- a/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json +++ b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json @@ -1,4 +1,5 @@ { + "id": "default_f96e794f-eb3e-4d01-a960-9b4e43402bcf", "name": "MultiDiffusion SD1.5", "author": "Invoke", "description": "A workflow to upscale an input image with tiled upscaling, using SD1.5 based models.", @@ -52,7 +53,6 @@ "version": "3.0.0", "category": "default" }, - "id": "e5b5fb01-8906-463a-963a-402dbc42f79b", "nodes": [ { "id": "33fe76a0-5efd-4482-a7f0-e2abf1223dc2", @@ -1427,4 +1427,4 @@ "targetHandle": "noise" } ] -} \ No newline at end of file +} diff --git a/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json index 876ca6f8e6f..247fa6e476a 100644 --- a/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json +++ b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json @@ -1,4 +1,5 @@ { + "id": "default_35658541-6d41-4a20-8ec5-4bf2561faed0", "name": "MultiDiffusion SDXL", "author": "Invoke", "description": "A workflow to upscale an input image with tiled upscaling, using SDXL based models.", @@ -56,7 +57,6 @@ "version": "3.0.0", "category": "default" }, - "id": "dd607062-9e1b-48b9-89ad-9762cdfbb8f4", "nodes": [ { "id": "71a116e1-c631-48b3-923d-acea4753b887", @@ -1642,4 +1642,4 @@ "targetHandle": "noise" } ] -} \ No newline at end of file +} diff --git a/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json index de902bc77ee..74879565568 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json +++ b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json @@ -1,4 +1,5 @@ { + "id": "default_d7a1c60f-ca2f-4f90-9e33-75a826ca6d8f", "name": "Prompt from File", "author": "InvokeAI", "description": "Sample workflow using Prompt from File node", diff --git a/invokeai/app/services/workflow_records/default_workflows/README.md b/invokeai/app/services/workflow_records/default_workflows/README.md index 3901ead1cd5..d0b9f5225db 100644 --- a/invokeai/app/services/workflow_records/default_workflows/README.md +++ b/invokeai/app/services/workflow_records/default_workflows/README.md @@ -3,6 +3,7 @@ Workflows placed in this directory will be synced to the `workflow_library` as _default workflows_ on app startup. +- Default workflows must have an id that starts with "default\_". The ID must be retained when the workflow is updated. You may need to do this manually. - Default workflows are not editable by users. If they are loaded and saved, they will save as a copy of the default workflow. - Default workflows must have the `meta.category` property set to `"default"`. diff --git a/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json index 8d038283758..86a0aeeebc6 100644 --- a/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json +++ b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json @@ -1,382 +1,382 @@ { - "name": "SD3.5 Text to Image", - "author": "InvokeAI", - "description": "Sample text to image workflow for Stable Diffusion 3.5", - "version": "1.0.0", - "contact": "invoke@invoke.ai", - "tags": "text2image, SD3.5, default", + "id": "default_dbe46d95-22aa-43fb-9c16-94400d0ce2fd", + "name": "SD3.5 Text to Image", + "author": "InvokeAI", + "description": "Sample text to image workflow for Stable Diffusion 3.5", + "version": "1.0.0", + "contact": "invoke@invoke.ai", + "tags": "text2image, SD3.5, default", "notes": "", - "exposedFields": [ - { - "nodeId": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "fieldName": "model" - }, - { - "nodeId": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "fieldName": "prompt" - } - ], - "meta": { - "version": "3.0.0", - "category": "default" + "exposedFields": [ + { + "nodeId": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "fieldName": "model" }, - "id": "e3a51d6b-8208-4d6d-b187-fcfe8b32934c", - "nodes": [ - { + { + "nodeId": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", + "fieldName": "prompt" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "type": "invocation", + "data": { "id": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "type": "invocation", - "data": { - "id": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "type": "sd3_model_loader", - "version": "1.0.0", - "label": "", - "notes": "", - "isOpen": true, - "isIntermediate": true, - "useCache": true, - "nodePack": "invokeai", - "inputs": { - "model": { - "name": "model", - "label": "", - "value": { - "key": "f7b20be9-92a8-4cfb-bca4-6c3b5535c10b", - "hash": "placeholder", - "name": "stable-diffusion-3.5-medium", - "base": "sd-3", - "type": "main" - } - }, - "t5_encoder_model": { - "name": "t5_encoder_model", - "label": "" - }, - "clip_l_model": { - "name": "clip_l_model", - "label": "" - }, - "clip_g_model": { - "name": "clip_g_model", - "label": "" - }, - "vae_model": { - "name": "vae_model", - "label": "" + "type": "sd3_model_loader", + "version": "1.0.0", + "label": "", + "notes": "", + "isOpen": true, + "isIntermediate": true, + "useCache": true, + "nodePack": "invokeai", + "inputs": { + "model": { + "name": "model", + "label": "", + "value": { + "key": "f7b20be9-92a8-4cfb-bca4-6c3b5535c10b", + "hash": "placeholder", + "name": "stable-diffusion-3.5-medium", + "base": "sd-3", + "type": "main" } + }, + "t5_encoder_model": { + "name": "t5_encoder_model", + "label": "" + }, + "clip_l_model": { + "name": "clip_l_model", + "label": "" + }, + "clip_g_model": { + "name": "clip_g_model", + "label": "" + }, + "vae_model": { + "name": "vae_model", + "label": "" } - }, - "position": { - "x": -55.58689609637031, - "y": -111.53602444662268 } }, - { + "position": { + "x": -55.58689609637031, + "y": -111.53602444662268 + } + }, + { + "id": "f7e394ac-6394-4096-abcb-de0d346506b3", + "type": "invocation", + "data": { "id": "f7e394ac-6394-4096-abcb-de0d346506b3", - "type": "invocation", - "data": { - "id": "f7e394ac-6394-4096-abcb-de0d346506b3", - "type": "rand_int", - "version": "1.0.1", - "label": "", - "notes": "", - "isOpen": true, - "isIntermediate": true, - "useCache": false, - "nodePack": "invokeai", - "inputs": { - "low": { - "name": "low", - "label": "", - "value": 0 - }, - "high": { - "name": "high", - "label": "", - "value": 2147483647 - } + "type": "rand_int", + "version": "1.0.1", + "label": "", + "notes": "", + "isOpen": true, + "isIntermediate": true, + "useCache": false, + "nodePack": "invokeai", + "inputs": { + "low": { + "name": "low", + "label": "", + "value": 0 + }, + "high": { + "name": "high", + "label": "", + "value": 2147483647 } - }, - "position": { - "x": 470.45870147220353, - "y": 350.3141781644303 } }, - { + "position": { + "x": 470.45870147220353, + "y": 350.3141781644303 + } + }, + { + "id": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", + "type": "invocation", + "data": { "id": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", - "type": "invocation", - "data": { - "id": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", - "type": "sd3_l2i", - "version": "1.3.0", - "label": "", - "notes": "", - "isOpen": true, - "isIntermediate": false, - "useCache": true, - "nodePack": "invokeai", - "inputs": { - "board": { - "name": "board", - "label": "" - }, - "metadata": { - "name": "metadata", - "label": "" - }, - "latents": { - "name": "latents", - "label": "" - }, - "vae": { - "name": "vae", - "label": "" - } + "type": "sd3_l2i", + "version": "1.3.0", + "label": "", + "notes": "", + "isOpen": true, + "isIntermediate": false, + "useCache": true, + "nodePack": "invokeai", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "latents": { + "name": "latents", + "label": "" + }, + "vae": { + "name": "vae", + "label": "" } - }, - "position": { - "x": 1192.3097009334897, - "y": -366.0994675072209 } }, - { + "position": { + "x": 1192.3097009334897, + "y": -366.0994675072209 + } + }, + { + "id": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", + "type": "invocation", + "data": { "id": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", - "type": "invocation", - "data": { - "id": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", - "type": "sd3_text_encoder", - "version": "1.0.0", - "label": "", - "notes": "", - "isOpen": true, - "isIntermediate": true, - "useCache": true, - "nodePack": "invokeai", - "inputs": { - "clip_l": { - "name": "clip_l", - "label": "" - }, - "clip_g": { - "name": "clip_g", - "label": "" - }, - "t5_encoder": { - "name": "t5_encoder", - "label": "" - }, - "prompt": { - "name": "prompt", - "label": "", - "value": "" - } + "type": "sd3_text_encoder", + "version": "1.0.0", + "label": "", + "notes": "", + "isOpen": true, + "isIntermediate": true, + "useCache": true, + "nodePack": "invokeai", + "inputs": { + "clip_l": { + "name": "clip_l", + "label": "" + }, + "clip_g": { + "name": "clip_g", + "label": "" + }, + "t5_encoder": { + "name": "t5_encoder", + "label": "" + }, + "prompt": { + "name": "prompt", + "label": "", + "value": "" } - }, - "position": { - "x": 408.16054647924784, - "y": 65.06415352118786 } }, - { + "position": { + "x": 408.16054647924784, + "y": 65.06415352118786 + } + }, + { + "id": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", + "type": "invocation", + "data": { "id": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "type": "invocation", - "data": { - "id": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "type": "sd3_text_encoder", - "version": "1.0.0", - "label": "", - "notes": "", - "isOpen": true, - "isIntermediate": true, - "useCache": true, - "nodePack": "invokeai", - "inputs": { - "clip_l": { - "name": "clip_l", - "label": "" - }, - "clip_g": { - "name": "clip_g", - "label": "" - }, - "t5_encoder": { - "name": "t5_encoder", - "label": "" - }, - "prompt": { - "name": "prompt", - "label": "", - "value": "" - } + "type": "sd3_text_encoder", + "version": "1.0.0", + "label": "", + "notes": "", + "isOpen": true, + "isIntermediate": true, + "useCache": true, + "nodePack": "invokeai", + "inputs": { + "clip_l": { + "name": "clip_l", + "label": "" + }, + "clip_g": { + "name": "clip_g", + "label": "" + }, + "t5_encoder": { + "name": "t5_encoder", + "label": "" + }, + "prompt": { + "name": "prompt", + "label": "", + "value": "" } - }, - "position": { - "x": 378.9283412440941, - "y": -302.65777497352553 } }, - { + "position": { + "x": 378.9283412440941, + "y": -302.65777497352553 + } + }, + { + "id": "c7539f7b-7ac5-49b9-93eb-87ede611409f", + "type": "invocation", + "data": { "id": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "type": "invocation", - "data": { - "id": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "type": "sd3_denoise", - "version": "1.0.0", - "label": "", - "notes": "", - "isOpen": true, - "isIntermediate": true, - "useCache": true, - "nodePack": "invokeai", - "inputs": { - "board": { - "name": "board", - "label": "" - }, - "metadata": { - "name": "metadata", - "label": "" - }, - "transformer": { - "name": "transformer", - "label": "" - }, - "positive_conditioning": { - "name": "positive_conditioning", - "label": "" - }, - "negative_conditioning": { - "name": "negative_conditioning", - "label": "" - }, - "cfg_scale": { - "name": "cfg_scale", - "label": "", - "value": 3.5 - }, - "width": { - "name": "width", - "label": "", - "value": 1024 - }, - "height": { - "name": "height", - "label": "", - "value": 1024 - }, - "steps": { - "name": "steps", - "label": "", - "value": 30 - }, - "seed": { - "name": "seed", - "label": "", - "value": 0 - } + "type": "sd3_denoise", + "version": "1.0.0", + "label": "", + "notes": "", + "isOpen": true, + "isIntermediate": true, + "useCache": true, + "nodePack": "invokeai", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "transformer": { + "name": "transformer", + "label": "" + }, + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 3.5 + }, + "width": { + "name": "width", + "label": "", + "value": 1024 + }, + "height": { + "name": "height", + "label": "", + "value": 1024 + }, + "steps": { + "name": "steps", + "label": "", + "value": 30 + }, + "seed": { + "name": "seed", + "label": "", + "value": 0 } - }, - "position": { - "x": 813.7814762740603, - "y": -142.20529727605867 } - } - ], - "edges": [ - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cvae-9eb72af0-dd9e-4ec5-ad87-d65e3c01f48bvae", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", - "sourceHandle": "vae", - "targetHandle": "vae" - }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ct5_encoder-3b4f7f27-cfc0-4373-a009-99c5290d0cd6t5_encoder", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", - "sourceHandle": "t5_encoder", - "targetHandle": "t5_encoder" - }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ct5_encoder-e17d34e7-6ed1-493c-9a85-4fcd291cb084t5_encoder", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "sourceHandle": "t5_encoder", - "targetHandle": "t5_encoder" }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_g-3b4f7f27-cfc0-4373-a009-99c5290d0cd6clip_g", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", - "sourceHandle": "clip_g", - "targetHandle": "clip_g" - }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_g-e17d34e7-6ed1-493c-9a85-4fcd291cb084clip_g", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "sourceHandle": "clip_g", - "targetHandle": "clip_g" - }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_l-3b4f7f27-cfc0-4373-a009-99c5290d0cd6clip_l", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", - "sourceHandle": "clip_l", - "targetHandle": "clip_l" - }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_l-e17d34e7-6ed1-493c-9a85-4fcd291cb084clip_l", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "sourceHandle": "clip_l", - "targetHandle": "clip_l" - }, - { - "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ctransformer-c7539f7b-7ac5-49b9-93eb-87ede611409ftransformer", - "type": "default", - "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", - "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "sourceHandle": "transformer", - "targetHandle": "transformer" - }, - { - "id": "reactflow__edge-f7e394ac-6394-4096-abcb-de0d346506b3value-c7539f7b-7ac5-49b9-93eb-87ede611409fseed", - "type": "default", - "source": "f7e394ac-6394-4096-abcb-de0d346506b3", - "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "sourceHandle": "value", - "targetHandle": "seed" - }, - { - "id": "reactflow__edge-c7539f7b-7ac5-49b9-93eb-87ede611409flatents-9eb72af0-dd9e-4ec5-ad87-d65e3c01f48blatents", - "type": "default", - "source": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "target": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", - "sourceHandle": "latents", - "targetHandle": "latents" - }, - { - "id": "reactflow__edge-e17d34e7-6ed1-493c-9a85-4fcd291cb084conditioning-c7539f7b-7ac5-49b9-93eb-87ede611409fpositive_conditioning", - "type": "default", - "source": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", - "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "sourceHandle": "conditioning", - "targetHandle": "positive_conditioning" - }, - { - "id": "reactflow__edge-3b4f7f27-cfc0-4373-a009-99c5290d0cd6conditioning-c7539f7b-7ac5-49b9-93eb-87ede611409fnegative_conditioning", - "type": "default", - "source": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", - "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", - "sourceHandle": "conditioning", - "targetHandle": "negative_conditioning" + "position": { + "x": 813.7814762740603, + "y": -142.20529727605867 } - ] - } \ No newline at end of file + } + ], + "edges": [ + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cvae-9eb72af0-dd9e-4ec5-ad87-d65e3c01f48bvae", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ct5_encoder-3b4f7f27-cfc0-4373-a009-99c5290d0cd6t5_encoder", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", + "sourceHandle": "t5_encoder", + "targetHandle": "t5_encoder" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ct5_encoder-e17d34e7-6ed1-493c-9a85-4fcd291cb084t5_encoder", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", + "sourceHandle": "t5_encoder", + "targetHandle": "t5_encoder" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_g-3b4f7f27-cfc0-4373-a009-99c5290d0cd6clip_g", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", + "sourceHandle": "clip_g", + "targetHandle": "clip_g" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_g-e17d34e7-6ed1-493c-9a85-4fcd291cb084clip_g", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", + "sourceHandle": "clip_g", + "targetHandle": "clip_g" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_l-3b4f7f27-cfc0-4373-a009-99c5290d0cd6clip_l", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", + "sourceHandle": "clip_l", + "targetHandle": "clip_l" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_l-e17d34e7-6ed1-493c-9a85-4fcd291cb084clip_l", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", + "sourceHandle": "clip_l", + "targetHandle": "clip_l" + }, + { + "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ctransformer-c7539f7b-7ac5-49b9-93eb-87ede611409ftransformer", + "type": "default", + "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c", + "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", + "sourceHandle": "transformer", + "targetHandle": "transformer" + }, + { + "id": "reactflow__edge-f7e394ac-6394-4096-abcb-de0d346506b3value-c7539f7b-7ac5-49b9-93eb-87ede611409fseed", + "type": "default", + "source": "f7e394ac-6394-4096-abcb-de0d346506b3", + "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-c7539f7b-7ac5-49b9-93eb-87ede611409flatents-9eb72af0-dd9e-4ec5-ad87-d65e3c01f48blatents", + "type": "default", + "source": "c7539f7b-7ac5-49b9-93eb-87ede611409f", + "target": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-e17d34e7-6ed1-493c-9a85-4fcd291cb084conditioning-c7539f7b-7ac5-49b9-93eb-87ede611409fpositive_conditioning", + "type": "default", + "source": "e17d34e7-6ed1-493c-9a85-4fcd291cb084", + "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-3b4f7f27-cfc0-4373-a009-99c5290d0cd6conditioning-c7539f7b-7ac5-49b9-93eb-87ede611409fnegative_conditioning", + "type": "default", + "source": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6", + "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + } + ] +} diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json index 65f894724c7..ef2fd5c2ffe 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json @@ -1,4 +1,5 @@ { + "id": "default_7dde3e36-d78f-4152-9eea-00ef9c8124ed", "name": "Text to Image - SD1.5", "author": "InvokeAI", "description": "Sample text to image workflow for Stable Diffusion 1.5/2", diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json index 0f4777169e2..3821270f4c1 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json @@ -1,4 +1,5 @@ { + "id": "default_5e8b008d-c697-45d0-8883-085a954c6ace", "name": "Text to Image - SDXL", "author": "InvokeAI", "description": "Sample text to image workflow for SDXL", diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json index b4df4b921ce..0963f4dff01 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json @@ -1,4 +1,5 @@ { + "id": "default_e71d153c-2089-43c7-bd2c-f61f37d4c1c1", "name": "Text to Image with LoRA", "author": "InvokeAI", "description": "Simple text to image workflow with a LoRA", diff --git a/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json b/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json index 426fe49c419..f8de71ad843 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json +++ b/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json @@ -1,4 +1,5 @@ { + "id": "default_43b0d7f7-6a12-4dcf-a5a4-50c940cbee29", "name": "Tiled Upscaling (Beta)", "author": "Invoke", "description": "A workflow to upscale an input image with tiled upscaling. ", diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index b2986c430a8..b4f7e9c01b9 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -14,8 +14,8 @@ WorkflowRecordListItemDTO, WorkflowRecordListItemDTOValidator, WorkflowRecordOrderBy, + WorkflowValidator, WorkflowWithoutID, - WorkflowWithoutIDValidator, ) from invokeai.app.util.misc import uuid_string @@ -187,27 +187,68 @@ def _sync_default_workflows(self) -> None: """ try: - workflows: list[Workflow] = [] + cursor = self._conn.cursor() + workflows_from_file: list[Workflow] = [] + workflows_to_update: list[Workflow] = [] + workflows_to_add: list[Workflow] = [] workflows_dir = Path(__file__).parent / Path("default_workflows") workflow_paths = workflows_dir.glob("*.json") for path in workflow_paths: bytes_ = path.read_bytes() - workflow_without_id = WorkflowWithoutIDValidator.validate_json(bytes_) - workflow = Workflow(**workflow_without_id.model_dump(), id=uuid_string()) - workflows.append(workflow) - # Only default workflows may be managed by this method - assert all(w.meta.category is WorkflowCategory.Default for w in workflows) - cursor = self._conn.cursor() - cursor.execute( - """--sql - DELETE FROM workflow_library - WHERE category = 'default'; - """ - ) - for w in workflows: + workflow_from_file = WorkflowValidator.validate_json(bytes_) + + assert workflow_from_file.id.startswith( + "default_" + ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + + assert ( + workflow_from_file.meta.category is WorkflowCategory.Default + ), f"Invalid default workflow category: {workflow_from_file.meta.category}" + + workflows_from_file.append(workflow_from_file) + + try: + workflow_from_db = self.get(workflow_from_file.id).workflow + if workflow_from_file != workflow_from_db: + self._invoker.services.logger.debug( + f"Updating library workflow {workflow_from_file.name} ({workflow_from_file.id})" + ) + workflows_to_update.append(workflow_from_file) + continue + except WorkflowNotFoundError: + self._invoker.services.logger.debug( + f"Adding missing default workflow {workflow_from_file.name} ({workflow_from_file.id})" + ) + workflows_to_add.append(workflow_from_file) + continue + + library_workflows_from_db = self.get_many( + order_by=WorkflowRecordOrderBy.Name, + direction=SQLiteDirection.Ascending, + category=WorkflowCategory.Default, + ).items + + workflows_from_file_ids = [w.id for w in workflows_from_file] + + for w in library_workflows_from_db: + if w.workflow_id not in workflows_from_file_ids: + self._invoker.services.logger.debug( + f"Deleting obsolete default workflow {w.name} ({w.workflow_id})" + ) + # We cannot use the `delete` method here, as it only deletes non-default workflows + cursor.execute( + """--sql + DELETE from workflow_library + WHERE workflow_id = ?; + """, + (w.workflow_id,), + ) + + for w in workflows_to_add: + # We cannot use the `create` method here, as it only creates non-default workflows cursor.execute( """--sql - INSERT OR REPLACE INTO workflow_library ( + INSERT INTO workflow_library ( workflow_id, workflow ) @@ -215,6 +256,18 @@ def _sync_default_workflows(self) -> None: """, (w.id, w.model_dump_json()), ) + + for w in workflows_to_update: + # We cannot use the `update` method here, as it only updates non-default workflows + cursor.execute( + """--sql + UPDATE workflow_library + SET workflow = ? + WHERE workflow_id = ?; + """, + (w.model_dump_json(), w.id), + ) + self._conn.commit() except Exception: self._conn.rollback() From bf209663acadb695fa95787e16018bed970e34d2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 3 Mar 2025 15:37:10 +1000 Subject: [PATCH 046/102] tidy(app): make workflow thumbnails base class an ABC, move it to own file --- .../workflow_thumbnails_base.py | 28 +++++++++++++++++++ .../workflow_thumbnails_common.py | 25 ----------------- .../workflow_thumbnails_disk.py | 2 +- 3 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py new file mode 100644 index 00000000000..f2674dd3b7c --- /dev/null +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from PIL import Image + + +class WorkflowThumbnailServiceBase(ABC): + """Base class for workflow thumbnail services""" + + @abstractmethod + def get_path(self, workflow_id: str) -> Path: + """Gets the path to a workflow thumbnail""" + pass + + @abstractmethod + def get_url(self, workflow_id: str) -> str | None: + """Gets the URL of a workflow thumbnail""" + pass + + @abstractmethod + def save(self, workflow_id: str, image: Image.Image) -> None: + """Saves a workflow thumbnail""" + pass + + @abstractmethod + def delete(self, workflow_id: str) -> None: + """Deletes a workflow thumbnail""" + pass diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py index 25174fdca4e..8d124adec33 100644 --- a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py @@ -1,8 +1,3 @@ -from pathlib import Path - -from PIL import Image - - class WorkflowThumbnailFileNotFoundException(Exception): """Raised when a workflow thumbnail file is not found""" @@ -25,23 +20,3 @@ class WorkflowThumbnailFileDeleteException(Exception): def __init__(self, message: str = "Workflow thumbnail file cannot be deleted"): self.message = message super().__init__(self.message) - - -class WorkflowThumbnailServiceBase: - """Base class for workflow thumbnail services""" - - def get_path(self, workflow_id: str) -> Path: - """Gets the path to a workflow thumbnail""" - raise NotImplementedError - - def get_url(self, workflow_id: str) -> str | None: - """Gets the URL of a workflow thumbnail""" - raise NotImplementedError - - def save(self, workflow_id: str, image: Image.Image) -> None: - """Saves a workflow thumbnail""" - raise NotImplementedError - - def delete(self, workflow_id: str) -> None: - """Deletes a workflow thumbnail""" - raise NotImplementedError diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py index 39e53e29a2f..4e10456e264 100644 --- a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py @@ -4,11 +4,11 @@ from PIL.Image import Image as PILImageType from invokeai.app.services.invoker import Invoker +from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import ( WorkflowThumbnailFileDeleteException, WorkflowThumbnailFileNotFoundException, WorkflowThumbnailFileSaveException, - WorkflowThumbnailServiceBase, ) from invokeai.app.util.misc import uuid_string from invokeai.app.util.thumbnails import make_thumbnail From 1353c3301a7c1521fc75ca992f828c0d713fa4db Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 3 Mar 2025 15:38:42 +1000 Subject: [PATCH 047/102] typo(app): style_preset_id -> workflow_id --- .../services/workflow_thumbnails/workflow_thumbnails_disk.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py index 4e10456e264..a35625c35e7 100644 --- a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py @@ -22,9 +22,9 @@ def __init__(self, thumbnails_path: Path): def start(self, invoker: Invoker) -> None: self._invoker = invoker - def get(self, style_preset_id: str) -> PILImageType: + def get(self, workflow_id: str) -> PILImageType: try: - path = self.get_path(style_preset_id) + path = self.get_path(workflow_id) return Image.open(path) except FileNotFoundError as e: From 76e2f41ec7e60b8fa34217e3f70a645adfa3f55c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 09:09:09 +1000 Subject: [PATCH 048/102] feat(app): throw as early as possible when attempting to create, update or delete a default workflow --- .../workflow_records/workflow_records_sqlite.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index b4f7e9c01b9..702001dc798 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -55,9 +55,10 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO: return WorkflowRecordDTO.from_dict(dict(row)) def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO: + if workflow.meta.category is WorkflowCategory.Default: + raise ValueError("Default workflows cannot be created via this method") + try: - # Only user workflows may be created by this method - assert workflow.meta.category is WorkflowCategory.User workflow_with_id = Workflow(**workflow.model_dump(), id=uuid_string()) cursor = self._conn.cursor() cursor.execute( @@ -77,6 +78,9 @@ def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO: return self.get(workflow_with_id.id) def update(self, workflow: Workflow) -> WorkflowRecordDTO: + if workflow.meta.category is WorkflowCategory.Default: + raise ValueError("Default workflows cannot be updated") + try: cursor = self._conn.cursor() cursor.execute( @@ -94,6 +98,9 @@ def update(self, workflow: Workflow) -> WorkflowRecordDTO: return self.get(workflow.id) def delete(self, workflow_id: str) -> None: + if self.get(workflow_id).workflow.meta.category is WorkflowCategory.Default: + raise ValueError("Default workflows cannot be deleted") + try: cursor = self._conn.cursor() cursor.execute( From 9aa04f0bea6b6b570da2febc6729d06dc64f4f35 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 09:09:59 +1000 Subject: [PATCH 049/102] feat(app): support thumbnails for default workflow images --- .../workflow_thumbnails/workflow_thumbnails_disk.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py index a35625c35e7..07c18e19eb5 100644 --- a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py @@ -4,6 +4,7 @@ from PIL.Image import Image as PILImageType from invokeai.app.services.invoker import Invoker +from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import ( WorkflowThumbnailFileDeleteException, @@ -41,7 +42,12 @@ def save(self, workflow_id: str, image: PILImageType) -> None: raise WorkflowThumbnailFileSaveException from e def get_path(self, workflow_id: str) -> Path: - path = self._workflow_thumbnail_folder / (workflow_id + ".webp") + workflow = self._invoker.services.workflow_records.get(workflow_id).workflow + if workflow.meta.category is WorkflowCategory.Default: + default_thumbnails_dir = Path(__file__).parent / Path("default_workflow_thumbnails") + path = default_thumbnails_dir / (workflow_id + ".png") + else: + path = self._workflow_thumbnail_folder / (workflow_id + ".webp") return path From 0eb237ac64976ed31d25b07a6c25ecdfedc5210c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:26:30 +1000 Subject: [PATCH 050/102] feat(app): make category required on workflows It's only by misunderstanding the pydantic API that this field was is typed as optional. Workflows must _always_ have a category, and indeed they do. Fixing this allows the generated types in the frontend to be easier to work with.. --- .../app/services/workflow_records/workflow_records_common.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py index 28b24271237..644068b1db3 100644 --- a/invokeai/app/services/workflow_records/workflow_records_common.py +++ b/invokeai/app/services/workflow_records/workflow_records_common.py @@ -36,9 +36,7 @@ class WorkflowCategory(str, Enum, metaclass=MetaEnum): class WorkflowMeta(BaseModel): version: str = Field(description="The version of the workflow schema.") - category: WorkflowCategory = Field( - default=WorkflowCategory.User, description="The category of the workflow (user or default)." - ) + category: WorkflowCategory = Field(description="The category of the workflow (user or default).") @field_validator("version") def validate_version(cls, version: str): From 8c0ee9c48f407c03693ac30c6cc1b5b107bd24ad Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:27:12 +1000 Subject: [PATCH 051/102] fix(app): fix import of WorkflowThumbnailServiceBase --- invokeai/app/services/invocation_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 0b22f5feffa..933c57b4a08 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -32,7 +32,7 @@ from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase from invokeai.app.services.urls.urls_base import UrlServiceBase from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase - from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import WorkflowThumbnailServiceBase + from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData From 445f122f37a0af11ecd2a0b41fba56b9acb0ae13 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:28:30 +1000 Subject: [PATCH 052/102] fix(api): allow deleting a workflow even if the thumbnail file doesn't exist --- invokeai/app/api/routers/workflows.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 54d22e25eba..07f7b9ad0ca 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -19,6 +19,7 @@ WorkflowRecordWithThumbnailDTO, WorkflowWithoutID, ) +from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import WorkflowThumbnailFileNotFoundException IMAGE_MAX_AGE = 31536000 workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"]) @@ -65,7 +66,11 @@ async def delete_workflow( workflow_id: str = Path(description="The workflow to delete"), ) -> None: """Deletes a workflow""" - ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id) + try: + ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id) + except WorkflowThumbnailFileNotFoundException: + # It's OK if the workflow has no thumbnail file. We can still delete the workflow. + pass ApiDependencies.invoker.services.workflow_records.delete(workflow_id) From 3c2e6378cacfefd7f698ce474c3a5ee3e0ecbaf0 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 12:29:30 +1000 Subject: [PATCH 053/102] chore(ui): typegen --- invokeai/frontend/web/src/services/api/schema.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index fedbba2938a..926381bde93 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -21091,11 +21091,8 @@ export type components = { * @description The version of the workflow schema. */ version: string; - /** - * @description The category of the workflow (user or default). - * @default user - */ - category?: components["schemas"]["WorkflowCategory"]; + /** @description The category of the workflow (user or default). */ + category: components["schemas"]["WorkflowCategory"]; }; /** WorkflowRecordDTO */ WorkflowRecordDTO: { From 07d65b8fd12089321da1ad8ec8c5b8ad478660c8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:08:39 +1000 Subject: [PATCH 054/102] refactor(ui): workflow loading, saving and saved status tracking This big chungus reworks and simplifies much of the logic around loading and saving workflows. It also makes some minor changes to how store the current workflow and determine if it is a draft, user workflow or default workflow. --- The lower-level hooks to save a workflow have been revised: - `useSaveLibraryWorkflow`: Saves a user or project workflow that has had changes made to it. - `useCreateNewWorkflow`: Saves a workflow as a new entity. A new higher-level hook `useSaveOrSaveAsWorkflow` is intended to be used by components. It returns a single function that: - Constructs the workflow payload to be sent to the server - Checks if the workflow is an existing user workflow. If so, it immediately saves (updates) that workflow. - If it's not an existing user workflow, it opens the save as dialog so the user can choose a name for it and create a new workflow. This occurs for both draft workflows and loaded default workflows. --- The logic to build the current redux state into a workflow - either to be saved as JSON, to update an existing user workflow, or save as - was a bit convoluted. Changes to redux state triggered a debounced function to build the workflow, setting it in a global nanostores atom. Then, all of the functions that consumed the "built workflow" referenced this atom. Now, this logic is strictly imperative. When a consumer wants to save a workflow, we build it on the spot. This removes a layer of indirection. The logic is in the `useBuildWorkflowFast` hook. --- The logic for loading a workflow is also revised. Previously, it happened in an RTK listener. You'd need to dispatch an action to load a workflow, and wouldn't know if it succeeded or not (though the listener would make a toast if the load failed). This is now done in a callback, outside redux middleware. The callback is returned from the `useLoadWorkflow` hook. --- Previously, we stripped the id from default workflows when loading them. Then, when saving the workflow, we built a workflow object from redux state and hit the API with it. This has two issues: - It relies on redux state never having an ID set when a default workflow is loaded. If we somehow ended up with a default workflow's ID in redux, when we go to save the workflow, we'd get and error or it wouldn't work, because you cannot save a default workflow. You can only save-as it. - We do not know the default workflow from which the current workflow was loaded. And be cause we don't know the default workflow, we cannot show a thumbnail image. The responsibilities have been shifted around a bit. Now, when we load a workflow, we load it as-is. The default workflow IDs are saved in redux state. We can render the thumbnail, and if the user goes to save the workflow, we detect that it is a default workflow and save-as it. --- In `App.tsx`, the long list of modals are moved into their own "isolator" component to ensure any re-renders there do not affect the rest of the app. --- The save-workflow-as modal is restructured to be a bit simpler. Still works the same. On commercial, "save to project" will be enabled by default. --- The workflow JSON tab uses a debounced version of "buildWorkflow" to build the workflow as JSON. --- `buildWorkflowFast` is updated to deep-copy its _whole_ output, preventing issues where field types could accidentally get mutated. I don't think this has ever happened but we may as well be safe. --- Fixed an issue where the edit button in the workflow list didn't open the workflow in edit mode. --- .../frontend/web/src/app/components/App.tsx | 57 +++--- .../middleware/listenerMiddleware/index.ts | 2 - .../features/nodes/components/NodeEditor.tsx | 4 - .../features/nodes/components/flow/Flow.tsx | 2 - .../panels/TopPanel/SaveWorkflowButton.tsx | 24 +-- .../WorkflowListMenu/SaveWorkflowButton.tsx | 30 +--- .../WorkflowListMenu/WorkflowListItem.tsx | 24 ++- .../sidePanel/workflow/WorkflowGeneralTab.tsx | 58 ++++-- .../sidePanel/workflow/WorkflowJSONTab.tsx | 42 ++++- .../nodes/hooks/useWorkflowWatcher.ts | 30 ---- .../web/src/features/nodes/store/actions.ts | 7 +- .../nodes/util/workflow/buildWorkflow.ts | 65 ++++--- .../nodes/util/workflow/validateWorkflow.ts | 7 - .../web/src/features/ui/store/uiSlice.ts | 4 +- .../LoadWorkflowFromGraphModal.tsx | 9 +- .../components/SaveWorkflowAsDialog.tsx | 169 ++++++++++++++++++ .../SaveWorkflowAsDialog.tsx | 101 ----------- .../useSaveWorkflowAsDialog.ts | 52 ------ .../components/UploadWorkflowButton.tsx | 8 +- .../SaveWorkflowAsMenuItem.tsx | 19 +- .../SaveWorkflowMenuItem.tsx | 24 +-- ...eWorkflowAs.ts => useCreateNewWorkflow.ts} | 62 ++++--- .../useDownloadCurrentlyLoadedWorkflow.ts | 11 +- .../hooks/useGetAndLoadEmbeddedWorkflow.ts | 9 +- .../hooks/useGetAndLoadLibraryWorkflow.ts | 9 +- .../workflowLibrary/hooks/useLoadWorkflow.ts} | 37 ++-- .../hooks/useLoadWorkflowFromFile.tsx | 28 ++- .../hooks/useSaveLibraryWorkflow.ts | 79 ++++++++ .../hooks/useSaveOrSaveAsWorkflow.ts | 24 +++ .../workflowLibrary/hooks/useSaveWorkflow.ts | 82 --------- .../util/getWorkflowCopyName.ts | 1 - 31 files changed, 549 insertions(+), 531 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useWorkflowWatcher.ts create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx delete mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx delete mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts rename invokeai/frontend/web/src/features/workflowLibrary/hooks/{useSaveWorkflowAs.ts => useCreateNewWorkflow.ts} (63%) rename invokeai/frontend/web/src/{app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts => features/workflowLibrary/hooks/useLoadWorkflow.ts} (82%) create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveLibraryWorkflow.ts create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts delete mode 100644 invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts delete mode 100644 invokeai/frontend/web/src/features/workflowLibrary/util/getWorkflowCopyName.ts diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index ef4f1b5ebd0..ea9fa813116 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -39,7 +39,9 @@ import { selectLanguage } from 'features/system/store/systemSelectors'; import { AppContent } from 'features/ui/components/AppContent'; import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; +import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal'; import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog'; +import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog'; import i18n from 'i18n'; import { size } from 'lodash-es'; import { memo, useCallback, useEffect } from 'react'; @@ -73,28 +75,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { {!didStudioInit && } - - - - - - - - - - - - - - - - - - - - - - + ); }; @@ -140,3 +121,35 @@ const HookIsolator = memo( } ); HookIsolator.displayName = 'HookIsolator'; + +const ModalIsolator = memo(() => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); +ModalIsolator.displayName = 'ModalIsolator'; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 554a274cf95..bd21d83175c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -27,7 +27,6 @@ import { addDynamicPromptsListener } from 'app/store/middleware/listenerMiddlewa import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings'; import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected'; import { addUpdateAllNodesRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested'; -import { addWorkflowLoadRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested'; import type { AppDispatch, RootState } from 'app/store/store'; import { addArchivedOrDeletedBoardListener } from './listeners/addArchivedOrDeletedBoardListener'; @@ -89,7 +88,6 @@ addArchivedOrDeletedBoardListener(startAppListening); addGetOpenAPISchemaListener(startAppListening); // Workflows -addWorkflowLoadRequestedListener(startAppListening); addUpdateAllNodesRequestedListener(startAppListening); // Models diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index de0ab48d7c7..e0353a60c61 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -4,8 +4,6 @@ import { useFocusRegion } from 'common/hooks/focus'; import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk'; import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings'; -import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal'; -import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog'; import { memo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFlowArrowBold } from 'react-icons/pi'; @@ -40,8 +38,6 @@ const NodeEditor = () => { - - )} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index 1814b02c2dd..85041208c37 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -20,7 +20,6 @@ import { useConnection } from 'features/nodes/hooks/useConnection'; import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection'; import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste'; import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; -import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher'; import { $addNodeCmdk, $cursorPos, @@ -95,7 +94,6 @@ export const Flow = memo(() => { const isWorkflowsFocused = useIsRegionFocused('workflows'); useFocusRegion('workflows', flowWrapper); - useWorkflowWatcher(); useSyncExecutionState(); const [borderRadius] = useToken('radii', ['base']); const flowStyles = useMemo(() => ({ borderRadius }), [borderRadius]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx index 557dc6c2893..7471c71cfde 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx @@ -1,31 +1,15 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice'; -import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; -import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow'; -import { memo, useCallback } from 'react'; +import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFloppyDiskBold } from 'react-icons/pi'; const SaveWorkflowButton = () => { const { t } = useTranslation(); const isTouched = useAppSelector(selectWorkflowIsTouched); - const { onOpen } = useSaveWorkflowAsDialog(); - const { saveWorkflow } = useSaveLibraryWorkflow(); - - const handleClickSave = useCallback(() => { - const builtWorkflow = $builtWorkflow.get(); - if (!builtWorkflow) { - return; - } - - if (isWorkflowWithID(builtWorkflow)) { - saveWorkflow(); - } else { - onOpen(); - } - }, [onOpen, saveWorkflow]); + const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow(); return ( { aria-label={t('workflows.saveWorkflow')} icon={} isDisabled={!isTouched} - onClick={handleClickSave} + onClick={saveOrSaveAsWorkflow} pointerEvents="auto" /> ); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx index dc3b66250c7..39a93e4a382 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx @@ -1,41 +1,19 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; -import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; -import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow'; -import type { MouseEventHandler } from 'react'; -import { memo, useCallback } from 'react'; +import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFloppyDiskBold } from 'react-icons/pi'; const SaveWorkflowButton = () => { const { t } = useTranslation(); - const { onOpen } = useSaveWorkflowAsDialog(); - const { saveWorkflow } = useSaveLibraryWorkflow(); - - const handleClickSave = useCallback>( - (e) => { - e.stopPropagation(); - - const builtWorkflow = $builtWorkflow.get(); - if (!builtWorkflow) { - return; - } - - if (isWorkflowWithID(builtWorkflow)) { - saveWorkflow(); - } else { - onOpen(); - } - }, - [onOpen, saveWorkflow] - ); + const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow(); return ( } - onClick={handleClickSave} + onClick={saveOrSaveAsWorkflow} pointerEvents="auto" variant="ghost" size="sm" diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx index 4d97e4e45b5..68355552b7d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx @@ -39,15 +39,23 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte return workflowId === workflow.workflow_id; }, [workflowId, workflow.workflow_id]); - const handleClickLoad = useCallback(() => { - setIsHovered(false); - loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); - }, [loadWorkflow, workflow.workflow_id]); + const handleClickLoad = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); + }, + [loadWorkflow, workflow.workflow_id] + ); - const handleClickEdit = useCallback(() => { - setIsHovered(false); - loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); - }, [loadWorkflow, workflow.workflow_id]); + const handleClickEdit = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit'); + }, + [loadWorkflow, workflow.workflow_id] + ); const handleClickDelete = useCallback( (e: MouseEvent) => { diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx index c4103033d15..a6d1e3a0d78 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx @@ -1,5 +1,5 @@ import type { FormControlProps } from '@invoke-ai/ui-library'; -import { Flex, FormControl, FormControlGroup, FormLabel, Input, Textarea } from '@invoke-ai/ui-library'; +import { Box, Flex, FormControl, FormControlGroup, FormLabel, Image, Input, Textarea } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -40,8 +40,6 @@ const WorkflowGeneralTab = () => { const { id, author, name, description, tags, version, contact, notes } = useAppSelector(selector); const dispatch = useAppDispatch(); - const { data } = useGetWorkflowQuery(id ?? skipToken); - const handleChangeName = useCallback( (e: ChangeEvent) => { dispatch(workflowNameChanged(e.target.value)); @@ -96,17 +94,7 @@ const WorkflowGeneralTab = () => { {t('nodes.workflowName')} - {/* - * Only saved and non-default workflows can have a thumbnail. - * - Unsaved workflows have no id. - * - Default workflows have a category of 'default'. - */} - {id && data && data.workflow.meta.category !== 'default' && ( - - {t('workflows.workflowThumbnail')} - - - )} + {t('nodes.workflowVersion')} @@ -156,3 +144,45 @@ export default memo(WorkflowGeneralTab); const formControlProps: FormControlProps = { flexShrink: 0, }; + +const Thumbnail = ({ id }: { id?: string | null }) => { + const { t } = useTranslation(); + + const { data } = useGetWorkflowQuery(id ?? skipToken); + + if (!data) { + return null; + } + + if (data.workflow.meta.category === 'default' && data.thumbnail_url) { + // This is a default workflow and it has a thumbnail set. Users may only view the thumbnail. + return ( + + {t('workflows.workflowThumbnail')} + + + + + ); + } + + if (data.workflow.meta.category !== 'default') { + // This is a user workflow and they may edit the thumbnail. + return ( + + {t('workflows.workflowThumbnail')} + + + ); + } + + // This is a default workflow and it does not have a thumbnail set. Users may not edit the thumbnail. + return null; +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx index 56fe0ea4a09..f38172964ea 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx @@ -1,17 +1,51 @@ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { EMPTY_OBJECT } from 'app/store/constants'; +import { useAppSelector } from 'app/store/storeHooks'; import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; -import { memo } from 'react'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import type { NodesState, WorkflowsState } from 'features/nodes/store/types'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; +import { buildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow'; +import { debounce } from 'lodash-es'; +import { atom, computed } from 'nanostores'; +import { memo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +const $maybePreviewWorkflow = atom(null); +const $previewWorkflow = computed( + $maybePreviewWorkflow, + (maybePreviewWorkflow) => maybePreviewWorkflow ?? EMPTY_OBJECT +); + +const debouncedBuildPreviewWorkflow = debounce( + (nodes: NodesState['nodes'], edges: NodesState['edges'], workflow: WorkflowsState) => { + $maybePreviewWorkflow.set(buildWorkflowFast({ nodes, edges, workflow })); + }, + 300 +); + +const IsolatedWorkflowBuilderWatcher = memo(() => { + const { nodes, edges } = useAppSelector(selectNodesSlice); + const workflow = useAppSelector(selectWorkflowSlice); + + useEffect(() => { + debouncedBuildPreviewWorkflow(nodes, edges, workflow); + }, [edges, nodes, workflow]); + + return null; +}); +IsolatedWorkflowBuilderWatcher.displayName = 'IsolatedWorkflowBuilderWatcher'; + const WorkflowJSONTab = () => { - const workflow = useStore($builtWorkflow); + const previewWorkflow = useStore($previewWorkflow); const { t } = useTranslation(); return ( - + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useWorkflowWatcher.ts b/invokeai/frontend/web/src/features/nodes/hooks/useWorkflowWatcher.ts deleted file mode 100644 index b33b59c2dd2..00000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useWorkflowWatcher.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectNodesSlice } from 'features/nodes/store/selectors'; -import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; -import type { WorkflowV3 } from 'features/nodes/types/workflow'; -import type { BuildWorkflowArg } from 'features/nodes/util/workflow/buildWorkflow'; -import { buildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow'; -import { debounce } from 'lodash-es'; -import { atom } from 'nanostores'; -import { useEffect } from 'react'; - -export const $builtWorkflow = atom(null); - -const debouncedBuildWorkflow = debounce((arg: BuildWorkflowArg) => { - $builtWorkflow.set(buildWorkflowFast(arg)); -}, 300); - -const selectWorkflowSlices = createSelector(selectNodesSlice, selectWorkflowSlice, (nodes, workflow) => ({ - nodes: nodes.nodes, - edges: nodes.edges, - workflow, -})); - -export const useWorkflowWatcher = () => { - const buildWorkflowArg = useAppSelector(selectWorkflowSlices); - - useEffect(() => { - debouncedBuildWorkflow(buildWorkflowArg); - }, [buildWorkflowArg]); -}; diff --git a/invokeai/frontend/web/src/features/nodes/store/actions.ts b/invokeai/frontend/web/src/features/nodes/store/actions.ts index 14b7cfa95c3..c48eb95f529 100644 --- a/invokeai/frontend/web/src/features/nodes/store/actions.ts +++ b/invokeai/frontend/web/src/features/nodes/store/actions.ts @@ -1,6 +1,6 @@ import { createAction, isAnyOf } from '@reduxjs/toolkit'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; -import type { Graph, GraphAndWorkflowResponse } from 'services/api/types'; +import type { Graph } from 'services/api/types'; const textToImageGraphBuilt = createAction('nodes/textToImageGraphBuilt'); const imageToImageGraphBuilt = createAction('nodes/imageToImageGraphBuilt'); @@ -14,11 +14,6 @@ export const isAnyGraphBuilt = isAnyOf( nodesGraphBuilt ); -export const workflowLoadRequested = createAction<{ - data: GraphAndWorkflowResponse; - asCopy: boolean; -}>('nodes/workflowLoadRequested'); - export const updateAllNodesRequested = createAction('nodes/updateAllNodesRequested'); export const workflowLoaded = createAction('workflow/workflowLoaded'); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts index ff57ceeafe0..e060c585075 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts @@ -1,17 +1,21 @@ import { logger } from 'app/logging/logger'; +import { useAppStore } from 'app/store/storeHooks'; import { deepClone } from 'common/util/deepClone'; import { parseify } from 'common/util/serialize'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { NodesState, WorkflowsState } from 'features/nodes/store/types'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; import { isInvocationNode, isNotesNode } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { zWorkflowV3 } from 'features/nodes/types/workflow'; import i18n from 'i18n'; import { pick } from 'lodash-es'; +import { useCallback } from 'react'; import { fromZodError } from 'zod-validation-error'; const log = logger('workflows'); -export type BuildWorkflowArg = { +type BuildWorkflowArg = { nodes: NodesState['nodes']; edges: NodesState['edges']; workflow: WorkflowsState; @@ -34,7 +38,7 @@ const workflowKeys = [ type BuildWorkflowFunction = (arg: BuildWorkflowArg) => WorkflowV3; export const buildWorkflowFast: BuildWorkflowFunction = ({ nodes, edges, workflow }: BuildWorkflowArg): WorkflowV3 => { - const clonedWorkflow = pick(deepClone(workflow), workflowKeys); + const clonedWorkflow = pick(workflow, workflowKeys); const newWorkflow: WorkflowV3 = { ...clonedWorkflow, @@ -42,46 +46,27 @@ export const buildWorkflowFast: BuildWorkflowFunction = ({ nodes, edges, workflo edges: [], }; - nodes.forEach((node) => { + for (const node of nodes) { if (isInvocationNode(node) && node.type) { - newWorkflow.nodes.push({ - id: node.id, - type: node.type, - data: deepClone(node.data), - position: { ...node.position }, - }); + const { id, type, data, position } = node; + newWorkflow.nodes.push({ id, type, data, position }); } else if (isNotesNode(node) && node.type) { - newWorkflow.nodes.push({ - id: node.id, - type: node.type, - data: deepClone(node.data), - position: { ...node.position }, - }); + const { id, type, data, position } = node; + newWorkflow.nodes.push({ id, type, data, position }); } - }); + } - edges.forEach((edge) => { + for (const edge of edges) { if (edge.type === 'default' && edge.sourceHandle && edge.targetHandle) { - newWorkflow.edges.push({ - id: edge.id, - type: edge.type, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - hidden: edge.hidden, - }); + const { id, type, source, target, sourceHandle, targetHandle, hidden } = edge; + newWorkflow.edges.push({ id, type, source, target, sourceHandle, targetHandle, hidden }); } else if (edge.type === 'collapsed') { - newWorkflow.edges.push({ - id: edge.id, - type: edge.type, - source: edge.source, - target: edge.target, - }); + const { id, type, source, target } = edge; + newWorkflow.edges.push({ id, type, source, target }); } - }); + } - return newWorkflow; + return deepClone(newWorkflow); }; export const buildWorkflowWithValidation = ({ nodes, edges, workflow }: BuildWorkflowArg): WorkflowV3 | null => { @@ -102,3 +87,15 @@ export const buildWorkflowWithValidation = ({ nodes, edges, workflow }: BuildWor return result.data; }; + +export const useBuildWorkflowFast = (): (() => WorkflowV3) => { + const store = useAppStore(); + const buildWorkflow = useCallback(() => { + const state = store.getState(); + const { nodes, edges } = selectNodesSlice(state); + const workflow = selectWorkflowSlice(state); + return buildWorkflowFast({ nodes, edges, workflow }); + }, [store]); + + return buildWorkflow; +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts index 348ff304f08..f8978c74b14 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -54,13 +54,6 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise { + builder.addCase(workflowLoaded, (state) => { state.activeTab = 'workflows'; }); builder.addMatcher(newSessionRequested, (state) => { diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx index 9f06ce321a8..d759eff611d 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal.tsx @@ -14,9 +14,8 @@ import { Textarea, } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { workflowLoadRequested } from 'features/nodes/store/actions'; import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; +import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow'; import { atom } from 'nanostores'; import type { ChangeEvent } from 'react'; import { useCallback, useState } from 'react'; @@ -38,7 +37,7 @@ export const useLoadWorkflowFromGraphModal = () => { export const LoadWorkflowFromGraphModal = () => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const _loadWorkflow = useLoadWorkflow(); const { isOpen, onClose } = useLoadWorkflowFromGraphModal(); const [graphRaw, setGraphRaw] = useState(''); const [workflowRaw, setWorkflowRaw] = useState(''); @@ -58,9 +57,9 @@ export const LoadWorkflowFromGraphModal = () => { setWorkflowRaw(JSON.stringify(workflow, null, 2)); }, [graphRaw, shouldAutoLayout]); const loadWorkflow = useCallback(() => { - dispatch(workflowLoadRequested({ data: { workflow: workflowRaw, graph: null }, asCopy: true })); + _loadWorkflow({ workflow: workflowRaw, graph: null }); onClose(); - }, [dispatch, onClose, workflowRaw]); + }, [_loadWorkflow, onClose, workflowRaw]); return ( diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx new file mode 100644 index 00000000000..7e57a002642 --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx @@ -0,0 +1,169 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + Button, + Checkbox, + Flex, + FormControl, + FormLabel, + Input, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { deepClone } from 'common/util/deepClone'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; +import { isDraftWorkflow, useCreateLibraryWorkflow } from 'features/workflowLibrary/hooks/useCreateNewWorkflow'; +import { t } from 'i18next'; +import { atom, computed } from 'nanostores'; +import type { ChangeEvent, RefObject } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; +import { assert } from 'tsafe'; + +/** + * The workflow to save as a new workflow. + * + * This state is used to determine whether or not the modal is open. + */ +const $workflowToSave = atom(null); + +/** + * Whether or not the modal is open. It is open if there is a workflow to save. + * + * The state is derived from the workflow to save. + * + * To open the modal, set the workflow to save to a workflow object. + * To close the modal, set the workflow to save to null. + */ +const $isOpen = computed($workflowToSave, (val) => val !== null); + +const getInitialName = (workflow: WorkflowV3): string => { + if (!workflow.id) { + // If the workflow has no ID, that means it's a new workflow that has never been saved to the server. In this case, + // we should use whatever the user has entered in the workflow name field. + return workflow.name; + } + // Otherwise, the workflow is already saved to the server. + if (workflow.name.length) { + // This workflow has a name so let's use the workflow's name with " (copy)" appended to it. + return `${workflow.name.trim()} (copy)`; + } + // Fallback - will show a placeholder in the input field. + return ''; +}; + +/** + * Save the workflow as a new workflow. This will open a dialog where the user can enter the name of the new workflow. + * The workflow object is deep cloned to prevent any changes to the original workflow object. + * @param workflow The workflow to save as a new workflow. + */ +export const saveWorkflowAs = (workflow: WorkflowV3) => { + $workflowToSave.set(deepClone(workflow)); +}; + +export const SaveWorkflowAsDialog = () => { + const isOpen = useStore($isOpen); + const workflowToSave = useStore($workflowToSave); + + const cancelRef = useRef(null); + + const onClose = useCallback(() => { + $workflowToSave.set(null); + }, []); + + return ( + + {!workflowToSave && } + {workflowToSave && } + + ); +}; + +const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef: RefObject }) => { + const workflowCategories = useStore($workflowCategories); + const [name, setName] = useState(() => { + if (workflow) { + return getInitialName(workflow); + } + return ''; + }); + const [shouldSaveToProject, setShouldSaveToProject] = useState(() => workflowCategories.includes('project')); + + const { createNewWorkflow } = useCreateLibraryWorkflow(); + + const inputRef = useRef(null); + + const onChange = useCallback((e: ChangeEvent) => { + setName(e.target.value); + }, []); + + const onChangeCheckbox = useCallback( + (e: ChangeEvent) => { + setShouldSaveToProject(e.target.checked); + }, + [setShouldSaveToProject] + ); + + const onClose = useCallback(() => { + $workflowToSave.set(null); + }, []); + + const onSave = useCallback(async () => { + workflow.id = undefined; + workflow.name = name; + workflow.meta.category = shouldSaveToProject ? 'project' : 'user'; + + // We've just made the workflow a draft, but TS doesn't know that. We need to assert it. + assert(isDraftWorkflow(workflow)); + + await createNewWorkflow({ + workflow, + onSuccess: onClose, + onError: onClose, + }); + }, [workflow, name, shouldSaveToProject, createNewWorkflow, onClose]); + + return ( + + + {t('workflows.saveWorkflowAs')} + + + + + {t('workflows.workflowName')} + + + {workflowCategories.includes('project') && ( + + {t('workflows.saveWorkflowToProject')} + + )} + + + + + + + + + + ); +}); +Content.displayName = 'Content'; + +const NoWorkflowToSaveContent = memo(() => { + return ( + + + + ); +}); +NoWorkflowToSaveContent.displayName = 'NoWorkflowToSaveContent'; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx deleted file mode 100644 index 218a9d1d2d1..00000000000 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - Button, - Checkbox, - Flex, - FormControl, - FormLabel, - Input, -} from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; -import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; -import { useSaveWorkflowAs } from 'features/workflowLibrary/hooks/useSaveWorkflowAs'; -import { t } from 'i18next'; -import type { ChangeEvent } from 'react'; -import { useCallback, useRef } from 'react'; - -export const SaveWorkflowAsDialog = () => { - const { isOpen, onClose, workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject } = - useSaveWorkflowAsDialog(); - - const workflowCategories = useStore($workflowCategories); - - const { saveWorkflowAs } = useSaveWorkflowAs(); - - const cancelRef = useRef(null); - const inputRef = useRef(null); - - const onChange = useCallback( - (e: ChangeEvent) => { - setWorkflowName(e.target.value); - }, - [setWorkflowName] - ); - - const onChangeCheckbox = useCallback( - (e: ChangeEvent) => { - setShouldSaveToProject(e.target.checked); - }, - [setShouldSaveToProject] - ); - - const clearAndClose = useCallback(() => { - onClose(); - }, [onClose]); - - const onSave = useCallback(async () => { - const category = shouldSaveToProject ? 'project' : 'user'; - await saveWorkflowAs({ - name: workflowName, - category, - onSuccess: clearAndClose, - onError: clearAndClose, - }); - }, [workflowName, saveWorkflowAs, shouldSaveToProject, clearAndClose]); - - return ( - - - - - {t('workflows.saveWorkflowAs')} - - - - - {t('workflows.workflowName')} - - - {workflowCategories.includes('project') && ( - - {t('workflows.saveWorkflowToProject')} - - )} - - - - - - - - - - - - ); -}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts deleted file mode 100644 index 78f3e3f5370..00000000000 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useStore } from '@nanostores/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; -import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName'; -import { atom } from 'nanostores'; -import { useCallback } from 'react'; - -const $isOpen = atom(false); -const $workflowName = atom(''); -const $shouldSaveToProject = atom(false); - -const selectNewWorkflowName = createSelector(selectWorkflowSlice, ({ name, id }): string => { - // If the workflow has no ID, it's a new workflow that has never been saved to the server. The dialog should use - // whatever the user has entered in the workflow name field. - if (!id) { - return name; - } - // Else, the workflow is already saved to the server. The dialog should use the workflow's name with " (copy)" - // appended to it. - if (name.length) { - return getWorkflowCopyName(name); - } - // Else, we have a workflow that has been saved to the server, but has no name. This should never happen, but if - // it does, we just return an empty string and let the dialog use the default name. - return ''; -}); - -export const useSaveWorkflowAsDialog = () => { - const newWorkflowName = useAppSelector(selectNewWorkflowName); - - const isOpen = useStore($isOpen); - const onOpen = useCallback(() => { - $workflowName.set(newWorkflowName); - $isOpen.set(true); - }, [newWorkflowName]); - const onClose = useCallback(() => { - $isOpen.set(false); - $workflowName.set(''); - $shouldSaveToProject.set(false); - }, []); - - const workflowName = useStore($workflowName); - const setWorkflowName = useCallback((workflowName: string) => $workflowName.set(workflowName), []); - - const shouldSaveToProject = useStore($shouldSaveToProject); - const setShouldSaveToProject = useCallback((shouldSaveToProject: boolean) => { - $shouldSaveToProject.set(shouldSaveToProject); - }, []); - - return { workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject, isOpen, onOpen, onClose }; -}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx index 8676b018624..1695206041f 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx @@ -1,24 +1,22 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu'; +import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog'; import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile'; import { memo, useCallback, useRef } from 'react'; import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { PiUploadSimpleBold } from 'react-icons/pi'; -import { useSaveWorkflowAsDialog } from './SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; - const UploadWorkflowButton = () => { const { t } = useTranslation(); const resetRef = useRef<() => void>(null); const workflowListMenu = useWorkflowListMenu(); - const saveWorkflowAsDialog = useSaveWorkflowAsDialog(); const loadWorkflowFromFile = useLoadWorkflowFromFile({ resetRef, - onSuccess: () => { + onSuccess: (workflow) => { workflowListMenu.close(); - saveWorkflowAsDialog.onOpen(); + saveWorkflowAs(workflow); }, }); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx index bd8a909acea..9e6a4622dc0 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx @@ -1,15 +1,26 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; -import { memo } from 'react'; +import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow'; +import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog'; +import type { MouseEventHandler } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCopyBold } from 'react-icons/pi'; const SaveWorkflowAsMenuItem = () => { const { t } = useTranslation(); - const { onOpen } = useSaveWorkflowAsDialog(); + const buildWorkflow = useBuildWorkflowFast(); + + const handleClickSave = useCallback>( + (e) => { + e.stopPropagation(); + const workflow = buildWorkflow(); + saveWorkflowAs(workflow); + }, + [buildWorkflow] + ); return ( - } onClick={onOpen}> + } onClick={handleClickSave}> {t('workflows.saveWorkflowAs')} ); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx index c1335b1dc9d..14ceb159cf0 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx @@ -1,34 +1,18 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice'; -import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; -import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow'; -import { memo, useCallback } from 'react'; +import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFloppyDiskBold } from 'react-icons/pi'; const SaveWorkflowMenuItem = () => { const { t } = useTranslation(); - const { saveWorkflow } = useSaveLibraryWorkflow(); - const { onOpen } = useSaveWorkflowAsDialog(); + const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow(); const isTouched = useAppSelector(selectWorkflowIsTouched); - const handleClickSave = useCallback(() => { - const builtWorkflow = $builtWorkflow.get(); - if (!builtWorkflow) { - return; - } - - if (isWorkflowWithID(builtWorkflow)) { - saveWorkflow(); - } else { - onOpen(); - } - }, [onOpen, saveWorkflow]); - return ( - } onClick={handleClickSave}> + } onClick={saveOrSaveAsWorkflow}> {t('workflows.saveWorkflow')} ); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts similarity index 63% rename from invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts rename to invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts index 2c18fcdd909..a47975ff6d9 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts @@ -1,7 +1,6 @@ import type { ToastId } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; import { formFieldInitialValuesChanged, workflowCategoryChanged, @@ -9,42 +8,48 @@ import { workflowNameChanged, workflowSaved, } from 'features/nodes/store/workflowSlice'; -import type { WorkflowCategory } from 'features/nodes/types/workflow'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues'; import { newWorkflowSaved } from 'features/workflowLibrary/store/actions'; import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; +import type { SetFieldType } from 'type-fest'; -type SaveWorkflowAsArg = { - name: string; - category: WorkflowCategory; +/** + * A draft workflow is a workflow that is has not been saved yet. It does not have an id and is not in the default category. + */ +type DraftWorkflow = SetFieldType< + SetFieldType, + 'meta', + SetFieldType> +>; + +export const isDraftWorkflow = (workflow: WorkflowV3): workflow is DraftWorkflow => + !workflow.id && workflow.meta.category !== 'default'; + +type CreateLibraryWorkflowArg = { + workflow: DraftWorkflow; onSuccess?: () => void; onError?: () => void; }; -type UseSaveWorkflowAsReturn = { - saveWorkflowAs: (arg: SaveWorkflowAsArg) => Promise; +type CreateLibraryWorkflowReturn = { + createNewWorkflow: (arg: CreateLibraryWorkflowArg) => Promise; isLoading: boolean; isError: boolean; }; -type UseSaveWorkflowAs = () => UseSaveWorkflowAsReturn; - -export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { +export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); + const [createWorkflow, { isLoading, isError }] = useCreateWorkflowMutation(); const getFormFieldInitialValues = useGetFormFieldInitialValues(); const toast = useToast(); const toastRef = useRef(); - const saveWorkflowAs = useCallback( - async ({ name: newName, category, onSuccess, onError }: SaveWorkflowAsArg) => { - const workflow = $builtWorkflow.get(); - if (!workflow) { - return; - } + const createNewWorkflow = useCallback( + async ({ workflow, onSuccess, onError }: CreateLibraryWorkflowArg) => { toastRef.current = toast({ title: t('workflows.savingWorkflow'), status: 'loading', @@ -52,18 +57,19 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { isClosable: false, }); try { - workflow.id = undefined; - workflow.name = newName; - workflow.meta.category = category; - const data = await createWorkflow(workflow).unwrap(); - dispatch(workflowIDChanged(data.workflow.id)); - dispatch(workflowNameChanged(data.workflow.name)); - dispatch(workflowCategoryChanged(data.workflow.meta.category)); + const { + id, + name, + meta: { category }, + } = data.workflow; + dispatch(workflowIDChanged(id)); + dispatch(workflowNameChanged(name)); + dispatch(workflowCategoryChanged(category)); dispatch(workflowSaved()); + dispatch(newWorkflowSaved({ category })); // When a workflow is saved, the form field initial values are updated to the current form field values dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() })); - dispatch(newWorkflowSaved({ category })); onSuccess && onSuccess(); toast.update(toastRef.current, { @@ -89,8 +95,8 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { [toast, t, createWorkflow, dispatch, getFormFieldInitialValues] ); return { - saveWorkflowAs, - isLoading: createWorkflowResult.isLoading, - isError: createWorkflowResult.isError, + createNewWorkflow, + isLoading, + isError, }; }; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useDownloadCurrentlyLoadedWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useDownloadCurrentlyLoadedWorkflow.ts index c940a290c3e..399f2ab95fc 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useDownloadCurrentlyLoadedWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useDownloadCurrentlyLoadedWorkflow.ts @@ -1,16 +1,15 @@ import { useAppDispatch } from 'app/store/storeHooks'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; +import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow'; import { workflowDownloaded } from 'features/workflowLibrary/store/actions'; import { useCallback } from 'react'; export const useDownloadCurrentlyLoadedWorkflow = () => { const dispatch = useAppDispatch(); + const buildWorkflow = useBuildWorkflowFast(); const downloadWorkflow = useCallback(() => { - const workflow = $builtWorkflow.get(); - if (!workflow) { - return; - } + const workflow = buildWorkflow(); + const blob = new Blob([JSON.stringify(workflow, null, 2)]); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); @@ -19,7 +18,7 @@ export const useDownloadCurrentlyLoadedWorkflow = () => { a.click(); a.remove(); dispatch(workflowDownloaded()); - }, [dispatch]); + }, [buildWorkflow, dispatch]); return downloadWorkflow; }; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts index ec93345ac5c..ee1f460bc6e 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow.ts @@ -1,6 +1,5 @@ -import { useAppDispatch } from 'app/store/storeHooks'; -import { workflowLoadRequested } from 'features/nodes/store/actions'; import { toast } from 'features/toast/toast'; +import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useLazyGetImageWorkflowQuery } from 'services/api/endpoints/images'; @@ -11,15 +10,15 @@ type UseGetAndLoadEmbeddedWorkflowOptions = { }; export const useGetAndLoadEmbeddedWorkflow = (options?: UseGetAndLoadEmbeddedWorkflowOptions) => { - const dispatch = useAppDispatch(); const { t } = useTranslation(); const [_getAndLoadEmbeddedWorkflow, result] = useLazyGetImageWorkflowQuery(); + const loadWorkflow = useLoadWorkflow(); const getAndLoadEmbeddedWorkflow = useCallback( async (imageName: string) => { try { const { data } = await _getAndLoadEmbeddedWorkflow(imageName); if (data) { - dispatch(workflowLoadRequested({ data, asCopy: true })); + loadWorkflow(data); // No toast - the listener for this action does that after the workflow is loaded options?.onSuccess && options?.onSuccess(); } else { @@ -38,7 +37,7 @@ export const useGetAndLoadEmbeddedWorkflow = (options?: UseGetAndLoadEmbeddedWor options?.onError && options?.onError(); } }, - [_getAndLoadEmbeddedWorkflow, dispatch, options, t] + [_getAndLoadEmbeddedWorkflow, loadWorkflow, options, t] ); return [getAndLoadEmbeddedWorkflow, result] as const; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow.ts index 9f86d118600..91b833aeec4 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow.ts @@ -1,6 +1,5 @@ import { useToast } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { workflowLoadRequested } from 'features/nodes/store/actions'; +import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useLazyGetWorkflowQuery, workflowsApi } from 'services/api/endpoints/workflows'; @@ -18,16 +17,16 @@ type UseGetAndLoadLibraryWorkflowReturn = { type UseGetAndLoadLibraryWorkflow = (arg?: UseGetAndLoadLibraryWorkflowOptions) => UseGetAndLoadLibraryWorkflowReturn; export const useGetAndLoadLibraryWorkflow: UseGetAndLoadLibraryWorkflow = (arg) => { - const dispatch = useAppDispatch(); const toast = useToast(); const { t } = useTranslation(); + const loadWorkflow = useLoadWorkflow(); const [_getAndLoadWorkflow, getAndLoadWorkflowResult] = useLazyGetWorkflowQuery(); const getAndLoadWorkflow = useCallback( async (workflow_id: string) => { try { const { workflow } = await _getAndLoadWorkflow(workflow_id).unwrap(); // This action expects a stringified workflow, instead of updating the routes and services we will just stringify it here - dispatch(workflowLoadRequested({ data: { workflow: JSON.stringify(workflow), graph: null }, asCopy: false })); + loadWorkflow({ workflow: JSON.stringify(workflow), graph: null }); // No toast - the listener for this action does that after the workflow is loaded arg?.onSuccess && arg.onSuccess(); } catch { @@ -39,7 +38,7 @@ export const useGetAndLoadLibraryWorkflow: UseGetAndLoadLibraryWorkflow = (arg) arg?.onError && arg.onError(); } }, - [_getAndLoadWorkflow, dispatch, arg, t, toast] + [_getAndLoadWorkflow, loadWorkflow, arg, toast, t] ); return { getAndLoadWorkflow, getAndLoadWorkflowResult }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflow.ts similarity index 82% rename from invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts rename to invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflow.ts index d0e9f702abf..457e8dc84bc 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflow.ts @@ -1,15 +1,17 @@ import { logger } from 'app/logging/logger'; -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { useAppDispatch } from 'app/store/storeHooks'; import { $nodeExecutionStates } from 'features/nodes/hooks/useNodeExecutionState'; -import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions'; +import { workflowLoaded } from 'features/nodes/store/actions'; import { $templates } from 'features/nodes/store/nodesSlice'; import { $needsFit } from 'features/nodes/store/reactFlowInstance'; import type { Templates } from 'features/nodes/store/types'; import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'; import { toast } from 'features/toast/toast'; -import { t } from 'i18next'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { serializeError } from 'serialize-error'; import { checkBoardAccess, checkImageAccess, checkModelAccess } from 'services/api/hooks/accessChecks'; import type { GraphAndWorkflowResponse, NonNullableGraph } from 'services/api/types'; @@ -18,7 +20,7 @@ import { fromZodError } from 'zod-validation-error'; const log = logger('workflows'); -const getWorkflow = async (data: GraphAndWorkflowResponse, templates: Templates) => { +const getWorkflowFromStringifiedWorkflowOrGraph = async (data: GraphAndWorkflowResponse, templates: Templates) => { if (data.workflow) { // Prefer to load the workflow if it's available - it has more information const parsed = JSON.parse(data.workflow); @@ -39,20 +41,14 @@ const getWorkflow = async (data: GraphAndWorkflowResponse, templates: Templates) } }; -export const addWorkflowLoadRequestedListener = (startAppListening: AppStartListening) => { - startAppListening({ - actionCreator: workflowLoadRequested, - effect: async (action, { dispatch }) => { - const { data, asCopy } = action.payload; - const nodeTemplates = $templates.get(); - +export const useLoadWorkflow = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const loadWorkflow = useCallback( + async (data: GraphAndWorkflowResponse): Promise => { try { - const { workflow, warnings } = await getWorkflow(data, nodeTemplates); - - if (asCopy) { - // If we're loading a copy, we need to remove the ID so that the backend will create a new workflow - delete workflow.id; - } + const templates = $templates.get(); + const { workflow, warnings } = await getWorkflowFromStringifiedWorkflowOrGraph(data, templates); $nodeExecutionStates.set({}); dispatch(workflowLoaded(workflow)); @@ -75,6 +71,7 @@ export const addWorkflowLoadRequestedListener = (startAppListening: AppStartList } $needsFit.set(true); + return workflow; } catch (e) { if (e instanceof WorkflowVersionError) { // The workflow version was not recognized in the valid list of versions @@ -116,7 +113,11 @@ export const addWorkflowLoadRequestedListener = (startAppListening: AppStartList description: t('nodes.unknownErrorValidatingWorkflow'), }); } + return null; } }, - }); + [dispatch, t] + ); + + return loadWorkflow; }; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflowFromFile.tsx b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflowFromFile.tsx index a32a816fce0..e7920894d0b 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflowFromFile.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useLoadWorkflowFromFile.tsx @@ -1,44 +1,36 @@ -import { useLogger } from 'app/logging/useLogger'; import { useAppDispatch } from 'app/store/storeHooks'; -import { workflowLoadRequested } from 'features/nodes/store/actions'; -import { toast } from 'features/toast/toast'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; +import { useLoadWorkflow } from 'features/workflowLibrary/hooks/useLoadWorkflow'; import { workflowLoadedFromFile } from 'features/workflowLibrary/store/actions'; import type { RefObject } from 'react'; import { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; +import { assert } from 'tsafe'; type useLoadWorkflowFromFileOptions = { resetRef: RefObject<() => void>; - onSuccess?: () => void; + onSuccess?: (workflow: WorkflowV3) => void; }; type UseLoadWorkflowFromFile = (options: useLoadWorkflowFromFileOptions) => (file: File | null) => void; export const useLoadWorkflowFromFile: UseLoadWorkflowFromFile = ({ resetRef, onSuccess }) => { const dispatch = useAppDispatch(); - const logger = useLogger('workflows'); - const { t } = useTranslation(); + const loadWorkflow = useLoadWorkflow(); const loadWorkflowFromFile = useCallback( (file: File | null) => { if (!file) { return; } const reader = new FileReader(); - reader.onload = () => { + reader.onload = async () => { const rawJSON = reader.result; try { - dispatch(workflowLoadRequested({ data: { workflow: String(rawJSON), graph: null }, asCopy: true })); + const workflow = await loadWorkflow({ workflow: String(rawJSON), graph: null }); + assert(workflow !== null); dispatch(workflowLoadedFromFile()); - onSuccess && onSuccess(); + onSuccess && onSuccess(workflow); } catch (e) { - // There was a problem reading the file - logger.error(t('nodes.unableToLoadWorkflow')); - toast({ - id: 'UNABLE_TO_LOAD_WORKFLOW', - title: t('nodes.unableToLoadWorkflow'), - status: 'error', - }); reader.abort(); } }; @@ -48,7 +40,7 @@ export const useLoadWorkflowFromFile: UseLoadWorkflowFromFile = ({ resetRef, onS // Reset the file picker internal state so that the same file can be loaded again resetRef.current?.(); }, - [dispatch, logger, resetRef, t, onSuccess] + [resetRef, loadWorkflow, dispatch, onSuccess] ); return loadWorkflowFromFile; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveLibraryWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveLibraryWorkflow.ts new file mode 100644 index 00000000000..f028e24925f --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveLibraryWorkflow.ts @@ -0,0 +1,79 @@ +import type { ToastId } from '@invoke-ai/ui-library'; +import { useToast } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { formFieldInitialValuesChanged, workflowSaved } from 'features/nodes/store/workflowSlice'; +import type { WorkflowV3 } from 'features/nodes/types/workflow'; +import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues'; +import { workflowUpdated } from 'features/workflowLibrary/store/actions'; +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; +import type { SetFieldType, SetRequired } from 'type-fest'; + +/** + * A library workflow is a workflow that is already saved in the library. It has an id and is not in the default category. + */ +type LibraryWorkflow = SetFieldType< + SetRequired, + 'meta', + SetFieldType> +>; + +export const isLibraryWorkflow = (workflow: WorkflowV3): workflow is LibraryWorkflow => + !!workflow.id && workflow.meta.category !== 'default'; + +type UseSaveLibraryWorkflowReturn = { + saveWorkflow: (workflow: LibraryWorkflow) => Promise; + isLoading: boolean; + isError: boolean; +}; + +export const useSaveLibraryWorkflow = (): UseSaveLibraryWorkflowReturn => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const getFormFieldInitialValues = useGetFormFieldInitialValues(); + const [updateWorkflow, { isLoading, isError }] = useUpdateWorkflowMutation(); + const toast = useToast(); + const toastRef = useRef(); + + const saveWorkflow = useCallback( + async (workflow: LibraryWorkflow) => { + toastRef.current = toast({ + title: t('workflows.savingWorkflow'), + status: 'loading', + duration: null, + isClosable: false, + }); + try { + await updateWorkflow(workflow).unwrap(); + dispatch(workflowUpdated()); + dispatch(workflowSaved()); + // When a workflow is saved, the form field initial values are updated to the current form field values + dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() })); + toast.update(toastRef.current, { + title: t('workflows.workflowSaved'), + status: 'success', + duration: 1000, + isClosable: true, + }); + } catch (e) { + if (!toast.isActive(`auth-error-toast-${workflowsApi.endpoints.updateWorkflow.name}`)) { + toast.update(toastRef.current, { + title: t('workflows.problemSavingWorkflow'), + status: 'error', + duration: 1000, + isClosable: true, + }); + } else { + toast.close(toastRef.current); + } + } + }, + [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow] + ); + return { + saveWorkflow, + isLoading, + isError, + }; +}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts new file mode 100644 index 00000000000..21ec1bbb609 --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts @@ -0,0 +1,24 @@ +import { useBuildWorkflowFast } from 'features/nodes/util/workflow/buildWorkflow'; +import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog'; +import { isLibraryWorkflow, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveLibraryWorkflow'; +import { useCallback } from 'react'; + +/** + * Returns a function that saves the current workflow if it's a library workflow, or opens the save dialog. + */ +export const useSaveOrSaveAsWorkflow = () => { + const buildWorkflow = useBuildWorkflowFast(); + const { saveWorkflow } = useSaveLibraryWorkflow(); + + const saveOrSaveAsWorkflow = useCallback(() => { + const workflow = buildWorkflow(); + + if (isLibraryWorkflow(workflow)) { + saveWorkflow(workflow); + } else { + saveWorkflowAs(workflow); + } + }, [buildWorkflow, saveWorkflow]); + + return saveOrSaveAsWorkflow; +}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts deleted file mode 100644 index 53119c051e5..00000000000 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ToastId } from '@invoke-ai/ui-library'; -import { useToast } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; -import { formFieldInitialValuesChanged, workflowIDChanged, workflowSaved } from 'features/nodes/store/workflowSlice'; -import type { WorkflowV3 } from 'features/nodes/types/workflow'; -import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues'; -import { workflowUpdated } from 'features/workflowLibrary/store/actions'; -import { useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useCreateWorkflowMutation, useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; -import type { SetRequired } from 'type-fest'; - -type UseSaveLibraryWorkflowReturn = { - saveWorkflow: () => Promise; - isLoading: boolean; - isError: boolean; -}; - -type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn; - -export const isWorkflowWithID = (workflow: WorkflowV3): workflow is SetRequired => - Boolean(workflow.id); - -export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const getFormFieldInitialValues = useGetFormFieldInitialValues(); - const [updateWorkflow, updateWorkflowResult] = useUpdateWorkflowMutation(); - const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); - const toast = useToast(); - const toastRef = useRef(); - const saveWorkflow = useCallback(async () => { - const workflow = $builtWorkflow.get(); - if (!workflow) { - return; - } - toastRef.current = toast({ - title: t('workflows.savingWorkflow'), - status: 'loading', - duration: null, - isClosable: false, - }); - try { - if (isWorkflowWithID(workflow)) { - await updateWorkflow(workflow).unwrap(); - dispatch(workflowUpdated()); - } else { - const data = await createWorkflow(workflow).unwrap(); - dispatch(workflowIDChanged(data.workflow.id)); - } - dispatch(workflowSaved()); - // When a workflow is saved, the form field initial values are updated to the current form field values - dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() })); - toast.update(toastRef.current, { - title: t('workflows.workflowSaved'), - status: 'success', - duration: 1000, - isClosable: true, - }); - } catch (e) { - if ( - !toast.isActive(`auth-error-toast-${workflowsApi.endpoints.createWorkflow.name}`) && - !toast.isActive(`auth-error-toast-${workflowsApi.endpoints.updateWorkflow.name}`) - ) { - toast.update(toastRef.current, { - title: t('workflows.problemSavingWorkflow'), - status: 'error', - duration: 1000, - isClosable: true, - }); - } else { - toast.close(toastRef.current); - } - } - }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow]); - return { - saveWorkflow, - isLoading: updateWorkflowResult.isLoading || createWorkflowResult.isLoading, - isError: updateWorkflowResult.isError || createWorkflowResult.isError, - }; -}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/getWorkflowCopyName.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/getWorkflowCopyName.ts deleted file mode 100644 index d4cb9a2c308..00000000000 --- a/invokeai/frontend/web/src/features/workflowLibrary/util/getWorkflowCopyName.ts +++ /dev/null @@ -1 +0,0 @@ -export const getWorkflowCopyName = (name: string): string => `${name.trim()} (copy)`; From ac6fc6eccb131831f4347e48479fc62a666a2284 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 09:47:21 +1000 Subject: [PATCH 055/102] chore: ruff --- invokeai/app/invocations/segment_anything.py | 6 +++--- invokeai/app/services/config/config_default.py | 6 +++--- invokeai/backend/model_manager/merge.py | 18 +++++++++--------- .../services/download/test_download_queue.py | 12 ++++++------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/invokeai/app/invocations/segment_anything.py b/invokeai/app/invocations/segment_anything.py index 9b0000a247f..91517cb8e99 100644 --- a/invokeai/app/invocations/segment_anything.py +++ b/invokeai/app/invocations/segment_anything.py @@ -185,9 +185,9 @@ def _filter_masks( # Find the largest mask. return [max(masks, key=lambda x: float(x.sum()))] elif self.mask_filter == "highest_box_score": - assert bounding_boxes is not None, ( - "Bounding boxes must be provided to use the 'highest_box_score' mask filter." - ) + assert ( + bounding_boxes is not None + ), "Bounding boxes must be provided to use the 'highest_box_score' mask filter." assert len(masks) == len(bounding_boxes) # Find the index of the bounding box with the highest score. # Note that we fallback to -1.0 if the score is None. This is mainly to satisfy the type checker. In most diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index ec63021c698..b2f4eeecc46 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -483,9 +483,9 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: try: # Meta is not included in the model fields, so we need to validate it separately config = InvokeAIAppConfig.model_validate(loaded_config_dict) - assert config.schema_version == CONFIG_SCHEMA_VERSION, ( - f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" - ) + assert ( + config.schema_version == CONFIG_SCHEMA_VERSION + ), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" return config except Exception as e: raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e diff --git a/invokeai/backend/model_manager/merge.py b/invokeai/backend/model_manager/merge.py index 03056b10f59..b00bc99f3e2 100644 --- a/invokeai/backend/model_manager/merge.py +++ b/invokeai/backend/model_manager/merge.py @@ -115,19 +115,19 @@ def merge_diffusion_models_and_save( base_models: Set[BaseModelType] = set() variant = None if self._installer.app_config.precision == "float32" else "fp16" - assert len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference, ( - "When merging three models, only the 'add_difference' merge method is supported" - ) + assert ( + len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference + ), "When merging three models, only the 'add_difference' merge method is supported" for key in model_keys: info = store.get_model(key) model_names.append(info.name) - assert isinstance(info, MainDiffusersConfig), ( - f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" - ) - assert info.variant == ModelVariantType("normal"), ( - f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" - ) + assert isinstance( + info, MainDiffusersConfig + ), f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" + assert info.variant == ModelVariantType( + "normal" + ), f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" # tally base models used base_models.add(info.base) diff --git a/tests/app/services/download/test_download_queue.py b/tests/app/services/download/test_download_queue.py index 1c5c8ec6af4..fd2e2a65ae5 100644 --- a/tests/app/services/download/test_download_queue.py +++ b/tests/app/services/download/test_download_queue.py @@ -212,12 +212,12 @@ def event_handler(job: DownloadJob | MultiFileDownloadJob, excp: Optional[Except assert job.bytes > 0, "expected download bytes to be positive" assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes" assert job.download_path == tmp_path / "sdxl-turbo" - assert Path(tmp_path, "sdxl-turbo/model_index.json").exists(), ( - f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" - ) - assert Path(tmp_path, "sdxl-turbo/text_encoder/config.json").exists(), ( - f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" - ) + assert Path( + tmp_path, "sdxl-turbo/model_index.json" + ).exists(), f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" + assert Path( + tmp_path, "sdxl-turbo/text_encoder/config.json" + ).exists(), f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED} queue.stop() From cf0cbaf0ae632a56ad7c78692d4c6992999d7d16 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 09:48:54 +1000 Subject: [PATCH 056/102] chore: ruff (more) --- invokeai/app/invocations/segment_anything.py | 6 +++--- invokeai/app/services/config/config_default.py | 6 +++--- .../workflow_records_sqlite.py | 12 ++++++------ invokeai/backend/model_manager/merge.py | 18 +++++++++--------- .../services/download/test_download_queue.py | 12 ++++++------ 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/invokeai/app/invocations/segment_anything.py b/invokeai/app/invocations/segment_anything.py index 91517cb8e99..9b0000a247f 100644 --- a/invokeai/app/invocations/segment_anything.py +++ b/invokeai/app/invocations/segment_anything.py @@ -185,9 +185,9 @@ def _filter_masks( # Find the largest mask. return [max(masks, key=lambda x: float(x.sum()))] elif self.mask_filter == "highest_box_score": - assert ( - bounding_boxes is not None - ), "Bounding boxes must be provided to use the 'highest_box_score' mask filter." + assert bounding_boxes is not None, ( + "Bounding boxes must be provided to use the 'highest_box_score' mask filter." + ) assert len(masks) == len(bounding_boxes) # Find the index of the bounding box with the highest score. # Note that we fallback to -1.0 if the score is None. This is mainly to satisfy the type checker. In most diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index b2f4eeecc46..ec63021c698 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -483,9 +483,9 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: try: # Meta is not included in the model fields, so we need to validate it separately config = InvokeAIAppConfig.model_validate(loaded_config_dict) - assert ( - config.schema_version == CONFIG_SCHEMA_VERSION - ), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" + assert config.schema_version == CONFIG_SCHEMA_VERSION, ( + f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" + ) return config except Exception as e: raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index 702001dc798..d652530b0a3 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -204,13 +204,13 @@ def _sync_default_workflows(self) -> None: bytes_ = path.read_bytes() workflow_from_file = WorkflowValidator.validate_json(bytes_) - assert workflow_from_file.id.startswith( - "default_" - ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + assert workflow_from_file.id.startswith("default_"), ( + f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + ) - assert ( - workflow_from_file.meta.category is WorkflowCategory.Default - ), f"Invalid default workflow category: {workflow_from_file.meta.category}" + assert workflow_from_file.meta.category is WorkflowCategory.Default, ( + f"Invalid default workflow category: {workflow_from_file.meta.category}" + ) workflows_from_file.append(workflow_from_file) diff --git a/invokeai/backend/model_manager/merge.py b/invokeai/backend/model_manager/merge.py index b00bc99f3e2..03056b10f59 100644 --- a/invokeai/backend/model_manager/merge.py +++ b/invokeai/backend/model_manager/merge.py @@ -115,19 +115,19 @@ def merge_diffusion_models_and_save( base_models: Set[BaseModelType] = set() variant = None if self._installer.app_config.precision == "float32" else "fp16" - assert ( - len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference - ), "When merging three models, only the 'add_difference' merge method is supported" + assert len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference, ( + "When merging three models, only the 'add_difference' merge method is supported" + ) for key in model_keys: info = store.get_model(key) model_names.append(info.name) - assert isinstance( - info, MainDiffusersConfig - ), f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" - assert info.variant == ModelVariantType( - "normal" - ), f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" + assert isinstance(info, MainDiffusersConfig), ( + f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" + ) + assert info.variant == ModelVariantType("normal"), ( + f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" + ) # tally base models used base_models.add(info.base) diff --git a/tests/app/services/download/test_download_queue.py b/tests/app/services/download/test_download_queue.py index fd2e2a65ae5..1c5c8ec6af4 100644 --- a/tests/app/services/download/test_download_queue.py +++ b/tests/app/services/download/test_download_queue.py @@ -212,12 +212,12 @@ def event_handler(job: DownloadJob | MultiFileDownloadJob, excp: Optional[Except assert job.bytes > 0, "expected download bytes to be positive" assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes" assert job.download_path == tmp_path / "sdxl-turbo" - assert Path( - tmp_path, "sdxl-turbo/model_index.json" - ).exists(), f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" - assert Path( - tmp_path, "sdxl-turbo/text_encoder/config.json" - ).exists(), f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" + assert Path(tmp_path, "sdxl-turbo/model_index.json").exists(), ( + f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" + ) + assert Path(tmp_path, "sdxl-turbo/text_encoder/config.json").exists(), ( + f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" + ) assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED} queue.stop() From bdafe53f2ea43642cbd44068ab69dd8d8ae2b1ca Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 16:01:14 +1000 Subject: [PATCH 057/102] repo: add @jazzhaiku to codeowners for CI, app and backend --- .github/CODEOWNERS | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b979196cc1b..a3214172055 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,12 +1,12 @@ # continuous integration -/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr +/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr @jazzhaiku # documentation /docs/ @lstein @blessedcoolant @hipsterusername @Millu /mkdocs.yml @lstein @blessedcoolant @hipsterusername @Millu # nodes -/invokeai/app/ @Kyle0654 @blessedcoolant @psychedelicious @brandonrising @hipsterusername +/invokeai/app/ @Kyle0654 @blessedcoolant @psychedelicious @brandonrising @hipsterusername @jazzhaiku # installation and configuration /pyproject.toml @lstein @blessedcoolant @hipsterusername @@ -22,7 +22,7 @@ /invokeai/backend @blessedcoolant @psychedelicious @lstein @maryhipp @hipsterusername # generation, model management, postprocessing -/invokeai/backend @damian0815 @lstein @blessedcoolant @gregghelt2 @StAlKeR7779 @brandonrising @ryanjdick @hipsterusername +/invokeai/backend @damian0815 @lstein @blessedcoolant @gregghelt2 @StAlKeR7779 @brandonrising @ryanjdick @hipsterusername @jazzhaiku # front ends /invokeai/frontend/CLI @lstein @hipsterusername From 518a7c941fd00798b208448e756322f100f09161 Mon Sep 17 00:00:00 2001 From: Jonathan <34005131+JPPhoto@users.noreply.github.com> Date: Thu, 6 Mar 2025 09:06:50 -0600 Subject: [PATCH 058/102] Changed version of FluxDenoiseInvocation A Redux field was added but the node version wasn't updated. --- invokeai/app/invocations/flux_denoise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/invocations/flux_denoise.py b/invokeai/app/invocations/flux_denoise.py index 9c4255dee78..6003faf17cb 100644 --- a/invokeai/app/invocations/flux_denoise.py +++ b/invokeai/app/invocations/flux_denoise.py @@ -62,7 +62,7 @@ title="FLUX Denoise", tags=["image", "flux"], category="image", - version="3.2.2", + version="3.2.3", classification=Classification.Prototype, ) class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard): From d5c5e8e8eda2ba1043be8ea18a72eabbf9971bcd Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 27 Feb 2025 13:31:46 -0500 Subject: [PATCH 059/102] another new workflow library --- invokeai/app/api/routers/workflows.py | 4 +- .../workflow_records/workflow_records_base.py | 2 +- .../workflow_records_sqlite.py | 62 +++-- .../frontend/web/src/app/components/App.tsx | 5 +- .../web/src/app/hooks/useStudioInitAction.ts | 4 +- .../WorkflowListMenu/WorkflowList.tsx | 83 ------- .../WorkflowListMenu/WorkflowListItem.tsx | 194 ---------------- .../WorkflowListMenuTrigger.tsx | 85 ++----- .../sidePanel/viewMode/EmptyState.tsx | 6 +- .../WorkflowLibrary}/ShareWorkflowModal.tsx | 0 .../WorkflowLibrary/WorkflowLibraryModal.tsx | 38 +++ .../WorkflowLibraryPagination.tsx | 81 +++++++ .../WorkflowLibrarySideNav.tsx | 89 +++++++ .../WorkflowLibrary/WorkflowLibraryTopNav.tsx | 15 ++ .../workflow/WorkflowLibrary/WorkflowList.tsx | 97 ++++++++ .../WorkflowLibrary/WorkflowListItem.tsx | 219 ++++++++++++++++++ .../WorkflowListItemTooltip.tsx | 0 .../WorkflowLibrary}/WorkflowSearch.tsx | 44 ++-- .../WorkflowLibrary}/WorkflowSortControl.tsx | 64 ++--- .../web/src/features/nodes/store/types.ts | 3 + .../nodes/store/workflowLibraryModal.ts | 6 + .../features/nodes/store/workflowListMenu.ts | 6 - .../src/features/nodes/store/workflowSlice.ts | 14 +- .../LoadWorkflowConfirmationAlertDialog.tsx | 8 +- .../components/UploadWorkflowButton.tsx | 6 +- .../src/services/api/endpoints/workflows.ts | 4 +- .../frontend/web/src/services/api/schema.ts | 4 +- 27 files changed, 682 insertions(+), 461 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx rename invokeai/frontend/web/src/features/nodes/components/sidePanel/{WorkflowListMenu => workflow/WorkflowLibrary}/ShareWorkflowModal.tsx (100%) create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx rename invokeai/frontend/web/src/features/nodes/components/sidePanel/{WorkflowListMenu => workflow/WorkflowLibrary}/WorkflowListItemTooltip.tsx (100%) rename invokeai/frontend/web/src/features/nodes/components/sidePanel/{WorkflowListMenu => workflow/WorkflowLibrary}/WorkflowSearch.tsx (66%) rename invokeai/frontend/web/src/features/nodes/components/sidePanel/{WorkflowListMenu => workflow/WorkflowLibrary}/WorkflowSortControl.tsx (55%) create mode 100644 invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 07f7b9ad0ca..439f4353cef 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -102,13 +102,13 @@ async def list_workflows( default=WorkflowRecordOrderBy.Name, description="The attribute to order by" ), direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"), - category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"), + categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"), query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"), ) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]: """Gets a page of workflows""" workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = [] workflows = ApiDependencies.invoker.services.workflow_records.get_many( - order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, category=category + order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, categories=categories ) for workflow in workflows.items: workflows_with_thumbnails.append( diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py index 9da830eaafe..a884c7b7ed2 100644 --- a/invokeai/app/services/workflow_records/workflow_records_base.py +++ b/invokeai/app/services/workflow_records/workflow_records_base.py @@ -41,7 +41,7 @@ def get_many( self, order_by: WorkflowRecordOrderBy, direction: SQLiteDirection, - category: WorkflowCategory, + categories: Optional[list[WorkflowCategory]], page: int, per_page: Optional[int], query: Optional[str], diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index d652530b0a3..a54fc916a7c 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -120,7 +120,7 @@ def get_many( self, order_by: WorkflowRecordOrderBy, direction: SQLiteDirection, - category: WorkflowCategory, + categories: Optional[list[WorkflowCategory]], page: int = 0, per_page: Optional[int] = None, query: Optional[str] = None, @@ -128,28 +128,50 @@ def get_many( # sanitize! assert order_by in WorkflowRecordOrderBy assert direction in SQLiteDirection - assert category in WorkflowCategory - count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?" - main_query = """ - SELECT - workflow_id, - category, - name, - description, - created_at, - updated_at, - opened_at - FROM workflow_library - WHERE category = ? - """ - main_params: list[int | str] = [category.value] - count_params: list[int | str] = [category.value] + if categories: + assert all(c in WorkflowCategory for c in categories) + count_query = "SELECT COUNT(*) FROM workflow_library WHERE category IN ({})".format( + ", ".join("?" for _ in categories) + ) + main_query = """ + SELECT + workflow_id, + category, + name, + description, + created_at, + updated_at, + opened_at + FROM workflow_library + WHERE category IN ({}) + """.format(", ".join("?" for _ in categories)) + main_params: list[int | str] = [category.value for category in categories] + count_params: list[int | str] = [category.value for category in categories] + else: + count_query = "SELECT COUNT(*) FROM workflow_library" + main_query = """ + SELECT + workflow_id, + category, + name, + description, + created_at, + updated_at, + opened_at + FROM workflow_library + """ + main_params: list[int | str] = [] + count_params: list[int | str] = [] stripped_query = query.strip() if query else None if stripped_query: wildcard_query = "%" + stripped_query + "%" - main_query += " AND name LIKE ? OR description LIKE ? " - count_query += " AND name LIKE ? OR description LIKE ?;" + if categories: + main_query += " AND (name LIKE ? OR description LIKE ?) " + count_query += " AND (name LIKE ? OR description LIKE ?);" + else: + main_query += " WHERE name LIKE ? OR description LIKE ? " + count_query += " WHERE name LIKE ? OR description LIKE ?;" main_params.extend([wildcard_query, wildcard_query]) count_params.extend([wildcard_query, wildcard_query]) @@ -232,7 +254,7 @@ def _sync_default_workflows(self) -> None: library_workflows_from_db = self.get_many( order_by=WorkflowRecordOrderBy.Name, direction=SQLiteDirection.Ascending, - category=WorkflowCategory.Default, + categories=[WorkflowCategory.Default], ).items workflows_from_file_ids = [w.id for w in workflows_from_file] diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index ea9fa813116..78b22e8f8e6 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -26,7 +26,8 @@ import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicP import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; -import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal'; +import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal'; +import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal'; import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { useReadinessWatcher } from 'features/queue/store/readiness'; @@ -50,7 +51,6 @@ import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo'; import { useSocketIO } from 'services/events/useSocketIO'; import AppErrorBoundaryFallback from './AppErrorBoundaryFallback'; - const DEFAULT_CONFIG = {}; interface Props { @@ -129,6 +129,7 @@ const ModalIsolator = memo(() => { + diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index 9b7b38427ed..8af256ad380 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -11,7 +11,7 @@ import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageVi import { sentImageToCanvas } from 'features/gallery/store/actions'; import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers'; import { $hasTemplates } from 'features/nodes/store/nodesSlice'; -import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu'; +import { $isWorkflowLibraryModalOpen } from 'features/nodes/store/workflowLibraryModal'; import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice'; import { toast } from 'features/toast/toast'; import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; @@ -166,7 +166,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => { case 'viewAllWorkflows': // Go to the workflows tab and open the workflow library modal store.dispatch(setActiveTab('workflows')); - $isWorkflowListMenuIsOpen.set(true); + $isWorkflowLibraryModalOpen.set(true); break; case 'viewAllStylePresets': // Go to the canvas tab and open the style presets menu diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx deleted file mode 100644 index 62eaf3316ce..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Button, Collapse, Flex, Icon, Spinner, Text } from '@invoke-ai/ui-library'; -import { EMPTY_ARRAY } from 'app/store/constants'; -import { useAppSelector } from 'app/store/storeHooks'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; -import { useCategorySections } from 'features/nodes/hooks/useCategorySections'; -import { - selectWorkflowOrderBy, - selectWorkflowOrderDirection, - selectWorkflowSearchTerm, -} from 'features/nodes/store/workflowSlice'; -import type { WorkflowCategory } from 'features/nodes/types/workflow'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold } from 'react-icons/pi'; -import { useListWorkflowsQuery } from 'services/api/endpoints/workflows'; - -import { WorkflowListItem } from './WorkflowListItem'; - -export const WorkflowList = ({ category }: { category: WorkflowCategory }) => { - const searchTerm = useAppSelector(selectWorkflowSearchTerm); - const orderBy = useAppSelector(selectWorkflowOrderBy); - const direction = useAppSelector(selectWorkflowOrderDirection); - const { t } = useTranslation(); - - const queryArg = useMemo[0]>(() => { - if (category !== 'default') { - return { - order_by: orderBy, - direction, - category: category, - }; - } - return { - order_by: 'name' as const, - direction: 'ASC' as const, - category: category, - }; - }, [category, direction, orderBy]); - - const { data, isLoading } = useListWorkflowsQuery(queryArg, { - selectFromResult: ({ data, isLoading }) => { - const filteredData = - data?.items.filter((workflow) => workflow.name.toLowerCase().includes(searchTerm.toLowerCase())) || EMPTY_ARRAY; - - return { - data: filteredData, - isLoading, - }; - }, - }); - - const { isOpen, onToggle } = useCategorySections(category); - - return ( - - - - {isLoading ? ( - - - - ) : data.length ? ( - data.map((workflow) => ) - ) : ( - - )} - - - ); -}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx deleted file mode 100644 index 68355552b7d..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { Badge, Flex, IconButton, Spacer, Text, Tooltip } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { $projectUrl } from 'app/store/nanostores/projectId'; -import { useAppSelector } from 'app/store/storeHooks'; -import dateFormat, { masks } from 'dateformat'; -import { selectWorkflowId } from 'features/nodes/store/workflowSlice'; -import { useDeleteWorkflow } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; -import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; -import { useDownloadWorkflowById } from 'features/workflowLibrary/hooks/useDownloadWorkflowById'; -import type { MouseEvent } from 'react'; -import { useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiDownloadSimpleBold, PiPencilBold, PiShareFatBold, PiTrashBold } from 'react-icons/pi'; -import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; - -import { useShareWorkflow } from './ShareWorkflowModal'; -import { WorkflowListItemTooltip } from './WorkflowListItemTooltip'; - -export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { - const { t } = useTranslation(); - const projectUrl = useStore($projectUrl); - const [isHovered, setIsHovered] = useState(false); - - const handleMouseOver = useCallback(() => { - setIsHovered(true); - }, []); - - const handleMouseOut = useCallback(() => { - setIsHovered(false); - }, []); - - const workflowId = useAppSelector(selectWorkflowId); - const { downloadWorkflow, isLoading: isLoadingDownloadWorkflow } = useDownloadWorkflowById(); - const shareWorkflow = useShareWorkflow(); - const deleteWorkflow = useDeleteWorkflow(); - const loadWorkflow = useLoadWorkflow(); - - const isActive = useMemo(() => { - return workflowId === workflow.workflow_id; - }, [workflowId, workflow.workflow_id]); - - const handleClickLoad = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); - }, - [loadWorkflow, workflow.workflow_id] - ); - - const handleClickEdit = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit'); - }, - [loadWorkflow, workflow.workflow_id] - ); - - const handleClickDelete = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - deleteWorkflow(workflow); - }, - [deleteWorkflow, workflow] - ); - - const handleClickShare = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - shareWorkflow(workflow); - }, - [shareWorkflow, workflow] - ); - - const handleClickDownload = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - downloadWorkflow(workflow.workflow_id); - }, - [downloadWorkflow, workflow.workflow_id] - ); - - return ( - - } closeOnScroll> - - - {workflow.name} - - {isActive && ( - - {t('workflows.opened')} - - )} - - {workflow.category !== 'default' && ( - - {t('common.updated')}: {dateFormat(workflow.updated_at, masks.shortDate)}{' '} - {dateFormat(workflow.updated_at, masks.shortTime)} - - )} - - - - - - - } - /> - - - } - isLoading={isLoadingDownloadWorkflow} - /> - - {!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && ( - - } - /> - - )} - {workflow.category !== 'default' && ( - - } - /> - - )} - - - ); -}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx index c2fe14669f4..2b8fe8bd20d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx @@ -1,82 +1,27 @@ -import { - Box, - Button, - Flex, - Popover, - PopoverBody, - PopoverContent, - PopoverTrigger, - Portal, - Text, -} from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; +import { Button, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; -import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu'; +import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; import { selectWorkflowName } from 'features/nodes/store/workflowSlice'; -import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton'; -import UploadWorkflowButton from 'features/workflowLibrary/components/UploadWorkflowButton'; -import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFolderOpenFill } from 'react-icons/pi'; -import { WorkflowList } from './WorkflowList'; -import { WorkflowSearch } from './WorkflowSearch'; -import { WorkflowSortControl } from './WorkflowSortControl'; - export const WorkflowListMenuTrigger = () => { - const workflowListMenu = useWorkflowListMenu(); + const workflowLibraryModal = useWorkflowLibraryModal(); const { t } = useTranslation(); - const workflowCategories = useStore($workflowCategories); - const searchInputRef = useRef(null); const workflowName = useAppSelector(selectWorkflowName); return ( - - - - - - - - - - - - - - - - - {workflowCategories.map((category) => ( - - ))} - - - - - - - + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx index b23803ef22c..861ca8adcef 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx @@ -1,6 +1,6 @@ import { Button, Flex, Image, Link, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu'; +import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; import { selectCleanEditor, workflowModeChanged } from 'features/nodes/store/workflowSlice'; import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg'; import { useCallback } from 'react'; @@ -40,7 +40,7 @@ export const EmptyState = () => { const CleanEditorContent = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const workflowListMenu = useWorkflowListMenu(); + const workflowLibraryModal = useWorkflowLibraryModal(); const onClickNewWorkflow = useCallback(() => { dispatch(workflowModeChanged('edit')); @@ -52,7 +52,7 @@ const CleanEditorContent = () => { - diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal.tsx similarity index 100% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal.tsx diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx new file mode 100644 index 00000000000..940dec45706 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx @@ -0,0 +1,38 @@ +import { + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, +} from '@invoke-ai/ui-library'; +import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; + +import { WorkflowLibrarySideNav } from './WorkflowLibrarySideNav'; +import { WorkflowLibraryTopNav } from './WorkflowLibraryTopNav'; +import { WorkflowList } from './WorkflowList'; + +export const WorkflowLibraryModal = () => { + const workflowLibraryModal = useWorkflowLibraryModal(); + return ( + + + + Workflow Library + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx new file mode 100644 index 00000000000..7a076a15dbf --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx @@ -0,0 +1,81 @@ +import { Button, Flex, IconButton } from '@invoke-ai/ui-library'; +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; +import type { paths } from 'services/api/schema'; + +const PAGES_TO_DISPLAY = 5; + +type PageData = { + page: number; + onClick: () => void; +}; + +type Props = { + page: number; + setPage: Dispatch>; + data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json']; +}; + +export const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => { + const { t } = useTranslation(); + + const handlePrevPage = useCallback(() => { + setPage((p) => Math.max(p - 1, 0)); + }, [setPage]); + + const handleNextPage = useCallback(() => { + setPage((p) => Math.min(p + 1, data.pages - 1)); + }, [data.pages, setPage]); + + const pages: PageData[] = useMemo(() => { + const pages = []; + let first = data.pages > PAGES_TO_DISPLAY ? Math.max(0, page - Math.floor(PAGES_TO_DISPLAY / 2)) : 0; + const last = data.pages > PAGES_TO_DISPLAY ? Math.min(data.pages, first + PAGES_TO_DISPLAY) : data.pages; + if (last - first < PAGES_TO_DISPLAY && data.pages > PAGES_TO_DISPLAY) { + first = last - PAGES_TO_DISPLAY; + } + for (let i = first; i < last; i++) { + pages.push({ + page: i, + onClick: () => setPage(i), + }); + } + return pages; + }, [data.pages, page, setPage]); + + return ( + + } + /> + + {pages.map((p) => ( + + ))} + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx new file mode 100644 index 00000000000..753551490b1 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -0,0 +1,89 @@ +/* eslint-disable i18next/no-literal-string */ +import { Button, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; +import { useAppSelector } from 'app/store/storeHooks'; +import type { WorkflowLibraryCategory } from 'features/nodes/store/types'; +import { selectWorkflowBrowsingCategory, workflowBrowsingCategoryChanged } from 'features/nodes/store/workflowSlice'; +import { useCallback } from 'react'; +import { PiUsersBold } from 'react-icons/pi'; +import { useDispatch } from 'react-redux'; + +export const WorkflowLibrarySideNav = () => { + const dispatch = useDispatch(); + const browsingCategory = useAppSelector(selectWorkflowBrowsingCategory); + const workflowCategories = useStore($workflowCategories); + + const handleCategoryChange = useCallback( + (category: WorkflowLibraryCategory) => { + dispatch(workflowBrowsingCategoryChanged(category)); + }, + [dispatch] + ); + + return ( + + + {workflowCategories.includes('project') && ( + + + + + )} + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx new file mode 100644 index 00000000000..01c7545e237 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx @@ -0,0 +1,15 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { useRef } from 'react'; + +import { WorkflowSearch } from './WorkflowSearch'; +import { WorkflowSortControl } from './WorkflowSortControl'; + +export const WorkflowLibraryTopNav = () => { + const searchInputRef = useRef(null); + return ( + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx new file mode 100644 index 00000000000..2655fc35f1c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx @@ -0,0 +1,97 @@ +import { Flex, Grid, GridItem, Spinner } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import type { WorkflowLibraryCategory } from 'features/nodes/store/types'; +import { + selectWorkflowBrowsingCategory, + selectWorkflowOrderBy, + selectWorkflowOrderDirection, + selectWorkflowSearchTerm, +} from 'features/nodes/store/workflowSlice'; +import type { WorkflowCategory } from 'features/nodes/types/workflow'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useListWorkflowsQuery } from 'services/api/endpoints/workflows'; +import { useDebounce } from 'use-debounce'; + +import { WorkflowLibraryPagination } from './WorkflowLibraryPagination'; +import { WorkflowListItem } from './WorkflowListItem'; + +const PER_PAGE = 6; + +const mapUiCategoryToApiCategory = (sideNav: WorkflowLibraryCategory): WorkflowCategory[] => { + switch (sideNav) { + case 'account': + return ['user', 'project']; + case 'private': + return ['user']; + case 'shared': + return ['project']; + case 'default': + return ['default']; + default: + return []; + } +}; + +export const WorkflowList = () => { + const searchTerm = useAppSelector(selectWorkflowSearchTerm); + const { t } = useTranslation(); + + const [page, setPage] = useState(0); + const browsingCategory = useAppSelector(selectWorkflowBrowsingCategory); + const orderBy = useAppSelector(selectWorkflowOrderBy); + const direction = useAppSelector(selectWorkflowOrderDirection); + const query = useAppSelector(selectWorkflowSearchTerm); + const [debouncedQuery] = useDebounce(query, 500); + + useEffect(() => { + setPage(0); + }, [browsingCategory, query]); + + const queryArg = useMemo[0]>(() => { + const categories = mapUiCategoryToApiCategory(browsingCategory); + return { + page, + per_page: PER_PAGE, + order_by: orderBy, + direction, + categories, + query: debouncedQuery, + }; + }, [direction, orderBy, page, browsingCategory, debouncedQuery]); + + const { data, isLoading } = useListWorkflowsQuery(queryArg); + + if (isLoading) { + return ( + + + + ); + } + + if (!data?.items.length) { + return ( + + ); + } + + return ( + + + {data?.items.map((workflow) => ( + + + + ))} + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx new file mode 100644 index 00000000000..cd71935a91b --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -0,0 +1,219 @@ +import { Badge, Button, Flex, Icon, IconButton, Image, Spacer, Text, Tooltip } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $projectUrl } from 'app/store/nanostores/projectId'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectWorkflowId } from 'features/nodes/store/workflowSlice'; +import { useDeleteWorkflow } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; +import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; +import { useDownloadWorkflowById } from 'features/workflowLibrary/hooks/useDownloadWorkflowById'; +import type { MouseEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + PiDownloadSimpleBold, + PiImageBold, + PiPencilBold, + PiShareFatBold, + PiTrashBold, + PiUsersBold, +} from 'react-icons/pi'; +import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; + +import { useShareWorkflow } from './ShareWorkflowModal'; + +const IMAGE_THUMBNAIL_SIZE = '80px'; +const FALLBACK_ICON_SIZE = '24px'; + +export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { + const { t } = useTranslation(); + const projectUrl = useStore($projectUrl); + const [isHovered, setIsHovered] = useState(false); + + const handleMouseOver = useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseOut = useCallback(() => { + setIsHovered(false); + }, []); + + const workflowId = useAppSelector(selectWorkflowId); + const downloadWorkflowById = useDownloadWorkflowById(); + const shareWorkflow = useShareWorkflow(); + const deleteWorkflow = useDeleteWorkflow(); + const loadWorkflow = useLoadWorkflow(); + + const isActive = useMemo(() => { + return workflowId === workflow.workflow_id; + }, [workflowId, workflow.workflow_id]); + + const handleClickLoad = useCallback(() => { + setIsHovered(false); + loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); + }, [loadWorkflow, workflow.workflow_id]); + + const handleClickEdit = useCallback(() => { + setIsHovered(false); + loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit'); + }, [loadWorkflow, workflow.workflow_id]); + + const handleClickDelete = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + deleteWorkflow(workflow); + }, + [deleteWorkflow, workflow] + ); + + const handleClickShare = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + shareWorkflow(workflow); + }, + [shareWorkflow, workflow] + ); + + const handleClickDownload = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + downloadWorkflowById.downloadWorkflow(workflow.workflow_id); + }, + [downloadWorkflowById, workflow.workflow_id] + ); + + return ( + + + + + } + objectFit="cover" + objectPosition="50% 50%" + height={IMAGE_THUMBNAIL_SIZE} + width={IMAGE_THUMBNAIL_SIZE} + minHeight={IMAGE_THUMBNAIL_SIZE} + minWidth={IMAGE_THUMBNAIL_SIZE} + borderRadius="base" + /> + + + {workflow.name} + + {isActive && ( + + {t('workflows.opened')} + + )} + + + {workflow.description} + + + + + + + {workflow.category === 'project' && } + {workflow.category === 'default' && } + + + {workflow.category !== 'default' ? ( + + + } + /> + + + } + isLoading={downloadWorkflowById.isLoading} + /> + + {!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && ( + + } + /> + + )} + + } + /> + + + ) : ( + + + + + )} + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx similarity index 100% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx similarity index 66% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSearch.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx index 145c48f29b1..716c24269a4 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSearch.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx @@ -1,4 +1,4 @@ -import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library'; +import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectWorkflowSearchTerm, workflowSearchTermChanged } from 'features/nodes/store/workflowSlice'; import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'; @@ -46,26 +46,28 @@ export const WorkflowSearch = memo(({ searchInputRef }: { searchInputRef: RefObj }, [searchInputRef]); return ( - - - {searchTerm && searchTerm.length && ( - - } - /> - - )} - + + + + {searchTerm && searchTerm.length && ( + + } + /> + + )} + + ); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSortControl.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx similarity index 55% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSortControl.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx index c80d8f0b874..f43c5b3942f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSortControl.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx @@ -1,14 +1,4 @@ -import { - Flex, - FormControl, - FormLabel, - IconButton, - Popover, - PopoverBody, - PopoverContent, - PopoverTrigger, - Select, -} from '@invoke-ai/ui-library'; +import { Flex, FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $projectId } from 'app/store/nanostores/projectId'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -21,7 +11,6 @@ import { import type { ChangeEvent } from 'react'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiSortAscendingBold, PiSortDescendingBold } from 'react-icons/pi'; import { z } from 'zod'; const zOrderBy = z.enum(['opened_at', 'created_at', 'updated_at', 'name']); @@ -83,38 +72,23 @@ export const WorkflowSortControl = () => { const defaultOrderBy = projectId !== undefined ? 'opened_at' : 'created_at'; return ( - - - : } - variant="ghost" - /> - - - - - - - {t('common.orderBy')} - - - - {t('common.direction')} - - - - - - + + + {t('common.orderBy')} + + + + {t('common.direction')} + + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index c5ee26665b2..4f63cb72d4c 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -22,10 +22,13 @@ export type NodesState = { export type WorkflowMode = 'edit' | 'view'; +export type WorkflowLibraryCategory = 'account' | 'private' | 'shared' | 'favorites' | `default`; + export type WorkflowsState = Omit & { _version: 1; isTouched: boolean; mode: WorkflowMode; + browsingCategory: WorkflowLibraryCategory; searchTerm: string; orderBy?: WorkflowRecordOrderBy; orderDirection: SQLiteDirection; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts new file mode 100644 index 00000000000..bf490f67dc7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts @@ -0,0 +1,6 @@ +import { buildUseDisclosure } from 'common/hooks/useBoolean'; + +/** + * Tracks the state for the workflow library modal. + */ +export const [useWorkflowLibraryModal, $isWorkflowLibraryModalOpen] = buildUseDisclosure(false); diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts b/invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts deleted file mode 100644 index cfc182046c3..00000000000 --- a/invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { buildUseDisclosure } from 'common/hooks/useBoolean'; - -/** - * Tracks the state for the workflow list menu. - */ -export const [useWorkflowListMenu, $isWorkflowListMenuIsOpen] = buildUseDisclosure(false); diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index 7dce3bfb26b..2b974e92755 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -11,7 +11,12 @@ import { } from 'features/nodes/components/sidePanel/builder/form-manipulation'; import { workflowLoaded } from 'features/nodes/store/actions'; import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged } from 'features/nodes/store/nodesSlice'; -import type { NodesState, WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types'; +import type { + NodesState, + WorkflowLibraryCategory, + WorkflowMode, + WorkflowsState as WorkflowState, +} from 'features/nodes/store/types'; import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field'; import { isInvocationNode } from 'features/nodes/types/invocation'; import type { @@ -82,6 +87,7 @@ const initialWorkflowState: WorkflowState = { orderBy: undefined, // initial value is decided in component orderDirection: 'DESC', categorySections: {}, + browsingCategory: 'account', ...getBlankWorkflow(), }; @@ -101,6 +107,10 @@ export const workflowSlice = createSlice({ workflowOrderDirectionChanged: (state, action: PayloadAction) => { state.orderDirection = action.payload; }, + workflowBrowsingCategoryChanged: (state, action: PayloadAction) => { + state.browsingCategory = action.payload; + state.searchTerm = ''; + }, categorySectionsChanged: (state, action: PayloadAction<{ id: string; isOpen: boolean }>) => { const { id, isOpen } = action.payload; state.categorySections[id] = isOpen; @@ -299,6 +309,7 @@ export const { workflowSearchTermChanged, workflowOrderByChanged, workflowOrderDirectionChanged, + workflowBrowsingCategoryChanged, categorySectionsChanged, formReset, formElementAdded, @@ -365,6 +376,7 @@ export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => work export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm); export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy); export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection); +export const selectWorkflowBrowsingCategory = createWorkflowSelector((workflow) => workflow.browsingCategory); export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description); export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx index 1f639a9bc8c..9deda79605a 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx @@ -2,7 +2,7 @@ import { ConfirmationAlertDialog, Flex, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; -import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu'; +import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; import { selectWorkflowIsTouched, workflowModeChanged } from 'features/nodes/store/workflowSlice'; import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow'; import { atom } from 'nanostores'; @@ -14,7 +14,7 @@ const cleanup = () => $workflowToLoad.set(null); export const useLoadWorkflow = () => { const dispatch = useAppDispatch(); - const workflowListMenu = useWorkflowListMenu(); + const workflowLibraryModal = useWorkflowLibraryModal(); const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow(); const isTouched = useAppSelector(selectWorkflowIsTouched); @@ -28,8 +28,8 @@ export const useLoadWorkflow = () => { await getAndLoadWorkflow(workflowId); dispatch(workflowModeChanged(mode)); cleanup(); - workflowListMenu.close(); - }, [dispatch, getAndLoadWorkflow, workflowListMenu]); + workflowLibraryModal.close(); + }, [dispatch, getAndLoadWorkflow, workflowLibraryModal]); const loadWithDialog = useCallback( (workflowId: string, mode: 'view' | 'edit') => { diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx index 1695206041f..d41e0f29842 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx @@ -1,5 +1,5 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu'; +import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog'; import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile'; import { memo, useCallback, useRef } from 'react'; @@ -10,12 +10,12 @@ import { PiUploadSimpleBold } from 'react-icons/pi'; const UploadWorkflowButton = () => { const { t } = useTranslation(); const resetRef = useRef<() => void>(null); - const workflowListMenu = useWorkflowListMenu(); + const workflowLibraryModal = useWorkflowLibraryModal(); const loadWorkflowFromFile = useLoadWorkflowFromFile({ resetRef, onSuccess: (workflow) => { - workflowListMenu.close(); + workflowLibraryModal.close(); saveWorkflowAs(workflow); }, }); diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts index 23d4763f856..31650170db3 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts @@ -1,6 +1,7 @@ import type { paths } from 'services/api/schema'; import { api, buildV1Url, LIST_TAG } from '..'; +import queryString from 'query-string'; /** * Builds an endpoint URL for the workflows router @@ -73,8 +74,7 @@ export const workflowsApi = api.injectEndpoints({ NonNullable >({ query: (params) => ({ - url: buildWorkflowsUrl(), - params, + url: `${buildWorkflowsUrl()}?${queryString.stringify(params, { arrayFormat: 'none' })}`, }), providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }], }), diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 926381bde93..714250405be 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -24252,8 +24252,8 @@ export interface operations { order_by?: components["schemas"]["WorkflowRecordOrderBy"]; /** @description The direction to order by */ direction?: components["schemas"]["SQLiteDirection"]; - /** @description The category of workflow to get */ - category?: components["schemas"]["WorkflowCategory"]; + /** @description The categories of workflow to get */ + categories?: components["schemas"]["WorkflowCategory"][] | null; /** @description The text to query by (matches name and description) */ query?: string | null; }; From e8db1c1d5a373b816c642a6aa10d78bc04eae748 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 28 Feb 2025 13:47:43 -0500 Subject: [PATCH 060/102] break out actions, start on marketplace categories --- invokeai/app/api/routers/workflows.py | 3 + .../DeleteWorkflow.tsx | 45 ++++++ .../DownloadWorkflow.tsx | 42 +++++ .../EditWorkflow.tsx | 40 +++++ .../SaveWorkflow.tsx | 40 +++++ .../ShareWorkflow.tsx | 53 +++++++ .../ViewWorkflow.tsx | 40 +++++ .../WorkflowLibrarySideNav.tsx | 31 ++++ .../workflow/WorkflowLibrary/WorkflowList.tsx | 2 +- .../WorkflowLibrary/WorkflowListItem.tsx | 147 ++++-------------- ...LibraryWorkflowConfirmationAlertDialog.tsx | 9 +- .../frontend/web/src/services/api/schema.ts | 2 + 12 files changed, 327 insertions(+), 127 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 439f4353cef..77286606bee 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -103,6 +103,9 @@ async def list_workflows( ), direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"), categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"), + marketplace_categories: Optional[list[str]] = Query( + default=None, description="The categories of marketplace workflow to get" + ), query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"), ) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]: """Gets a page of workflows""" diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx new file mode 100644 index 00000000000..4778243f779 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DeleteWorkflow.tsx @@ -0,0 +1,45 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useDeleteWorkflow } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashBold } from 'react-icons/pi'; + +export const DeleteWorkflow = ({ + isHovered, + setIsHovered, + workflowId, +}: { + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; + workflowId: string; +}) => { + const { t } = useTranslation(); + const deleteWorkflow = useDeleteWorkflow(); + + const handleClickDelete = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + deleteWorkflow(workflowId); + }, + [deleteWorkflow, workflowId, setIsHovered] + ); + return ( + + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx new file mode 100644 index 00000000000..431a1165fd3 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx @@ -0,0 +1,42 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useDownloadWorkflow } from 'features/workflowLibrary/hooks/useDownloadWorkflow'; +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDownloadSimpleBold } from 'react-icons/pi'; + +export const DownloadWorkflow = ({ + isHovered, + setIsHovered, +}: { + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; +}) => { + const downloadWorkflow = useDownloadWorkflow(); + const handleClickDownload = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + downloadWorkflow(); + }, + [downloadWorkflow, setIsHovered] + ); + + const { t } = useTranslation(); + return ( + + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx new file mode 100644 index 00000000000..b9651b917f4 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/EditWorkflow.tsx @@ -0,0 +1,40 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiPencilBold } from 'react-icons/pi'; + +export const EditWorkflow = ({ + isHovered, + setIsHovered, + workflowId, +}: { + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; + workflowId: string; +}) => { + const loadWorkflow = useLoadWorkflow(); + const { t } = useTranslation(); + + const handleClickEdit = useCallback(() => { + setIsHovered(false); + loadWorkflow.loadWithDialog(workflowId, 'edit'); + }, [loadWorkflow, workflowId, setIsHovered]); + + return ( + + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx new file mode 100644 index 00000000000..95beaaaf178 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx @@ -0,0 +1,40 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiEyeBold, PiFloppyDiskBold } from 'react-icons/pi'; + +export const SaveWorkflow = ({ + isHovered, + setIsHovered, + workflowId, +}: { + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; + workflowId: string; +}) => { + const loadWorkflow = useLoadWorkflow(); + const { t } = useTranslation(); + + const handleClickSave = useCallback(() => { + setIsHovered(false); + loadWorkflow.loadWithDialog(workflowId, 'view'); + }, [loadWorkflow, workflowId, setIsHovered]); + + return ( + + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx new file mode 100644 index 00000000000..c3f8b9a2752 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow.tsx @@ -0,0 +1,53 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $projectUrl } from 'app/store/nanostores/projectId'; +import { useShareWorkflow } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal'; +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiShareFatBold } from 'react-icons/pi'; +import type { WorkflowRecordListItemDTO } from 'services/api/types'; + +export const ViewWorkflow = ({ + isHovered, + setIsHovered, + workflow, +}: { + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; + workflow: WorkflowRecordListItemDTO; +}) => { + const projectUrl = useStore($projectUrl); + const shareWorkflow = useShareWorkflow(); + const { t } = useTranslation(); + + const handleClickShare = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setIsHovered(false); + shareWorkflow(workflow); + }, + [shareWorkflow, workflow, setIsHovered] + ); + + if (!projectUrl || !workflow.workflow_id || workflow.category === 'user') { + return null; + } + + return ( + + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx new file mode 100644 index 00000000000..0c01596616f --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ViewWorkflow.tsx @@ -0,0 +1,40 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiEyeBold } from 'react-icons/pi'; + +export const ViewWorkflow = ({ + isHovered, + setIsHovered, + workflowId, +}: { + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; + workflowId: string; +}) => { + const loadWorkflow = useLoadWorkflow(); + const { t } = useTranslation(); + + const handleClickLoad = useCallback(() => { + setIsHovered(false); + loadWorkflow.loadWithDialog(workflowId, 'view'); + }, [loadWorkflow, workflowId, setIsHovered]); + + return ( + + } + /> + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 753551490b1..50474584aba 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -84,6 +84,37 @@ export const WorkflowLibrarySideNav = () => { > Browse Workflows + + + + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx index 2655fc35f1c..b5142b0c1ae 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx @@ -84,7 +84,7 @@ export const WorkflowList = () => { return ( - + {data?.items.map((workflow) => ( diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx index cd71935a91b..c6b297324d5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -1,32 +1,24 @@ -import { Badge, Button, Flex, Icon, IconButton, Image, Spacer, Text, Tooltip } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { $projectUrl } from 'app/store/nanostores/projectId'; +import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { selectWorkflowId } from 'features/nodes/store/workflowSlice'; -import { useDeleteWorkflow } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; -import { useDownloadWorkflowById } from 'features/workflowLibrary/hooks/useDownloadWorkflowById'; -import type { MouseEvent } from 'react'; +import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - PiDownloadSimpleBold, - PiImageBold, - PiPencilBold, - PiShareFatBold, - PiTrashBold, - PiUsersBold, -} from 'react-icons/pi'; +import { PiImageBold, PiUsersBold } from 'react-icons/pi'; import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; -import { useShareWorkflow } from './ShareWorkflowModal'; +import { DeleteWorkflow } from './WorkflowLibraryListItemActions/DeleteWorkflow'; +import { DownloadWorkflow } from './WorkflowLibraryListItemActions/DownloadWorkflow'; +import { EditWorkflow } from './WorkflowLibraryListItemActions/EditWorkflow'; +import { SaveWorkflow } from './WorkflowLibraryListItemActions/SaveWorkflow'; +import { ViewWorkflow } from './WorkflowLibraryListItemActions/ViewWorkflow'; const IMAGE_THUMBNAIL_SIZE = '80px'; const FALLBACK_ICON_SIZE = '24px'; export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { const { t } = useTranslation(); - const projectUrl = useStore($projectUrl); const [isHovered, setIsHovered] = useState(false); const handleMouseOver = useCallback(() => { @@ -38,9 +30,6 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte }, []); const workflowId = useAppSelector(selectWorkflowId); - const downloadWorkflowById = useDownloadWorkflowById(); - const shareWorkflow = useShareWorkflow(); - const deleteWorkflow = useDeleteWorkflow(); const loadWorkflow = useLoadWorkflow(); const isActive = useMemo(() => { @@ -52,38 +41,6 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); }, [loadWorkflow, workflow.workflow_id]); - const handleClickEdit = useCallback(() => { - setIsHovered(false); - loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit'); - }, [loadWorkflow, workflow.workflow_id]); - - const handleClickDelete = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - deleteWorkflow(workflow); - }, - [deleteWorkflow, workflow] - ); - - const handleClickShare = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - shareWorkflow(workflow); - }, - [shareWorkflow, workflow] - ); - - const handleClickDownload = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - setIsHovered(false); - downloadWorkflowById.downloadWorkflow(workflow.workflow_id); - }, - [downloadWorkflowById, workflow.workflow_id] - ); - return ( {workflow.category === 'project' && } - {workflow.category === 'default' && } + {workflow.category === 'default' && ( + invoke-logo + )} - {workflow.category !== 'default' ? ( - - - } - /> - - - } - isLoading={downloadWorkflowById.isLoading} - /> - - {!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && ( - - } - /> - - )} - - } - /> - - - ) : ( - - - - - )} + + {workflow.category === 'default' && ( + <> + + + + )} + {workflow.category !== 'default' && ( + <> + + + + + )} + ); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx index de46c659997..3d37ddb6af4 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog.tsx @@ -6,14 +6,13 @@ import { atom } from 'nanostores'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useDeleteWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; -import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; -const $workflowToDelete = atom(null); +const $workflowToDelete = atom(null); const clearWorkflowToDelete = () => $workflowToDelete.set(null); export const useDeleteWorkflow = () => { - const deleteWorkflow = useCallback((workflow: WorkflowRecordListItemWithThumbnailDTO) => { - $workflowToDelete.set(workflow); + const deleteWorkflow = useCallback((workflowId: string) => { + $workflowToDelete.set(workflowId); }, []); return deleteWorkflow; @@ -30,7 +29,7 @@ export const DeleteWorkflowDialog = () => { return; } try { - await _deleteWorkflow(workflowToDelete.workflow_id).unwrap(); + await _deleteWorkflow(workflowToDelete).unwrap(); toast({ id: 'WORKFLOW_DELETED', title: t('toast.workflowDeleted'), diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 714250405be..00fc2e0d6cf 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -24254,6 +24254,8 @@ export interface operations { direction?: components["schemas"]["SQLiteDirection"]; /** @description The categories of workflow to get */ categories?: components["schemas"]["WorkflowCategory"][] | null; + /** @description The categories of marketplace workflow to get */ + marketplace_categories?: string[] | null; /** @description The text to query by (matches name and description) */ query?: string | null; }; From 2594eed1af47029b722631eb1c76420cdf800dbd Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 28 Feb 2025 15:06:50 -0500 Subject: [PATCH 061/102] add comments --- invokeai/app/api/routers/workflows.py | 4 +- .../DownloadWorkflow.tsx | 1 + .../SaveWorkflow.tsx | 2 + .../WorkflowLibraryPagination.tsx | 1 + .../WorkflowLibrarySideNav.tsx | 53 ++++++++++++------- .../workflow/WorkflowLibrary/WorkflowList.tsx | 26 ++------- .../WorkflowLibrary/WorkflowListItem.tsx | 1 + .../web/src/features/nodes/store/types.ts | 6 +-- .../src/features/nodes/store/workflowSlice.ts | 17 +++--- .../frontend/web/src/services/api/schema.ts | 4 +- 10 files changed, 53 insertions(+), 62 deletions(-) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 77286606bee..b3fa706aa2f 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -103,9 +103,7 @@ async def list_workflows( ), direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"), categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"), - marketplace_categories: Optional[list[str]] = Query( - default=None, description="The categories of marketplace workflow to get" - ), + tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"), query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"), ) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]: """Gets a page of workflows""" diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx index 431a1165fd3..fdf13682be1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/DownloadWorkflow.tsx @@ -5,6 +5,7 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiDownloadSimpleBold } from 'react-icons/pi'; +// needs to be updated to work for a workflow other than the one loaded in editor export const DownloadWorkflow = ({ isHovered, setIsHovered, diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx index 95beaaaf178..ec4616fa60b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/SaveWorkflow.tsx @@ -4,6 +4,8 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiEyeBold, PiFloppyDiskBold } from 'react-icons/pi'; + +// needs to clone and save workflow to account without taking over editor export const SaveWorkflow = ({ isHovered, setIsHovered, diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx index 7a076a15dbf..f7436b85954 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx @@ -18,6 +18,7 @@ type Props = { data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json']; }; +// kent and devon want to make this infinite scroll export const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 50474584aba..c89b6043c91 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -3,24 +3,40 @@ import { Button, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; import { useAppSelector } from 'app/store/storeHooks'; -import type { WorkflowLibraryCategory } from 'features/nodes/store/types'; -import { selectWorkflowBrowsingCategory, workflowBrowsingCategoryChanged } from 'features/nodes/store/workflowSlice'; -import { useCallback } from 'react'; +import { selectWorkflowCategories, workflowCategoriesChanged } from 'features/nodes/store/workflowSlice'; +import type { WorkflowCategory } from 'features/nodes/types/workflow'; +import { useCallback, useMemo } from 'react'; import { PiUsersBold } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; export const WorkflowLibrarySideNav = () => { const dispatch = useDispatch(); - const browsingCategory = useAppSelector(selectWorkflowBrowsingCategory); - const workflowCategories = useStore($workflowCategories); + const categories = useAppSelector(selectWorkflowCategories); + const categoryOptions = useStore($workflowCategories); const handleCategoryChange = useCallback( - (category: WorkflowLibraryCategory) => { - dispatch(workflowBrowsingCategoryChanged(category)); + (categories: WorkflowCategory[]) => { + dispatch(workflowCategoriesChanged(categories)); }, [dispatch] ); + const handleSelectYourWorkflows = useCallback(() => { + if (categoryOptions.includes('project')) { + handleCategoryChange(['user', 'project']); + } else { + handleCategoryChange(['user']); + } + }, [categoryOptions, handleCategoryChange]); + + const isYourWorkflowsActive = useMemo(() => { + if (categoryOptions.includes('project')) { + return categories.includes('user') && categories.includes('project'); + } else { + return categories.includes('user'); + } + }, [categoryOptions, categories]); + return ( - {workflowCategories.includes('project') && ( + {categoryOptions.includes('project') && ( + {/* these are obviously placeholders - we need to figure out the best way to do this. leaning towards "tags" so that we can filter and/or have multiple selected eventually */} + + ); + } +); +WorkflowListContent.displayName = 'WorkflowListContent'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx index 29f2065c68a..6c555a02fe7 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -1,9 +1,10 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { selectWorkflowId } from 'features/nodes/store/workflowSlice'; import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiImageBold, PiUsersBold } from 'react-icons/pi'; import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; @@ -17,17 +18,19 @@ import { ViewWorkflow } from './WorkflowLibraryListItemActions/ViewWorkflow'; const IMAGE_THUMBNAIL_SIZE = '80px'; const FALLBACK_ICON_SIZE = '24px'; -export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { - const { t } = useTranslation(); - const [isHovered, setIsHovered] = useState(false); +const WORKFLOW_ACTION_BUTTONS_CN = 'workflow-action-buttons'; - const handleMouseOver = useCallback(() => { - setIsHovered(true); - }, []); +const sx: SystemStyleObject = { + _hover: { + bg: 'base.700', + [`& .${WORKFLOW_ACTION_BUTTONS_CN}`]: { + display: 'flex', + }, + }, +}; - const handleMouseOut = useCallback(() => { - setIsHovered(false); - }, []); +export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { + const { t } = useTranslation(); const workflowId = useAppSelector(selectWorkflowId); const loadWorkflow = useLoadWorkflow(); @@ -37,24 +40,22 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte }, [workflowId, workflow.workflow_id]); const handleClickLoad = useCallback(() => { - setIsHovered(false); loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); }, [loadWorkflow, workflow.workflow_id]); return ( - + {workflow.category === 'project' && } {workflow.category === 'default' && ( @@ -103,7 +104,15 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte )} - + {workflow.category === 'default' && ( <> {/* need to consider what is useful here and which icons show that. idea is to "try it out"/"view" or "clone for your own changes" */} From e8aed67cf1582198cefb6f92f5524743b65ce8c2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:43:43 +1000 Subject: [PATCH 075/102] feat(app): add workflow library get_counts method Get the counts of workflows for the given tags and/or categories. Made a separate method bc get_many will deserialize all matching workflows, which is unnecessary for this use case. --- invokeai/app/api/routers/workflows.py | 10 +++ .../workflow_records/workflow_records_base.py | 9 +++ .../workflow_records_sqlite.py | 61 +++++++++++++++++-- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index f9063b6fc95..e6558b22ad4 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -219,3 +219,13 @@ async def get_workflow_thumbnail( return response except Exception: raise HTTPException(status_code=404) + + +@workflows_router.get("/counts", operation_id="get_counts") +async def get_counts( + tags: Optional[list[str]] = Query(default=None, description="The tags to include"), + categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"), +) -> int: + """Gets a the count of workflows that include the specified tags and categories""" + + return ApiDependencies.invoker.services.workflow_records.get_counts(tags=tags, categories=categories) diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py index 1fbdbf66ba8..d98936a441c 100644 --- a/invokeai/app/services/workflow_records/workflow_records_base.py +++ b/invokeai/app/services/workflow_records/workflow_records_base.py @@ -49,3 +49,12 @@ def get_many( ) -> PaginatedResults[WorkflowRecordListItemDTO]: """Gets many workflows.""" pass + + @abstractmethod + def get_counts( + self, + tags: Optional[list[str]], + categories: Optional[list[WorkflowCategory]], + ) -> int: + """Gets the count of workflows for the given tags and categories.""" + pass diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index 8b7fdaa1fc9..a2334b07006 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -237,6 +237,55 @@ def get_many( total=total, ) + def get_counts( + self, + tags: Optional[list[str]], + categories: Optional[list[WorkflowCategory]], + ) -> int: + cursor = self._conn.cursor() + + # Start with an empty list of conditions and params + conditions: list[str] = [] + params: list[str | int] = [] + + if tags: + # Construct a list of conditions for each tag + tags_conditions = ["tags LIKE ?" for _ in tags] + tags_conditions_joined = " OR ".join(tags_conditions) + tags_condition = f"({tags_conditions_joined})" + + # And the params for the tags, case-insensitive + tags_params = [f"%{t.strip()}%" for t in tags] + + conditions.append(tags_condition) + params.extend(tags_params) + + if categories: + # Ensure all categories are valid (is this necessary?) + assert all(c in WorkflowCategory for c in categories) + + # Construct a placeholder string for the number of categories + placeholders = ", ".join("?" for _ in categories) + + # Construct the condition string & params + conditions.append(f"category IN ({placeholders})") + params.extend([category.value for category in categories]) + + stmt = """--sql + SELECT COUNT(*) + FROM workflow_library + """ + + if conditions: + # If there are conditions, add a WHERE clause and then join the conditions + stmt += " WHERE " + + all_conditions = " AND ".join(conditions) + stmt += all_conditions + + cursor.execute(stmt, tuple(params)) + return cursor.fetchone()[0] + def _sync_default_workflows(self) -> None: """Syncs default workflows to the database. Internal use only.""" @@ -261,13 +310,13 @@ def _sync_default_workflows(self) -> None: bytes_ = path.read_bytes() workflow_from_file = WorkflowValidator.validate_json(bytes_) - assert workflow_from_file.id.startswith("default_"), ( - f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' - ) + assert workflow_from_file.id.startswith( + "default_" + ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' - assert workflow_from_file.meta.category is WorkflowCategory.Default, ( - f"Invalid default workflow category: {workflow_from_file.meta.category}" - ) + assert ( + workflow_from_file.meta.category is WorkflowCategory.Default + ), f"Invalid default workflow category: {workflow_from_file.meta.category}" workflows_from_file.append(workflow_from_file) From 4cb73e6c19be40915c868bba422d3d9d45442f7b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:43:53 +1000 Subject: [PATCH 076/102] chore(ui): typegen --- .../frontend/web/src/services/api/schema.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 4cbcc65a070..32d0a0f385b 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1438,6 +1438,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/workflows/counts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Counts + * @description Gets a the count of workflows that include the specified tags and categories + */ + get: operations["get_counts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/style_presets/i/{style_preset_id}": { parameters: { query?: never; @@ -21158,6 +21178,11 @@ export type components = { description: string; /** @description The description of the workflow. */ category: components["schemas"]["WorkflowCategory"]; + /** + * Tags + * @description The tags of the workflow. + */ + tags: string; /** * Thumbnail Url * @description The URL of the workflow thumbnail. @@ -24432,6 +24457,40 @@ export interface operations { }; }; }; + get_counts: { + parameters: { + query?: { + /** @description The tags to include */ + tags?: string[] | null; + /** @description The categories to include */ + categories?: components["schemas"]["WorkflowCategory"][] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": number; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_style_preset: { parameters: { query?: never; From 814fb939c0394a9988f8f2c818fc859a1529af32 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:44:16 +1000 Subject: [PATCH 077/102] chore: update default workflow tags --- .../ESRGAN Upscaling with Canny ControlNet.json | 2 +- .../workflow_records/default_workflows/FLUX Image to Image.json | 2 +- .../workflow_records/default_workflows/Flux Text to Image.json | 2 +- .../workflow_records/default_workflows/Prompt from File.json | 2 +- .../workflow_records/default_workflows/SD3.5 Text to Image.json | 2 +- .../default_workflows/Text to Image - SD1.5.json | 2 +- .../default_workflows/Text to Image - SDXL.json | 2 +- .../default_workflows/Text to Image with LoRA.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json index 13bd0c0bc6d..21b24994e5a 100644 --- a/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json +++ b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json @@ -5,7 +5,7 @@ "description": "Sample workflow for using Upscaling with ControlNet with SD1.5", "version": "2.1.0", "contact": "invoke@invoke.ai", - "tags": "upscale, controlnet, default", + "tags": "upscaling, controlnet, default", "notes": "", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json index 68fbe9297b0..9f585df1bc1 100644 --- a/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json +++ b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json @@ -5,7 +5,7 @@ "description": "A simple image-to-image workflow using a FLUX dev model. ", "version": "1.1.0", "contact": "", - "tags": "image2image, flux, image-to-image", + "tags": "image2image, flux, image-to-image, image to image", "notes": "Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend using FLUX dev models for image-to-image workflows. The image-to-image performance with FLUX schnell models is poor.", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json index b62f9144784..3fb9660fdf3 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json +++ b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json @@ -5,7 +5,7 @@ "description": "A simple text-to-image workflow using FLUX dev or schnell models.", "version": "1.1.0", "contact": "", - "tags": "text2image, flux", + "tags": "text2image, flux, text to image", "notes": "Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend 4 steps for FLUX schnell models and 30 steps for FLUX dev models.", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json index 74879565568..cbb98c0aeed 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json +++ b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json @@ -5,7 +5,7 @@ "description": "Sample workflow using Prompt from File node", "version": "2.1.0", "contact": "invoke@invoke.ai", - "tags": "text2image, prompt from file, default", + "tags": "text2image, prompt from file, default, text to image", "notes": "", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json index 86a0aeeebc6..937a3f5439c 100644 --- a/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json +++ b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json @@ -5,7 +5,7 @@ "description": "Sample text to image workflow for Stable Diffusion 3.5", "version": "1.0.0", "contact": "invoke@invoke.ai", - "tags": "text2image, SD3.5, default", + "tags": "text2image, SD3.5, text to image", "notes": "", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json index ef2fd5c2ffe..aa2de45b4c7 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json @@ -5,7 +5,7 @@ "description": "Sample text to image workflow for Stable Diffusion 1.5/2", "version": "2.1.0", "contact": "invoke@invoke.ai", - "tags": "text2image, SD1.5, SD2, default", + "tags": "text2image, SD1.5, SD2, text to image", "notes": "", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json index 3821270f4c1..ee0693f9db7 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json @@ -5,7 +5,7 @@ "description": "Sample text to image workflow for SDXL", "version": "2.1.0", "contact": "invoke@invoke.ai", - "tags": "text2image, SDXL, default", + "tags": "text2image, SDXL, text to image", "notes": "", "exposedFields": [ { diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json index 0963f4dff01..9a57d7f848e 100644 --- a/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json @@ -5,7 +5,7 @@ "description": "Simple text to image workflow with a LoRA", "version": "2.1.0", "contact": "invoke@invoke.ai", - "tags": "text to image, lora, default", + "tags": "text to image, lora, text to image", "notes": "", "exposedFields": [ { From 3694158434b9c783f1613f39a2d83c5d89e24afa Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:44:31 +1000 Subject: [PATCH 078/102] feat(ui): workflow library tags --- invokeai/frontend/web/public/locales/en.json | 9 + .../WorkflowLibrary/WorkflowLibraryModal.tsx | 2 +- .../WorkflowLibrarySideNav.tsx | 245 +++++++++++------- .../workflow/WorkflowLibrary/WorkflowList.tsx | 26 +- .../WorkflowLibrary/WorkflowSearch.tsx | 2 +- .../nodes/hooks/useCategorySections.ts | 18 -- .../web/src/features/nodes/store/types.ts | 12 +- .../src/features/nodes/store/workflowSlice.ts | 39 ++- .../src/services/api/endpoints/workflows.ts | 9 + 9 files changed, 224 insertions(+), 138 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useCategorySections.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f36193ba932..59f1667769b 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1686,6 +1686,15 @@ "descending": "Descending", "workflows": "Workflows", "workflowLibrary": "Library", + "loadMore": "Load More", + "allLoaded": "All Workflows Loaded", + "searchPlaceholder": "Search by name, description or tags", + "filterByTags": "Filter by Tags", + "yourWorkflows": "Your Workflows", + "private": "Private", + "shared": "Shared", + "browseWorkflows": "Browse Workflows", + "resetTags": "Reset Tags", "opened": "Opened", "openWorkflow": "Open Workflow", "updated": "Updated", diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx index 9f689e30ff4..bf4b176823b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx @@ -31,7 +31,7 @@ export const WorkflowLibraryModal = () => { - + diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 9d74c9329fd..07cc01c5f85 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -1,35 +1,50 @@ -/* eslint-disable i18next/no-literal-string */ -import { Button, Flex } from '@invoke-ai/ui-library'; +import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library'; +import { Button, Checkbox, Flex, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectWorkflowCategories, workflowCategoriesChanged } from 'features/nodes/store/workflowSlice'; -import type { WorkflowCategory } from 'features/nodes/types/workflow'; -import { useCallback, useMemo } from 'react'; -import { PiUsersBold } from 'react-icons/pi'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { WORKFLOW_TAGS, type WorkflowTag } from 'features/nodes/store/types'; +import { + selectWorkflowLibrarySelectedTags, + selectWorkflowSelectedCategories, + workflowSelectedCategoriesChanged, + workflowSelectedTagsRese, + workflowSelectedTagToggled, +} from 'features/nodes/store/workflowSlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowCounterClockwiseBold, PiUsersBold } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; +import { useGetCountsQuery } from 'services/api/endpoints/workflows'; export const WorkflowLibrarySideNav = () => { + const { t } = useTranslation(); const dispatch = useDispatch(); - const categories = useAppSelector(selectWorkflowCategories); + const categories = useAppSelector(selectWorkflowSelectedCategories); const categoryOptions = useStore($workflowCategories); + const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags); - const handleCategoryChange = useCallback( - (categories: WorkflowCategory[]) => { - dispatch(workflowCategoriesChanged(categories)); - }, - [dispatch] - ); + const selectYourWorkflows = useCallback(() => { + dispatch(workflowSelectedCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user'])); + }, [categoryOptions, dispatch]); - const handleSelectYourWorkflows = useCallback(() => { - if (categoryOptions.includes('project')) { - handleCategoryChange(['user', 'project']); - } else { - handleCategoryChange(['user']); - } - }, [categoryOptions, handleCategoryChange]); + const selectPrivateWorkflows = useCallback(() => { + dispatch(workflowSelectedCategoriesChanged(['user'])); + }, [dispatch]); + + const selectSharedWorkflows = useCallback(() => { + dispatch(workflowSelectedCategoriesChanged(['project'])); + }, [dispatch]); - const isYourWorkflowsActive = useMemo(() => { + const selectDefaultWorkflows = useCallback(() => { + dispatch(workflowSelectedCategoriesChanged(['default'])); + }, [dispatch]); + + const resetTags = useCallback(() => { + dispatch(workflowSelectedTagsRese()); + }, [dispatch]); + + const isYourWorkflowsSelected = useMemo(() => { if (categoryOptions.includes('project')) { return categories.includes('user') && categories.includes('project'); } else { @@ -37,97 +52,133 @@ export const WorkflowLibrarySideNav = () => { } }, [categoryOptions, categories]); + const isPrivateWorkflowsExclusivelySelected = useMemo(() => { + return categories.length === 1 && categories.includes('user'); + }, [categories]); + + const isSharedWorkflowsExclusivelySelected = useMemo(() => { + return categories.length === 1 && categories.includes('project'); + }, [categories]); + + const isDefaultWorkflowsExclusivelySelected = useMemo(() => { + return categories.length === 1 && categories.includes('default'); + }, [categories]); + return ( - + + {t('workflows.yourWorkflows')} + {categoryOptions.includes('project') && ( - - + {t('workflows.shared')} + )} - - - {/* these are obviously placeholders - we need to figure out the best way to do this. leaning towards "tags" so that we can filter and/or have multiple selected eventually */} - + + {t('workflows.browseWorkflows')} + + + - + + {WORKFLOW_TAGS.map((tagCategory) => ( + + ))} + ); }; + +const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected: boolean }) => { + return ( + + {hasNextPage && ( + + )} ); } diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx index 716c24269a4..599f7e1163a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx @@ -50,7 +50,7 @@ export const WorkflowSearch = memo(({ searchInputRef }: { searchInputRef: RefObj { - const dispatch = useAppDispatch(); - const selectIsOpen = useMemo( - () => createSelector(selectWorkflowSlice, (workflow) => workflow.categorySections[id] ?? true), - [id] - ); - const isOpen = useAppSelector(selectIsOpen); - const onToggle = useCallback(() => { - dispatch(categorySectionsChanged({ id, isOpen: !isOpen })); - }, [id, dispatch, isOpen]); - - return { isOpen, onToggle }; -}; diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 6a57309c569..7c89b105f75 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -22,14 +22,22 @@ export type NodesState = { export type WorkflowMode = 'edit' | 'view'; +export const WORKFLOW_TAGS = [ + { category: 'Industry', tags: ['Architecture', 'Fashion', 'Game Dev', 'Food'] }, + { category: 'Task', tags: ['Upscaling', 'Text to Image', 'Image to Image'] }, + { category: 'Base Model', tags: ['SD1.5', 'SDXL', 'Bria', 'FLUX'] }, + { category: 'Tech Showcase', tags: ['Control', 'Reference Image'] }, +] as const; +export type WorkflowTag = (typeof WORKFLOW_TAGS)[number]['tags'][number]; + export type WorkflowsState = Omit & { _version: 1; isTouched: boolean; mode: WorkflowMode; - categories: WorkflowCategory[]; + selectedTags: WorkflowTag[]; + selectedCategories: WorkflowCategory[]; searchTerm: string; orderBy?: WorkflowRecordOrderBy; orderDirection: SQLiteDirection; - categorySections: Record; formFieldInitialValues: Record; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index 7c682b54b23..c36a16dff44 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -11,7 +11,12 @@ import { } from 'features/nodes/components/sidePanel/builder/form-manipulation'; import { workflowLoaded } from 'features/nodes/store/actions'; import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged } from 'features/nodes/store/nodesSlice'; -import type { NodesState, WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types'; +import type { + NodesState, + WorkflowMode, + WorkflowsState as WorkflowState, + WorkflowTag, +} from 'features/nodes/store/types'; import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field'; import { isInvocationNode } from 'features/nodes/types/invocation'; import type { @@ -81,8 +86,8 @@ const initialWorkflowState: WorkflowState = { searchTerm: '', orderBy: undefined, // initial value is decided in component orderDirection: 'DESC', - categorySections: {}, - categories: ['user'], + selectedTags: [], + selectedCategories: ['user'], ...getBlankWorkflow(), }; @@ -102,14 +107,10 @@ export const workflowSlice = createSlice({ workflowOrderDirectionChanged: (state, action: PayloadAction) => { state.orderDirection = action.payload; }, - workflowCategoriesChanged: (state, action: PayloadAction) => { - state.categories = action.payload; + workflowSelectedCategoriesChanged: (state, action: PayloadAction) => { + state.selectedCategories = action.payload; state.searchTerm = ''; }, - categorySectionsChanged: (state, action: PayloadAction<{ id: string; isOpen: boolean }>) => { - const { id, isOpen } = action.payload; - state.categorySections[id] = isOpen; - }, workflowNameChanged: (state, action: PayloadAction) => { state.name = action.payload; state.isTouched = true; @@ -149,6 +150,18 @@ export const workflowSlice = createSlice({ workflowSaved: (state) => { state.isTouched = false; }, + workflowSelectedTagToggled: (state, action: PayloadAction) => { + const tag = action.payload; + const tags = state.selectedTags; + if (tags.includes(tag)) { + state.selectedTags = tags.filter((t) => t !== tag); + } else { + state.selectedTags = [...tags, tag]; + } + }, + workflowSelectedTagsRese: (state) => { + state.selectedTags = []; + }, formReset: (state) => { const rootElement = buildContainer('column', []); state.form = { @@ -304,8 +317,9 @@ export const { workflowSearchTermChanged, workflowOrderByChanged, workflowOrderDirectionChanged, - workflowCategoriesChanged, - categorySectionsChanged, + workflowSelectedCategoriesChanged, + workflowSelectedTagToggled, + workflowSelectedTagsRese, formReset, formElementAdded, formElementRemoved, @@ -371,8 +385,9 @@ export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => work export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm); export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy); export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection); -export const selectWorkflowCategories = createWorkflowSelector((workflow) => workflow.categories); +export const selectWorkflowSelectedCategories = createWorkflowSelector((workflow) => workflow.selectedCategories); export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description); +export const selectWorkflowLibrarySelectedTags = createWorkflowSelector((workflow) => workflow.selectedTags); export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form); export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts index 7bd3db78ad2..4e6b564af9c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts @@ -78,6 +78,14 @@ export const workflowsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }], }), + getCounts: build.query< + paths['/api/v1/workflows/counts']['get']['responses']['200']['content']['application/json'], + NonNullable + >({ + query: (params) => ({ + url: `${buildWorkflowsUrl('counts')}?${queryString.stringify(params, { arrayFormat: 'none' })}`, + }), + }), listWorkflowsInfinite: build.infiniteQuery< paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'], NonNullable, @@ -130,6 +138,7 @@ export const workflowsApi = api.injectEndpoints({ }); export const { + useGetCountsQuery, useLazyGetWorkflowQuery, useGetWorkflowQuery, useCreateWorkflowMutation, From bfbcaad8c28da0cac7b3ee2cdb388a9108deffab Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:04:35 +1000 Subject: [PATCH 079/102] tweak(ui): workflow tag names --- invokeai/frontend/web/src/features/nodes/store/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 7c89b105f75..bbafe0eebb9 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -24,8 +24,8 @@ export type WorkflowMode = 'edit' | 'view'; export const WORKFLOW_TAGS = [ { category: 'Industry', tags: ['Architecture', 'Fashion', 'Game Dev', 'Food'] }, - { category: 'Task', tags: ['Upscaling', 'Text to Image', 'Image to Image'] }, - { category: 'Base Model', tags: ['SD1.5', 'SDXL', 'Bria', 'FLUX'] }, + { category: 'Common Tasks', tags: ['Upscaling', 'Text to Image', 'Image to Image'] }, + { category: 'Model Architecture', tags: ['SD1.5', 'SDXL', 'Bria', 'FLUX'] }, { category: 'Tech Showcase', tags: ['Control', 'Reference Image'] }, ] as const; export type WorkflowTag = (typeof WORKFLOW_TAGS)[number]['tags'][number]; From ec6cea6705784998551505cb7eb3ded0e128915a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:05:01 +1000 Subject: [PATCH 080/102] feat(ui): workflow library styling --- .../WorkflowLibrarySideNav.tsx | 121 ++++++++++-------- .../workflow/WorkflowLibrary/WorkflowList.tsx | 2 +- 2 files changed, 71 insertions(+), 52 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 07cc01c5f85..882b16c4e23 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -1,5 +1,5 @@ import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library'; -import { Button, Checkbox, Flex, Text } from '@invoke-ai/ui-library'; +import { Box, Button, Checkbox, Collapse, Flex, Spacer, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -65,53 +65,70 @@ export const WorkflowLibrarySideNav = () => { }, [categories]); return ( - - - {t('workflows.yourWorkflows')} - - {categoryOptions.includes('project') && ( - - - {t('workflows.private')} - - } - onClick={selectSharedWorkflows} - isSelected={isSharedWorkflowsExclusivelySelected} + + + + {t('workflows.yourWorkflows')} + + {categoryOptions.includes('project') && ( + - {t('workflows.shared')} - - - )} - - {t('workflows.browseWorkflows')} - - - - - - {WORKFLOW_TAGS.map((tagCategory) => ( - - ))} - - + + + {t('workflows.private')} + + } + onClick={selectSharedWorkflows} + isSelected={isSharedWorkflowsExclusivelySelected} + > + {t('workflows.shared')} + + + + + )} + + + + {t('workflows.browseWorkflows')} + + + + + + {WORKFLOW_TAGS.map((tagCategory) => ( + + ))} + + + + ); }; @@ -119,12 +136,14 @@ export const WorkflowLibrarySideNav = () => { const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected: boolean }) => { return ( ); -}; +}); -export default memo(UploadWorkflowButton); +UploadWorkflowButton.displayName = 'UploadWorkflowButton'; From 2d3a2f9842857867500b630de9d0879b432e2a1d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:33:56 +1000 Subject: [PATCH 086/102] feat(app): add update_opened_at method for workflows This method simply sets the `opened_at` attribute to the current time. Previously `opened_at` was set when calling `get`, but that is not correct. We `get` workflows often, even when not opening them. So this needs to be a separate thing --- invokeai/app/api/routers/workflows.py | 11 +++++++ .../workflow_records/workflow_records_base.py | 5 ++++ .../workflow_records_sqlite.py | 27 ++++++++++++------ ...t_686bb1d0-d086-4c70-9fa3-2f600b922023.png | Bin 0 -> 124754 bytes 4 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 invokeai/app/services/workflow_thumbnails/default_workflow_thumbnails/default_686bb1d0-d086-4c70-9fa3-2f600b922023.png diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index e6558b22ad4..2ec071ac766 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -229,3 +229,14 @@ async def get_counts( """Gets a the count of workflows that include the specified tags and categories""" return ApiDependencies.invoker.services.workflow_records.get_counts(tags=tags, categories=categories) + + +@workflows_router.put( + "/i/{workflow_id}/opened_at", + operation_id="update_opened_at", +) +async def update_opened_at( + workflow_id: str = Path(description="The workflow to update"), +) -> None: + """Updates the opened_at field of a workflow""" + ApiDependencies.invoker.services.workflow_records.update_opened_at(workflow_id) diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py index d98936a441c..de25ea876d6 100644 --- a/invokeai/app/services/workflow_records/workflow_records_base.py +++ b/invokeai/app/services/workflow_records/workflow_records_base.py @@ -58,3 +58,8 @@ def get_counts( ) -> int: """Gets the count of workflows for the given tags and categories.""" pass + + @abstractmethod + def update_opened_at(self, workflow_id: str) -> None: + """Open a workflow.""" + pass diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index a2334b07006..f89517917f3 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -19,6 +19,8 @@ ) from invokeai.app.util.misc import uuid_string +SQL_TIME_FORMAT = "%Y-%m-%d %H:%M:%f" + class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase): def __init__(self, db: SqliteDatabase) -> None: @@ -32,15 +34,6 @@ def start(self, invoker: Invoker) -> None: def get(self, workflow_id: str) -> WorkflowRecordDTO: """Gets a workflow by ID. Updates the opened_at column.""" cursor = self._conn.cursor() - cursor.execute( - """--sql - UPDATE workflow_library - SET opened_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') - WHERE workflow_id = ?; - """, - (workflow_id,), - ) - self._conn.commit() cursor.execute( """--sql SELECT workflow_id, workflow, name, created_at, updated_at, opened_at @@ -286,6 +279,22 @@ def get_counts( cursor.execute(stmt, tuple(params)) return cursor.fetchone()[0] + def update_opened_at(self, workflow_id: str) -> None: + try: + cursor = self._conn.cursor() + cursor.execute( + f"""--sql + UPDATE workflow_library + SET opened_at = STRFTIME('{SQL_TIME_FORMAT}', 'NOW') + WHERE workflow_id = ?; + """, + (workflow_id,), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + def _sync_default_workflows(self) -> None: """Syncs default workflows to the database. Internal use only.""" diff --git a/invokeai/app/services/workflow_thumbnails/default_workflow_thumbnails/default_686bb1d0-d086-4c70-9fa3-2f600b922023.png b/invokeai/app/services/workflow_thumbnails/default_workflow_thumbnails/default_686bb1d0-d086-4c70-9fa3-2f600b922023.png new file mode 100644 index 0000000000000000000000000000000000000000..5db78ce086f6841a3e5781afe8f775aa1867c55b GIT binary patch literal 124754 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

qFW(G9+8ublOcvpjS8@A=alaK! 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 Date: Thu, 6 Mar 2025 15:34:04 +1000 Subject: [PATCH 087/102] chore(ui): lint --- .../frontend/web/src/services/api/schema.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 32d0a0f385b..94539863336 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1458,6 +1458,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/workflows/i/{workflow_id}/opened_at": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Opened At + * @description Updates the opened_at field of a workflow + */ + put: operations["update_opened_at"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/style_presets/i/{style_preset_id}": { parameters: { query?: never; @@ -24491,6 +24511,38 @@ export interface operations { }; }; }; + update_opened_at: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The workflow to update */ + workflow_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_style_preset: { parameters: { query?: never; From 50657650c2eddab635dda2c017c65bfe0aafa923 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:38:45 +1000 Subject: [PATCH 088/102] feat(ui): rough out recent workflows --- invokeai/frontend/web/public/locales/en.json | 1 + .../store/nanostores/workflowCategories.ts | 2 +- .../WorkflowLibrarySideNav.tsx | 74 +++++++++++++++++-- .../workflow/WorkflowLibrary/WorkflowList.tsx | 2 +- .../WorkflowLibrary/WorkflowSortControl.tsx | 10 +-- .../src/features/nodes/store/workflowSlice.ts | 2 +- .../hooks/useGetAndLoadLibraryWorkflow.ts | 12 +-- .../src/services/api/endpoints/workflows.ts | 8 ++ 8 files changed, 90 insertions(+), 21 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 96e832aa45e..a7868e4cd2d 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1691,6 +1691,7 @@ "searchPlaceholder": "Search by name, description or tags", "filterByTags": "Filter by Tags", "yourWorkflows": "Your Workflows", + "recentlyOpened": "Recently Opened", "private": "Private", "shared": "Shared", "browseWorkflows": "Browse Workflows", diff --git a/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts b/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts index e0d61071294..3b673cfab33 100644 --- a/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts +++ b/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts @@ -1,4 +1,4 @@ import type { WorkflowCategory } from 'features/nodes/types/workflow'; import { atom } from 'nanostores'; -export const $workflowCategories = atom(['user', 'default']); +export const $workflowCategories = atom(['user', 'default', 'project']); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 7652c85dcd7..16148536050 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -1,5 +1,5 @@ import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library'; -import { Button, Checkbox, Collapse, Flex, Spacer, Text } from '@invoke-ai/ui-library'; +import { Button, Checkbox, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -11,12 +11,14 @@ import { workflowSelectedTagsRese, workflowSelectedTagToggled, } from 'features/nodes/store/workflowSlice'; +import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiUsersBold } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; -import { useGetCountsQuery } from 'services/api/endpoints/workflows'; +import { useGetCountsQuery, useListWorkflowsQuery } from 'services/api/endpoints/workflows'; +import type { S } from 'services/api/types'; export const WorkflowLibrarySideNav = () => { const { t } = useTranslation(); @@ -66,8 +68,16 @@ export const WorkflowLibrarySideNav = () => { }, [categories]); return ( - - + + + + {t('workflows.recentlyOpened')} + + + + + + {t('workflows.yourWorkflows')} @@ -98,7 +108,7 @@ export const WorkflowLibrarySideNav = () => { )} - + {t('workflows.browseWorkflows')} @@ -136,6 +146,60 @@ export const WorkflowLibrarySideNav = () => { ); }; +const recentWorkflowsQueryArg = { + page: 0, + per_page: 5, + order_by: 'opened_at', + direction: 'DESC', +} satisfies Parameters[0]; + +const RecentWorkflows = memo(() => { + const { t } = useTranslation(); + const { data, isLoading } = useListWorkflowsQuery(recentWorkflowsQueryArg); + + if (isLoading) { + return {t('common.loading')}; + } + + if (!data) { + return {t('workflows.noRecentWorkflows')}; + } + + return ( + <> + {data.items.map((workflow) => { + return ; + })} + + ); +}); +RecentWorkflows.displayName = 'RecentWorkflows'; + +const RecentWorkflowButton = memo(({ workflow }: { workflow: S['WorkflowRecordListItemWithThumbnailDTO'] }) => { + const loadWorkflow = useLoadWorkflow(); + const load = useCallback(() => { + loadWorkflow.loadWithDialog(workflow.workflow_id, 'view'); + }, [loadWorkflow, workflow.workflow_id]); + + return ( + + + {workflow.name} + + {workflow.category === 'project' && } + + ); +}); +RecentWorkflowButton.displayName = 'RecentWorkflowButton'; + const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected: boolean }) => { return ( ); }); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx index feba39a9016..2c39a0e226e 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog.tsx @@ -3,6 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { buildUseDisclosure } from 'common/hooks/useBoolean'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal'; import { selectWorkflowIsTouched, workflowModeChanged } from 'features/nodes/store/workflowSlice'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; @@ -15,10 +16,12 @@ export const useNewWorkflow = () => { const dispatch = useAppDispatch(); const dialog = useDialogState(); const isTouched = useAppSelector(selectWorkflowIsTouched); + const workflowLibraryModal = useWorkflowLibraryModal(); const createImmediate = useCallback(() => { dispatch(nodeEditorReset()); dispatch(workflowModeChanged('edit')); + workflowLibraryModal.close(); toast({ id: 'NEW_WORKFLOW_CREATED', @@ -27,7 +30,7 @@ export const useNewWorkflow = () => { }); dialog.close(); - }, [dialog, dispatch, t]); + }, [dialog, dispatch, t, workflowLibraryModal]); const createWithDialog = useCallback(() => { if (!isTouched) { From e51588197f14635a423bf4c60732af64ac9f1793 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:53:18 +1000 Subject: [PATCH 090/102] chore(ui): lint --- invokeai/frontend/web/package.json | 2 - invokeai/frontend/web/pnpm-lock.yaml | 15 ---- .../WorkflowLibraryPagination.tsx | 82 ------------------- .../WorkflowListItemTooltip.tsx | 26 ------ 4 files changed, 125 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index bb175162821..d12a55bb81a 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -67,7 +67,6 @@ "chakra-react-select": "^4.9.2", "cmdk": "^1.0.0", "compare-versions": "^6.1.1", - "dateformat": "^5.0.3", "fracturedjsonjs": "^4.0.2", "framer-motion": "^11.10.0", "i18next": "^23.15.1", @@ -131,7 +130,6 @@ "@storybook/react": "^8.3.4", "@storybook/react-vite": "^8.5.5", "@storybook/theming": "^8.3.4", - "@types/dateformat": "^5.0.2", "@types/lodash-es": "^4.17.12", "@types/node": "^20.16.10", "@types/react": "^18.3.11", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 29b762e3dcc..17c3cde1168 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -50,9 +50,6 @@ dependencies: compare-versions: specifier: ^6.1.1 version: 6.1.1 - dateformat: - specifier: ^5.0.3 - version: 5.0.3 fracturedjsonjs: specifier: ^4.0.2 version: 4.0.2 @@ -226,9 +223,6 @@ devDependencies: '@storybook/theming': specifier: ^8.3.4 version: 8.3.4(storybook@8.3.4) - '@types/dateformat': - specifier: ^5.0.2 - version: 5.0.2 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -3355,10 +3349,6 @@ packages: '@types/d3-selection': 3.0.10 dev: false - /@types/dateformat@5.0.2: - resolution: {integrity: sha512-M95hNBMa/hnwErH+a+VOD/sYgTmo15OTYTM2Hr52/e0OdOuY+Crag+kd3/ioZrhg0WGbl9Sm3hR7UU+MH6rfOw==} - dev: true - /@types/diff-match-patch@1.0.36: resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} dev: false @@ -4856,11 +4846,6 @@ packages: '@babel/runtime': 7.25.7 dev: true - /dateformat@5.0.3: - resolution: {integrity: sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA==} - engines: {node: '>=12.20'} - dev: false - /de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} dev: true diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx deleted file mode 100644 index f7436b85954..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryPagination.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Button, Flex, IconButton } from '@invoke-ai/ui-library'; -import type { Dispatch, SetStateAction } from 'react'; -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; -import type { paths } from 'services/api/schema'; - -const PAGES_TO_DISPLAY = 5; - -type PageData = { - page: number; - onClick: () => void; -}; - -type Props = { - page: number; - setPage: Dispatch>; - data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json']; -}; - -// kent and devon want to make this infinite scroll -export const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => { - const { t } = useTranslation(); - - const handlePrevPage = useCallback(() => { - setPage((p) => Math.max(p - 1, 0)); - }, [setPage]); - - const handleNextPage = useCallback(() => { - setPage((p) => Math.min(p + 1, data.pages - 1)); - }, [data.pages, setPage]); - - const pages: PageData[] = useMemo(() => { - const pages = []; - let first = data.pages > PAGES_TO_DISPLAY ? Math.max(0, page - Math.floor(PAGES_TO_DISPLAY / 2)) : 0; - const last = data.pages > PAGES_TO_DISPLAY ? Math.min(data.pages, first + PAGES_TO_DISPLAY) : data.pages; - if (last - first < PAGES_TO_DISPLAY && data.pages > PAGES_TO_DISPLAY) { - first = last - PAGES_TO_DISPLAY; - } - for (let i = first; i < last; i++) { - pages.push({ - page: i, - onClick: () => setPage(i), - }); - } - return pages; - }, [data.pages, page, setPage]); - - return ( - - } - /> - - {pages.map((p) => ( - - ))} - } - /> - - ); -}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx deleted file mode 100644 index 5b3f77a70f5..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Flex, Text } from '@invoke-ai/ui-library'; -import dateFormat, { masks } from 'dateformat'; -import { useTranslation } from 'react-i18next'; -import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; - -export const WorkflowListItemTooltip = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => { - const { t } = useTranslation(); - return ( - - {workflow.description} - {workflow.category !== 'default' && ( - - - {t('workflows.opened')}: {dateFormat(workflow.opened_at, masks.shortDate)} - - - {t('common.updated')}: {dateFormat(workflow.updated_at, masks.shortDate)} - - - {t('common.created')}: {dateFormat(workflow.created_at, masks.shortDate)} - - - )} - - ); -}; From 58959a18cbdcdf1a1c1f4d231590c647a3876617 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:57:15 +1000 Subject: [PATCH 091/102] chore: ruff --- .../workflow_records/workflow_records_sqlite.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index f89517917f3..9425653eff2 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -319,13 +319,13 @@ def _sync_default_workflows(self) -> None: bytes_ = path.read_bytes() workflow_from_file = WorkflowValidator.validate_json(bytes_) - assert workflow_from_file.id.startswith( - "default_" - ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + assert workflow_from_file.id.startswith("default_"), ( + f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + ) - assert ( - workflow_from_file.meta.category is WorkflowCategory.Default - ), f"Invalid default workflow category: {workflow_from_file.meta.category}" + assert workflow_from_file.meta.category is WorkflowCategory.Default, ( + f"Invalid default workflow category: {workflow_from_file.meta.category}" + ) workflows_from_file.append(workflow_from_file) From 9045237bfbd74e2c975604e19fdbddb608d85fda Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 08:38:13 +1000 Subject: [PATCH 092/102] feat(api): add util to extract metadata from image --- .../app/api/extract_metadata_from_image.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 invokeai/app/api/extract_metadata_from_image.py diff --git a/invokeai/app/api/extract_metadata_from_image.py b/invokeai/app/api/extract_metadata_from_image.py new file mode 100644 index 00000000000..5153b524f22 --- /dev/null +++ b/invokeai/app/api/extract_metadata_from_image.py @@ -0,0 +1,108 @@ +import json +import logging +from dataclasses import dataclass + +from PIL import Image + +from invokeai.app.services.shared.graph import Graph +from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator + + +@dataclass +class ExtractedMetadata: + invokeai_metadata: str | None + invokeai_workflow: str | None + invokeai_graph: str | None + + +def extract_metadata_from_image( + pil_image: Image.Image, + invokeai_metadata_override: str | None, + invokeai_workflow_override: str | None, + invokeai_graph_override: str | None, + logger: logging.Logger, +) -> ExtractedMetadata: + """ + Extracts the "invokeai_metadata", "invokeai_workflow", and "invokeai_graph" data embedded in the PIL Image. + + These items are stored as stringified JSON in the image file's metadata, so we need to do some parsing to validate + them. Once parsed, the values are returned as they came (as strings), or None if they are not present or invalid. + + In some situations, we may prefer to override the values extracted from the image file with some other values. + + For example, when uploading an image via API, the client can optionally provide the metadata directly in the request, + as opposed to embedding it in the image file. In this case, the client-provided metadata will be used instead of the + metadata embedded in the image file. + + Args: + pil_image: The PIL Image object. + invokeai_metadata_override: The metadata override provided by the client. + invokeai_workflow_override: The workflow override provided by the client. + invokeai_graph_override: The graph override provided by the client. + logger: The logger to use for debug logging. + + Returns: + ExtractedMetadata: The extracted metadata, workflow, and graph. + """ + + # The fallback value for metadata is None. + stringified_metadata: str | None = None + + # Use the metadata override if provided, else attempt to extract it from the image file. + metadata_raw = invokeai_metadata_override or pil_image.info.get("invokeai_metadata", None) + + # If the metadata is present in the image file, we will attempt to parse it as JSON. When we create images, + # we always store metadata as a stringified JSON dict. So, we expect it to be a string here. + if isinstance(metadata_raw, str): + try: + # Must be a JSON string + metadata_parsed = json.loads(metadata_raw) + # Must be a dict + if isinstance(metadata_parsed, dict): + # Looks good, overwrite the fallback value + stringified_metadata = metadata_raw + except Exception as e: + logger.debug(f"Failed to parse metadata for uploaded image, {e}") + pass + + # We expect the workflow, if embedded in the image, to be a JSON-stringified WorkflowWithoutID. We will store it + # as a string. + workflow_raw: str | None = invokeai_workflow_override or pil_image.info.get("invokeai_workflow", None) + + # The fallback value for workflow is None. + stringified_workflow: str | None = None + + # If the workflow is present in the image file, we will attempt to parse it as JSON. When we create images, we + # always store workflows as a stringified JSON WorkflowWithoutID. So, we expect it to be a string here. + if isinstance(workflow_raw, str): + try: + # Validate the workflow JSON before storing it + WorkflowWithoutIDValidator.validate_json(workflow_raw) + # Looks good, overwrite the fallback value + stringified_workflow = workflow_raw + except Exception: + logger.debug("Failed to parse workflow for uploaded image") + pass + + # We expect the workflow, if embedded in the image, to be a JSON-stringified Graph. We will store it as a + # string. + graph_raw: str | None = invokeai_graph_override or pil_image.info.get("invokeai_graph", None) + + # The fallback value for graph is None. + stringified_graph: str | None = None + + # If the graph is present in the image file, we will attempt to parse it as JSON. When we create images, we + # always store graphs as a stringified JSON Graph. So, we expect it to be a string here. + if isinstance(graph_raw, str): + try: + # Validate the graph JSON before storing it + Graph.model_validate_json(graph_raw) + # Looks good, overwrite the fallback value + stringified_graph = graph_raw + except Exception as e: + logger.debug(f"Failed to parse graph for uploaded image, {e}") + pass + + return ExtractedMetadata( + invokeai_metadata=stringified_metadata, invokeai_workflow=stringified_workflow, invokeai_graph=stringified_graph + ) From 8e46b03f09bd671579d712bcdee1157819fa9ad7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 08:39:35 +1000 Subject: [PATCH 093/102] tests: add tests for extract_metadata_from_image --- tests/app/test_extract_metadata_from_image.py | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/app/test_extract_metadata_from_image.py diff --git a/tests/app/test_extract_metadata_from_image.py b/tests/app/test_extract_metadata_from_image.py new file mode 100644 index 00000000000..3c028780e9e --- /dev/null +++ b/tests/app/test_extract_metadata_from_image.py @@ -0,0 +1,204 @@ +import json +import logging +from unittest.mock import MagicMock, patch + +import pytest +from PIL import Image + +from invokeai.app.api.extract_metadata_from_image import ExtractedMetadata, extract_metadata_from_image + + +@pytest.fixture +def mock_logger(): + return MagicMock(spec=logging.Logger) + + +@pytest.fixture +def valid_metadata(): + return json.dumps({"param1": "value1", "param2": 123}) + + +@pytest.fixture +def valid_workflow(): + return json.dumps({"name": "test_workflow", "version": "1.0"}) + + +@pytest.fixture +def valid_graph(): + return json.dumps({"nodes": [], "edges": []}) + + +def test_extract_valid_metadata_from_image(mock_logger, valid_metadata, valid_workflow, valid_graph): + # Create a mock image with valid metadata + mock_image = MagicMock(spec=Image.Image) + mock_image.info = { + "invokeai_metadata": valid_metadata, + "invokeai_workflow": valid_workflow, + "invokeai_graph": valid_graph, + } + + # Mock the validation functions + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ) as mock_workflow_validate: + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as mock_graph_validate: + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + # Assert correct calls to validators + mock_workflow_validate.assert_called_once_with(valid_workflow) + mock_graph_validate.assert_called_once_with(valid_graph) + + # Assert correct extraction + assert result == ExtractedMetadata( + invokeai_metadata=valid_metadata, invokeai_workflow=valid_workflow, invokeai_graph=valid_graph + ) + + +def test_extract_invalid_metadata(mock_logger, valid_workflow, valid_graph): + # Invalid metadata (not JSON) + invalid_metadata = "not a valid json" + + mock_image = MagicMock(spec=Image.Image) + mock_image.info = { + "invokeai_metadata": invalid_metadata, + "invokeai_workflow": valid_workflow, + "invokeai_graph": valid_graph, + } + + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ): + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"): + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + assert mock_logger.debug.to_have_been_called_with("Failed to parse metadata for uploaded image") + + # Invalid metadata should be None, others valid + assert result.invokeai_metadata is None + assert result.invokeai_workflow == valid_workflow + assert result.invokeai_graph == valid_graph + + +def test_metadata_wrong_type(mock_logger, valid_workflow, valid_graph): + # Valid JSON but not a dict + metadata_array = json.dumps(["item1", "item2"]) + + mock_image = MagicMock(spec=Image.Image) + mock_image.info = { + "invokeai_metadata": metadata_array, + "invokeai_workflow": valid_workflow, + "invokeai_graph": valid_graph, + } + + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ): + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"): + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + # Metadata should be None as it's not a dict + assert result.invokeai_metadata is None + assert result.invokeai_workflow == valid_workflow + assert result.invokeai_graph == valid_graph + + +def test_with_non_string_metadata(mock_logger, valid_workflow, valid_graph): + # Some implementations might include metadata as non-string values + mock_image = MagicMock(spec=Image.Image) + mock_image.info = { + "invokeai_metadata": 12345, # Not a string + "invokeai_workflow": valid_workflow, + "invokeai_graph": valid_graph, + } + + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ): + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"): + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + assert mock_logger.debug.to_have_been_called_with("Failed to parse metadata for uploaded image") + + assert result.invokeai_metadata is None + assert result.invokeai_workflow == valid_workflow + assert result.invokeai_graph == valid_graph + + +def test_invalid_workflow(mock_logger, valid_metadata, valid_graph): + invalid_workflow = "not a valid workflow json" + + mock_image = MagicMock(spec=Image.Image) + mock_image.info = { + "invokeai_metadata": valid_metadata, + "invokeai_workflow": invalid_workflow, + "invokeai_graph": valid_graph, + } + + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ) as mock_validate: + mock_validate.side_effect = ValueError("Invalid workflow") + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"): + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + assert result.invokeai_metadata == valid_metadata + assert result.invokeai_workflow is None + assert result.invokeai_graph == valid_graph + + +def test_invalid_graph(mock_logger, valid_metadata, valid_workflow): + invalid_graph = "not a valid graph json" + + mock_image = MagicMock(spec=Image.Image) + mock_image.info = { + "invokeai_metadata": valid_metadata, + "invokeai_workflow": valid_workflow, + "invokeai_graph": invalid_graph, + } + + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ): + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as mock_validate: + mock_validate.side_effect = ValueError("Invalid graph") + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + assert result.invokeai_metadata == valid_metadata + assert result.invokeai_workflow == valid_workflow + assert result.invokeai_graph is None + + +def test_with_overrides(mock_logger, valid_metadata, valid_workflow, valid_graph): + # Different values in the image + mock_image = MagicMock(spec=Image.Image) + + # When overrides are provided, they should be used instead of the values in the image, we shouldn'teven try + # to parse the values in the image + mock_image.info = { + "invokeai_metadata": 12345, + "invokeai_workflow": 12345, + "invokeai_graph": 12345, + } + + with patch( + "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" + ): + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"): + result = extract_metadata_from_image(mock_image, valid_metadata, valid_workflow, valid_graph, mock_logger) + + # Override values should be used + assert result.invokeai_metadata == valid_metadata + assert result.invokeai_workflow == valid_workflow + assert result.invokeai_graph == valid_graph + + +def test_with_no_metadata(mock_logger): + # Image with no metadata + mock_image = MagicMock(spec=Image.Image) + mock_image.info = {} + + result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) + + assert result.invokeai_metadata is None + assert result.invokeai_workflow is None + assert result.invokeai_graph is None From 7f0452173b8709dd65a1567e5549399488060df7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 08:39:44 +1000 Subject: [PATCH 094/102] feat(api): use extract_metadata_from_image in upload router --- invokeai/app/api/routers/images.py | 50 ++++++++++-------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 14652ea7848..c86b554f9a0 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -6,9 +6,10 @@ from fastapi.responses import FileResponse from fastapi.routing import APIRouter from PIL import Image -from pydantic import BaseModel, Field, JsonValue +from pydantic import BaseModel, Field from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_image from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, @@ -45,18 +46,16 @@ async def upload_image( board_id: Optional[str] = Query(default=None, description="The board to add this image to, if any"), session_id: Optional[str] = Query(default=None, description="The session ID associated with this upload, if any"), crop_visible: Optional[bool] = Query(default=False, description="Whether to crop the image"), - metadata: Optional[JsonValue] = Body( - default=None, description="The metadata to associate with the image", embed=True + metadata: Optional[str] = Body( + default=None, + description="The metadata to associate with the image, must be a stringified JSON dict", + embed=True, ), ) -> ImageDTO: """Uploads an image""" if not file.content_type or not file.content_type.startswith("image"): raise HTTPException(status_code=415, detail="Not an image") - _metadata = None - _workflow = None - _graph = None - contents = await file.read() try: pil_image = Image.open(io.BytesIO(contents)) @@ -67,30 +66,13 @@ async def upload_image( ApiDependencies.invoker.services.logger.error(traceback.format_exc()) raise HTTPException(status_code=415, detail="Failed to read image") - # TODO: retain non-invokeai metadata on upload? - # attempt to parse metadata from image - metadata_raw = metadata if isinstance(metadata, str) else pil_image.info.get("invokeai_metadata", None) - if isinstance(metadata_raw, str): - _metadata = metadata_raw - else: - ApiDependencies.invoker.services.logger.debug("Failed to parse metadata for uploaded image") - pass - - # attempt to parse workflow from image - workflow_raw = pil_image.info.get("invokeai_workflow", None) - if isinstance(workflow_raw, str): - _workflow = workflow_raw - else: - ApiDependencies.invoker.services.logger.debug("Failed to parse workflow for uploaded image") - pass - - # attempt to extract graph from image - graph_raw = pil_image.info.get("invokeai_graph", None) - if isinstance(graph_raw, str): - _graph = graph_raw - else: - ApiDependencies.invoker.services.logger.debug("Failed to parse graph for uploaded image") - pass + extracted_metadata = extract_metadata_from_image( + pil_image=pil_image, + invokeai_metadata_override=metadata, + invokeai_workflow_override=None, + invokeai_graph_override=None, + logger=ApiDependencies.invoker.services.logger, + ) try: image_dto = ApiDependencies.invoker.services.images.create( @@ -99,9 +81,9 @@ async def upload_image( image_category=image_category, session_id=session_id, board_id=board_id, - metadata=_metadata, - workflow=_workflow, - graph=_graph, + metadata=extracted_metadata.invokeai_metadata, + workflow=extracted_metadata.invokeai_workflow, + graph=extracted_metadata.invokeai_graph, is_intermediate=is_intermediate, ) From 4136817d30325a794090c78b7930a376936683ed Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 08:39:51 +1000 Subject: [PATCH 095/102] chore(ui): typegen --- invokeai/frontend/web/src/services/api/schema.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 94539863336..f82a098e95d 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -2455,8 +2455,11 @@ export type components = { * Format: binary */ file: Blob; - /** @description The metadata to associate with the image */ - metadata?: components["schemas"]["JsonValue"] | null; + /** + * Metadata + * @description The metadata to associate with the image + */ + metadata?: string | null; }; /** * Boolean Collection Primitive From f03a2bf03f13fe63b47fd8ea5f4ff80038c18ec4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 08:56:16 +1000 Subject: [PATCH 096/102] chore(ui): typegen --- invokeai/frontend/web/src/services/api/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index f82a098e95d..59efc634715 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -2457,7 +2457,7 @@ export type components = { file: Blob; /** * Metadata - * @description The metadata to associate with the image + * @description The metadata to associate with the image, must be a stringified JSON dict */ metadata?: string | null; }; From 839a7915099cb726a5d9380aeea2e4186bfcf2a4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:10:42 +1000 Subject: [PATCH 097/102] fix(api): loosen graph parsing in extract_metadata_from_image There's a pydantic thing that causes the graphs to fail validation erroneously. Details in the comments - not a high priority to fix but we should figure it out someday. --- .../app/api/extract_metadata_from_image.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/invokeai/app/api/extract_metadata_from_image.py b/invokeai/app/api/extract_metadata_from_image.py index 5153b524f22..054b3cc38cc 100644 --- a/invokeai/app/api/extract_metadata_from_image.py +++ b/invokeai/app/api/extract_metadata_from_image.py @@ -4,7 +4,6 @@ from PIL import Image -from invokeai.app.services.shared.graph import Graph from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator @@ -95,8 +94,25 @@ def extract_metadata_from_image( # always store graphs as a stringified JSON Graph. So, we expect it to be a string here. if isinstance(graph_raw, str): try: - # Validate the graph JSON before storing it - Graph.model_validate_json(graph_raw) + # TODO(psyche): Due to pydantic's handling of None values, it is possible for the graph to fail validation, + # even if it is a direct dump of a valid graph. Node fields in the graph are allowed to have be unset if + # they have incoming connections, but something about the ser/de process cannot adequately handle this. + # + # In lieu of fixing the graph validation, we will just do a simple check here to see if the graph is dict + # with the correct keys. This is not a perfect solution, but it should be good enough for now. + + # FIX ME: Validate the graph JSON before storing it + # Graph.model_validate_json(graph_raw) + + # Crappy workaround to validate JSON + graph_parsed = json.loads(graph_raw) + if not isinstance(graph_parsed, dict): + raise ValueError("Not a dict") + if not isinstance(graph_parsed.get("nodes", None), dict): + raise ValueError("'nodes' is not a dict") + if not isinstance(graph_parsed.get("edges", None), list): + raise ValueError("'edges' is not a list") + # Looks good, overwrite the fallback value stringified_graph = graph_raw except Exception as e: From 0f45ee04a20064ed7374a310607b42f5bc71ac0d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:22:16 +1000 Subject: [PATCH 098/102] tests: fix test_extract_valid_metadata_from_image to accomodate prev commit --- tests/app/test_extract_metadata_from_image.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/app/test_extract_metadata_from_image.py b/tests/app/test_extract_metadata_from_image.py index 3c028780e9e..75ccac615f4 100644 --- a/tests/app/test_extract_metadata_from_image.py +++ b/tests/app/test_extract_metadata_from_image.py @@ -25,7 +25,7 @@ def valid_workflow(): @pytest.fixture def valid_graph(): - return json.dumps({"nodes": [], "edges": []}) + return json.dumps({"nodes": {}, "edges": []}) def test_extract_valid_metadata_from_image(mock_logger, valid_metadata, valid_workflow, valid_graph): @@ -46,7 +46,9 @@ def test_extract_valid_metadata_from_image(mock_logger, valid_metadata, valid_wo # Assert correct calls to validators mock_workflow_validate.assert_called_once_with(valid_workflow) - mock_graph_validate.assert_called_once_with(valid_graph) + # TODO(psyche): The extract_metadata_from_image does not validate the graph correctly. See note in `extract_metadata_from_image.py`. + # Skipping this. + # mock_graph_validate.assert_called_once_with(valid_graph) # Assert correct extraction assert result == ExtractedMetadata( From b9c7bc8b0e8239f97a644b347246ae2a43497b3c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:24:15 +1000 Subject: [PATCH 099/102] chore: ruff --- tests/app/test_extract_metadata_from_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/app/test_extract_metadata_from_image.py b/tests/app/test_extract_metadata_from_image.py index 75ccac615f4..949d6df1656 100644 --- a/tests/app/test_extract_metadata_from_image.py +++ b/tests/app/test_extract_metadata_from_image.py @@ -41,14 +41,14 @@ def test_extract_valid_metadata_from_image(mock_logger, valid_metadata, valid_wo with patch( "invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json" ) as mock_workflow_validate: - with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as mock_graph_validate: + with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as _mock_graph_validate: result = extract_metadata_from_image(mock_image, None, None, None, mock_logger) # Assert correct calls to validators mock_workflow_validate.assert_called_once_with(valid_workflow) # TODO(psyche): The extract_metadata_from_image does not validate the graph correctly. See note in `extract_metadata_from_image.py`. # Skipping this. - # mock_graph_validate.assert_called_once_with(valid_graph) + # _mock_graph_validate.assert_called_once_with(valid_graph) # Assert correct extraction assert result == ExtractedMetadata( From 8a4282365e664cf94e4aaafca6f9ffb7dc36d100 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:46:33 +1100 Subject: [PATCH 100/102] chore: bump version to v5.8.0a1 --- invokeai/version/invokeai_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/version/invokeai_version.py b/invokeai/version/invokeai_version.py index a5ccce42437..6cf6162ab95 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "5.7.2" +__version__ = "5.8.0a1" From d5d08f6569bb9de6af10d649a1d054191625cf43 Mon Sep 17 00:00:00 2001 From: Riku Date: Fri, 7 Mar 2025 10:17:42 +0100 Subject: [PATCH 101/102] fix(ui): add webp to supported image types in toast messages --- invokeai/frontend/web/public/locales/en.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a7868e4cd2d..b71454ed2ae 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1286,9 +1286,9 @@ "somethingWentWrong": "Something Went Wrong", "uploadFailed": "Upload failed", "imagesWillBeAddedTo": "Uploaded images will be added to board {{boardName}}'s assets.", - "uploadFailedInvalidUploadDesc_withCount_one": "Must be maximum of 1 PNG or JPEG image.", - "uploadFailedInvalidUploadDesc_withCount_other": "Must be maximum of {{count}} PNG or JPEG images.", - "uploadFailedInvalidUploadDesc": "Must be PNG or JPEG images.", + "uploadFailedInvalidUploadDesc_withCount_one": "Must be maximum of 1 PNG, JPEG or WEBP image.", + "uploadFailedInvalidUploadDesc_withCount_other": "Must be maximum of {{count}} PNG, JPEG or WEBP images.", + "uploadFailedInvalidUploadDesc": "Must be PNG, JPEG or WEBP images.", "workflowLoaded": "Workflow Loaded", "problemRetrievingWorkflow": "Problem Retrieving Workflow", "workflowDeleted": "Workflow Deleted", From 59a8c0d441c96990a8a9abfbee208ecfc6cd4258 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:40:41 +1000 Subject: [PATCH 102/102] feat(app): less janky custom node loading - We don't need to copy the init file. Just crawl the custom nodes dir for modules and import them all. Dunno why I didn't do this initially. - Pass the logger in as an arg. There was a race condition where if we got the logger directly in the load_custom_nodes function, the config would not have been loaded fully yet and we'd end up with the wrong custom nodes path! - Remove permissions-setting logic, I do not believe it is relevant for custom nodes - Minor cleanup of the utility --- invokeai/app/invocations/custom_nodes/init.py | 64 -------------- invokeai/app/invocations/load_custom_nodes.py | 87 ++++++++++++++----- invokeai/app/run_app.py | 2 +- 3 files changed, 66 insertions(+), 87 deletions(-) delete mode 100644 invokeai/app/invocations/custom_nodes/init.py diff --git a/invokeai/app/invocations/custom_nodes/init.py b/invokeai/app/invocations/custom_nodes/init.py deleted file mode 100644 index 171a3786904..00000000000 --- a/invokeai/app/invocations/custom_nodes/init.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Invoke-managed custom node loader. See README.md for more information. -""" - -import sys -import traceback -from importlib.util import module_from_spec, spec_from_file_location -from pathlib import Path - -from invokeai.backend.util.logging import InvokeAILogger - -logger = InvokeAILogger.get_logger() -loaded_packs: list[str] = [] -failed_packs: list[str] = [] - -custom_nodes_dir = Path(__file__).parent - -for d in custom_nodes_dir.iterdir(): - # skip files - if not d.is_dir(): - continue - - # skip hidden directories - if d.name.startswith("_") or d.name.startswith("."): - continue - - # skip directories without an `__init__.py` - init = d / "__init__.py" - if not init.exists(): - continue - - module_name = init.parent.stem - - # skip if already imported - if module_name in globals(): - continue - - # load the module, appending adding a suffix to identify it as a custom node pack - spec = spec_from_file_location(module_name, init.absolute()) - - if spec is None or spec.loader is None: - logger.warn(f"Could not load {init}") - continue - - logger.info(f"Loading node pack {module_name}") - - try: - module = module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - - loaded_packs.append(module_name) - except Exception: - failed_packs.append(module_name) - full_error = traceback.format_exc() - logger.error(f"Failed to load node pack {module_name} (may have partially loaded):\n{full_error}") - - del init, module_name - -loaded_count = len(loaded_packs) -if loaded_count > 0: - logger.info( - f"Loaded {loaded_count} node pack{'s' if loaded_count != 1 else ''} from {custom_nodes_dir}: {', '.join(loaded_packs)}" - ) diff --git a/invokeai/app/invocations/load_custom_nodes.py b/invokeai/app/invocations/load_custom_nodes.py index 993237478ea..a3a8194a3b9 100644 --- a/invokeai/app/invocations/load_custom_nodes.py +++ b/invokeai/app/invocations/load_custom_nodes.py @@ -1,40 +1,83 @@ +import logging import shutil import sys +import traceback from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path -def load_custom_nodes(custom_nodes_path: Path): +def load_custom_nodes(custom_nodes_path: Path, logger: logging.Logger): """ Loads all custom nodes from the custom_nodes_path directory. - This function copies a custom __init__.py file to the custom_nodes_path directory, effectively turning it into a - python module. + If custom_nodes_path does not exist, it creates it. - The custom __init__.py file itself imports all the custom node packs as python modules from the custom_nodes_path - directory. + It also copies the custom_nodes/README.md file to the custom_nodes_path directory. Because this file may change, + it is _always_ copied to the custom_nodes_path directory. - Then,the custom __init__.py file is programmatically imported using importlib. As it executes, it imports all the - custom node packs as python modules. + Then, it crawls the custom_nodes_path directory and imports all top-level directories as python modules. + + If the directory does not contain an __init__.py file or starts with an `_` or `.`, it is skipped. """ + + # create the custom nodes directory if it does not exist custom_nodes_path.mkdir(parents=True, exist_ok=True) - custom_nodes_init_path = str(custom_nodes_path / "__init__.py") - custom_nodes_readme_path = str(custom_nodes_path / "README.md") + # Copy the README file to the custom nodes directory + source_custom_nodes_readme_path = Path(__file__).parent / "custom_nodes/README.md" + target_custom_nodes_readme_path = Path(custom_nodes_path) / "README.md" - # copy our custom nodes __init__.py to the custom nodes directory - shutil.copy(Path(__file__).parent / "custom_nodes/init.py", custom_nodes_init_path) - shutil.copy(Path(__file__).parent / "custom_nodes/README.md", custom_nodes_readme_path) + # copy our custom nodes README to the custom nodes directory + shutil.copy(source_custom_nodes_readme_path, target_custom_nodes_readme_path) - # set the same permissions as the destination directory, in case our source is read-only, - # so that the files are user-writable - for p in custom_nodes_path.glob("**/*"): - p.chmod(custom_nodes_path.stat().st_mode) + loaded_packs: list[str] = [] + failed_packs: list[str] = [] # Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically - spec = spec_from_file_location("custom_nodes", custom_nodes_init_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Could not load custom nodes from {custom_nodes_init_path}") - module = module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) + for d in custom_nodes_path.iterdir(): + # skip files + if not d.is_dir(): + continue + + # skip hidden directories + if d.name.startswith("_") or d.name.startswith("."): + continue + + # skip directories without an `__init__.py` + init = d / "__init__.py" + if not init.exists(): + continue + + module_name = init.parent.stem + + # skip if already imported + if module_name in globals(): + continue + + # load the module + spec = spec_from_file_location(module_name, init.absolute()) + + if spec is None or spec.loader is None: + logger.warning(f"Could not load {init}") + continue + + logger.info(f"Loading node pack {module_name}") + + try: + module = module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + loaded_packs.append(module_name) + except Exception: + failed_packs.append(module_name) + full_error = traceback.format_exc() + logger.error(f"Failed to load node pack {module_name} (may have partially loaded):\n{full_error}") + + del init, module_name + + loaded_count = len(loaded_packs) + if loaded_count > 0: + logger.info( + f"Loaded {loaded_count} node pack{'s' if loaded_count != 1 else ''} from {custom_nodes_path}: {', '.join(loaded_packs)}" + ) diff --git a/invokeai/app/run_app.py b/invokeai/app/run_app.py index f8972cf4f11..3cc8f9d787a 100644 --- a/invokeai/app/run_app.py +++ b/invokeai/app/run_app.py @@ -59,7 +59,7 @@ def run_app() -> None: # Load custom nodes. This must be done after importing the Graph class, which itself imports all modules from the # invocations module. The ordering here is implicit, but important - we want to load custom nodes after all the # core nodes have been imported so that we can catch when a custom node clobbers a core node. - load_custom_nodes(custom_nodes_path=app_config.custom_nodes_path) + load_custom_nodes(custom_nodes_path=app_config.custom_nodes_path, logger=logger) # Start the server. config = uvicorn.Config(