From e4678201cb7202733d2c3a089127b4f319a4e2f6 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Wed, 2 Apr 2025 16:21:58 +1000
Subject: [PATCH] feat(ui): add conditionally-enabled workflow publishing ui
This is a squash of a lot of scattered commits that became very difficult to clean up and make individually. Sorry.
Besides the new UI, there are a number of notable changes:
- Publishing logic is disabled in OSS by default. To enable it, provided a `disabledFeatures` prop _without_ "publishWorkflow".
- Enqueuing a workflow is no longer handled in a redux listener. It was hard to track the state of the enqueue logic in the listener. It is now in a hook. I did not migrate the canvas and upscaling tabs - their enqueue logic is still in the listener.
- When queueing a validation run, the new `useEnqueueWorkflows()` hook will update the payload with the required data for the run.
- Some logic is added to the socket event listeners to handle workflow publish runs completing.
- The workflow library side nav has a new "published" view. It is hidden when the "publishWorkflow" feature is disabled.
- I've added `Safe` and `OrThrow` versions of some workflows hooks. These hooks typically retrieve some data from redux. For example, a node. The `Safe` hooks return the node or null if it cannot be found, while the `OrThrow` hooks return the node or raise if it cannot be found. The `OrThrow` hooks should be used within one of the gate components. These components use the `Safe` hooks and render a fallback if e.g. the node isn't found. This change is required for some of the publish flow UI.
- Add support for locking the workflow editor. When locked, you can pan and zoom but that's it. Currently, it is only locked during publish flow and if a published workflow is opened.
---
invokeai/frontend/web/public/locales/en.json | 32 +-
.../frontend/web/src/app/store/actions.ts | 7 -
.../middleware/listenerMiddleware/index.ts | 2 -
...addAdHocPostProcessingRequestedListener.ts | 4 +-
.../listeners/enqueueRequestedLinear.ts | 7 +-
.../listeners/enqueueRequestedUpscale.ts | 7 +-
invokeai/frontend/web/src/app/store/store.ts | 2 +
.../frontend/web/src/app/types/invokeai.ts | 3 +-
.../web/src/common/hooks/useGlobalHotkeys.ts | 4 +-
.../konva/CanvasStateApiModule.ts | 4 +-
.../features/nodes/components/NodeEditor.tsx | 8 +-
.../flow/AddNodeCmdk/AddNodeCmdk.tsx | 6 +-
.../features/nodes/components/flow/Flow.tsx | 52 ++-
.../InvocationNodeCollapsedHandles.tsx | 4 +-
.../Invocation/InvocationNodeInfoIcon.tsx | 8 +-
.../fields/InputFieldDescriptionPopover.tsx | 4 +-
.../fields/InputFieldEditModeNodes.tsx | 4 +-
.../Invocation/fields/InputFieldHandle.tsx | 21 +-
.../Invocation/fields/InputFieldRenderer.tsx | 2 +-
.../Invocation/fields/InputFieldTitle.tsx | 8 +-
.../fields/InputFieldTooltipContent.tsx | 2 +-
.../Invocation/fields/OutputFieldHandle.tsx | 20 +-
.../flow/nodes/common/NodeTitle.tsx | 8 +-
.../flow/nodes/common/NodeWrapper.tsx | 15 +-
.../flow/panels/TopPanel/TopCenterPanel.tsx | 15 +
.../flow/panels/TopPanel/TopLeftPanel.tsx | 54 +++
.../flow/panels/TopPanel/TopPanel.tsx | 40 --
.../flow/panels/TopPanel/TopRightPanel.tsx | 34 ++
.../sidePanel/EditModeLeftPanelContent.tsx | 33 +-
.../PublishedWorkflowPanelContent.tsx | 25 +
.../ActiveWorkflowNameAndActions.tsx | 5 +-
.../sidePanel/WorkflowsTabLeftPanel.tsx | 18 +-
.../builder/FormElementEditModeHeader.tsx | 7 +-
.../NodeFieldElementDescriptionEditable.tsx | 6 +-
.../builder/NodeFieldElementEditMode.tsx | 8 +-
.../builder/NodeFieldElementLabel.tsx | 6 +-
.../builder/NodeFieldElementLabelEditable.tsx | 6 +-
.../builder/NodeFieldElementSettings.tsx | 2 +-
.../builder/NodeFieldElementViewMode.tsx | 9 +-
.../components/sidePanel/builder/dnd-hooks.ts | 94 +++-
.../builder/use-add-node-field-to-root.ts | 2 +-
.../inspector/InspectorDetailsTab.tsx | 4 +-
.../inspector/InspectorOutputsTab.tsx | 4 +-
.../InspectorTabEditableNodeTitle.tsx | 8 +-
.../inspector/InspectorTemplateTab.tsx | 4 +-
.../sidePanel/inspector/NodeTemplateGate.tsx | 2 +-
.../workflow/PublishWorkflowPanelContent.tsx | 429 ++++++++++++++++++
.../LockedWorkflowIcon.tsx | 23 +
.../WorkflowLibrarySideNav.tsx | 9 +-
.../workflow/WorkflowLibrary/WorkflowList.tsx | 3 +
.../WorkflowLibrary/WorkflowListItem.tsx | 23 +-
.../sidePanel/workflow/WorkflowPanel.tsx | 7 +-
.../components/sidePanel/workflow/publish.ts | 90 ++++
.../nodes/hooks/useInputFieldDefaultValue.ts | 2 +-
.../nodes/hooks/useInputFieldNamesByStatus.ts | 9 +-
...ate.ts => useInputFieldTemplateOrThrow.ts} | 19 +-
.../nodes/hooks/useInputFieldTemplateSafe.ts | 17 +
...s => useInputFieldTemplateTitleOrThrow.ts} | 7 +-
...ts => useInputFieldUserDescriptionSafe.ts} | 2 +-
.../hooks/useInputFieldUserTitleOrThrow.ts | 23 +
...lSafe.ts => useInputFieldUserTitleSafe.ts} | 8 +-
.../features/nodes/hooks/useIsBatchNode.ts | 5 +-
.../nodes/hooks/useIsWorkflowEditorLocked.ts | 13 +
.../nodes/hooks/useNodeClassification.ts | 5 +-
.../nodes/hooks/useNodeHasImageOutput.ts | 5 +-
.../nodes/hooks/useNodeNeedsUpdate.ts | 5 +-
...eTemplate.ts => useNodeTemplateOrThrow.ts} | 9 +-
.../nodes/hooks/useNodeTemplateSafe.ts | 12 +
.../hooks/useNodeTemplateTitleOrThrow.ts | 25 +
...teTitle.ts => useNodeTemplateTitleSafe.ts} | 2 +-
.../nodes/hooks/useNodeUserTitleOrThrow.ts | 21 +
...seNodeLabel.ts => useNodeUserTitleSafe.ts} | 8 +-
.../nodes/hooks/useOutputFieldNames.ts | 5 +-
.../nodes/hooks/useOutputFieldTemplate.ts | 5 +-
.../src/features/nodes/hooks/useZoomToNode.ts | 6 +-
.../web/src/features/nodes/store/selectors.ts | 6 +-
.../nodes/store/workflowLibrarySlice.ts | 2 +-
.../src/features/nodes/store/workflowSlice.ts | 23 +-
.../web/src/features/nodes/types/field.ts | 3 +
.../src/features/nodes/types/invocation.ts | 2 +-
.../features/nodes/types/workflow.test-d.ts | 4 +-
.../web/src/features/nodes/types/workflow.ts | 1 +
.../util/graph/buildLinearBatchConfig.ts | 6 +-
.../nodes/util/graph/buildNodesGraph.ts | 2 +-
.../nodes/util/workflow/migrations.ts | 2 +
.../InvokeButtonTooltip.tsx | 12 +-
.../components/InvokeQueueBackButton.tsx | 2 +-
.../QueueList/QueueItemComponent.tsx | 6 +-
.../queue/components/QueueList/constants.ts | 1 +
.../queue/hooks/useEnqueueWorkflows.ts} | 87 +++-
.../web/src/features/queue/hooks/useInvoke.ts | 65 ++-
.../web/src/features/queue/store/readiness.ts | 15 +-
.../src/features/system/store/configSlice.ts | 2 +-
.../FloatingParametersPanelButtons.tsx | 2 +-
.../LoadWorkflowConfirmationAlertDialog.tsx | 10 +-
.../components/SaveWorkflowAsDialog.tsx | 1 +
.../SaveWorkflowMenuItem.tsx | 10 +-
.../hooks/useCreateNewWorkflow.ts | 2 +
.../hooks/useSaveOrSaveAsWorkflow.ts | 11 +-
.../frontend/web/src/services/api/types.ts | 2 +-
.../src/services/events/setEventListeners.tsx | 55 ++-
101 files changed, 1410 insertions(+), 341 deletions(-)
delete mode 100644 invokeai/frontend/web/src/app/store/actions.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx
delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts
rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldTemplate.ts => useInputFieldTemplateOrThrow.ts} (54%)
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts
rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldTemplateTitle.ts => useInputFieldTemplateTitleOrThrow.ts} (59%)
rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldDescriptionSafe.ts => useInputFieldUserDescriptionSafe.ts} (89%)
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts
rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldLabelSafe.ts => useInputFieldUserTitleSafe.ts} (74%)
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts
rename invokeai/frontend/web/src/features/nodes/hooks/{useNodeTemplate.ts => useNodeTemplateOrThrow.ts} (63%)
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts
rename invokeai/frontend/web/src/features/nodes/hooks/{useNodeTemplateTitle.ts => useNodeTemplateTitleSafe.ts} (91%)
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts
rename invokeai/frontend/web/src/features/nodes/hooks/{useNodeLabel.ts => useNodeUserTitleSafe.ts} (72%)
rename invokeai/frontend/web/src/{app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts => features/queue/hooks/useEnqueueWorkflows.ts} (57%)
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 9005b80e94..246509ea88 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1783,7 +1783,37 @@
"textPlaceholder": "Empty Text",
"workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release.",
"minimum": "Minimum",
- "maximum": "Maximum"
+ "maximum": "Maximum",
+ "publish": "Publish",
+ "published": "Published",
+ "unpublish": "Unpublish",
+ "workflowLocked": "Workflow Locked",
+ "workflowLockedPublished": "Published workflows are locked for editing.\nYou can unpublish the workflow to edit it, or make a copy of it.",
+ "workflowLockedDuringPublishing": "Workflow is locked while configuring for publishing.",
+ "selectOutputNode": "Select Output Node",
+ "changeOutputNode": "Change Output Node",
+ "publishedWorkflowOutputs": "Outputs",
+ "publishedWorkflowInputs": "Inputs",
+ "unpublishableInputs": "These unpublishable inputs will be omitted",
+ "noPublishableInputs": "No publishable inputs",
+ "noOutputNodeSelected": "No output node selected",
+ "cannotPublish": "Cannot publish workflow",
+ "publishWarnings": "Warnings",
+ "errorWorkflowHasUnsavedChanges": "Workflow has unsaved changes",
+ "errorWorkflowHasBatchOrGeneratorNodes": "Workflow has batch and/or generator nodes",
+ "errorWorkflowHasInvalidGraph": "Workflow graph invalid (hover Invoke button for details)",
+ "errorWorkflowHasNoOutputNode": "No output node selected",
+ "warningWorkflowHasNoPublishableInputFields": "No publishable input fields selected - published workflow will run with only default values",
+ "warningWorkflowHasUnpublishableInputFields": "Workflow has some unpublishable inputs - these will be omitted from the published workflow",
+ "publishFailed": "Publish failed",
+ "publishFailedDesc": "There was a problem publishing the workflow. Please try again.",
+ "publishSuccess": "Your workflow is being published",
+ "publishSuccessDesc": "Check your Project Dashboard to see its progress.",
+ "publishInProgress": "Publishing in progress",
+ "publishedWorkflowIsLocked": "Published workflow is locked",
+ "publishingValidationRun": "Publishing Validation Run",
+ "publishingValidationRunInProgress": "Publishing validation run in progress.",
+ "publishedWorkflowsLocked": "Published workflows are locked and cannot be edited or run. Either unpublish the workflow or save a copy to edit or run this workflow."
}
},
"controlLayers": {
diff --git a/invokeai/frontend/web/src/app/store/actions.ts b/invokeai/frontend/web/src/app/store/actions.ts
deleted file mode 100644
index 6b7475d1b6..0000000000
--- a/invokeai/frontend/web/src/app/store/actions.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { createAction } from '@reduxjs/toolkit';
-import type { TabName } from 'features/ui/store/uiTypes';
-
-export const enqueueRequested = createAction<{
- tabName: TabName;
- prepend: boolean;
-}>('app/enqueueRequested');
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 bd21d83175..f57ad8cac9 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
@@ -10,7 +10,6 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
-import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged';
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
@@ -63,7 +62,6 @@ addGalleryImageClickedListener(startAppListening);
addGalleryOffsetChangedListener(startAppListening);
// User Invoked
-addEnqueueRequestedNodes(startAppListening);
addEnqueueRequestedLinear(startAppListening);
addEnqueueRequestedUpscale(startAppListening);
addAnyEnqueuedListener(startAppListening);
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts
index 7ab053c185..e8a7ce23cf 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts
@@ -5,7 +5,7 @@ import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAd
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
-import type { BatchConfig, ImageDTO } from 'services/api/types';
+import type { EnqueueBatchArg, ImageDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
const log = logger('queue');
@@ -19,7 +19,7 @@ export const addAdHocPostProcessingRequestedListener = (startAppListening: AppSt
const { imageDTO } = action.payload;
const state = getState();
- const enqueueBatchArg: BatchConfig = {
+ const enqueueBatchArg: EnqueueBatchArg = {
prepend: true,
batch: {
graph: await buildAdHocPostProcessingGraph({
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
index 53301f6a9d..9de26aa07c 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
@@ -1,5 +1,5 @@
+import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
-import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError';
import { withResult, withResultAsync } from 'common/util/result';
@@ -17,10 +17,11 @@ import { assert, AssertionError } from 'tsafe';
const log = logger('generation');
+export const enqueueRequestedCanvas = createAction<{ prepend: boolean }>('app/enqueueRequestedCanvas');
+
export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => {
startAppListening({
- predicate: (action): action is ReturnType =>
- enqueueRequested.match(action) && action.payload.tabName === 'canvas',
+ actionCreator: enqueueRequestedCanvas,
effect: async (action, { getState, dispatch }) => {
log.debug('Enqueue requested');
const state = getState();
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts
index 62e7026315..d979f597ba 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts
@@ -1,5 +1,5 @@
+import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
-import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { parseify } from 'common/util/serialize';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
@@ -9,10 +9,11 @@ import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endp
const log = logger('generation');
+export const enqueueRequestedUpscaling = createAction<{ prepend: boolean }>('app/enqueueRequestedUpscaling');
+
export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => {
startAppListening({
- predicate: (action): action is ReturnType =>
- enqueueRequested.match(action) && action.payload.tabName === 'upscaling',
+ actionCreator: enqueueRequestedUpscaling,
effect: async (action, { getState, dispatch }) => {
const state = getState();
const { prepend } = action.payload;
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index a2028b49e1..1bcddceabf 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -3,6 +3,7 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too
import { logger } from 'app/logging/logger';
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
+import { getDebugLoggerMiddleware } from 'app/store/middleware/debugLoggerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
@@ -175,6 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
.concat(api.middleware)
.concat(dynamicMiddlewares)
.concat(authToastMiddleware)
+ .concat(getDebugLoggerMiddleware())
.prepend(listenerMiddleware.middleware),
enhancers: (getDefaultEnhancers) => {
const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer());
diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts
index a837894916..e09c4e68f1 100644
--- a/invokeai/frontend/web/src/app/types/invokeai.ts
+++ b/invokeai/frontend/web/src/app/types/invokeai.ts
@@ -28,7 +28,8 @@ export type AppFeature =
| 'starterModels'
| 'hfToken'
| 'retryQueueItem'
- | 'cancelAndClearAll';
+ | 'cancelAndClearAll'
+ | 'publishWorkflow';
/**
* A disable-able Stable Diffusion feature
*/
diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
index 8ea6f88745..76c2db9a6d 100644
--- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
+++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts
@@ -14,7 +14,7 @@ export const useGlobalHotkeys = () => {
useRegisteredHotkeys({
id: 'invoke',
category: 'app',
- callback: queue.queueBack,
+ callback: queue.enqueueBack,
options: {
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,
@@ -26,7 +26,7 @@ export const useGlobalHotkeys = () => {
useRegisteredHotkeys({
id: 'invokeFront',
category: 'app',
- callback: queue.queueFront,
+ callback: queue.enqueueFront,
options: {
enabled: !queue.isDisabled && !queue.isLoading,
preventDefault: true,
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
index fbb5705a6d..84dad91f03 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
@@ -54,7 +54,7 @@ import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { getImageDTO } from 'services/api/endpoints/images';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
-import type { BatchConfig, ImageDTO, S } from 'services/api/types';
+import type { EnqueueBatchArg, ImageDTO, S } from 'services/api/types';
import { QueueError } from 'services/events/errors';
import type { Param0 } from 'tsafe';
import { assert } from 'tsafe';
@@ -291,7 +291,7 @@ export class CanvasStateApiModule extends CanvasModuleBase {
*/
const origin = getPrefixedId(graph.id);
- const batch: BatchConfig = {
+ const batch: EnqueueBatchArg = {
prepend,
batch: {
graph: graph.getGraph(),
diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx
index 02e2abeadc..168af9dff9 100644
--- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx
@@ -2,7 +2,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk';
-import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
+import { TopCenterPanel } from 'features/nodes/components/flow/panels/TopPanel/TopCenterPanel';
+import { TopLeftPanel } from 'features/nodes/components/flow/panels/TopPanel/TopLeftPanel';
+import { TopRightPanel } from 'features/nodes/components/flow/panels/TopPanel/TopRightPanel';
import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -32,7 +34,9 @@ const NodeEditor = () => {
<>
-
+
+
+
>
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx
index 334325d096..3f12cf10a5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx
@@ -18,6 +18,7 @@ import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useBuildNode } from 'features/nodes/hooks/useBuildNode';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import {
$addNodeCmdk,
$cursorPos,
@@ -146,6 +147,7 @@ export const AddNodeCmdk = memo(() => {
const [searchTerm, setSearchTerm] = useState('');
const addNode = useAddNode();
const tab = useAppSelector(selectActiveTab);
+ const isLocked = useIsWorkflowEditorLocked();
// Filtering the list is expensive - debounce the search term to avoid stutters
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const isOpen = useStore($addNodeCmdk);
@@ -160,8 +162,8 @@ export const AddNodeCmdk = memo(() => {
id: 'addNode',
category: 'workflows',
callback: open,
- options: { enabled: tab === 'workflows', preventDefault: true },
- dependencies: [open, tab],
+ options: { enabled: tab === 'workflows' && !isLocked, preventDefault: true },
+ dependencies: [open, tab, isLocked],
});
const onChange = useCallback((e: ChangeEvent) => {
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 8e63098db6..24397ce3ec 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx
@@ -4,6 +4,7 @@ import type {
EdgeChange,
HandleType,
NodeChange,
+ NodeMouseHandler,
OnEdgesChange,
OnInit,
OnMoveEnd,
@@ -16,8 +17,10 @@ import type {
import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
+import { $isSelectingOutputNode, $outputNodeId } from 'features/nodes/components/sidePanel/workflow/publish';
import { useConnection } from 'features/nodes/hooks/useConnection';
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import {
@@ -44,7 +47,7 @@ import {
import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil';
import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
-import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation';
+import { type AnyEdge, type AnyNode, isInvocationNode } from 'features/nodes/types/invocation';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import type { CSSProperties, MouseEvent } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
@@ -92,6 +95,8 @@ export const Flow = memo(() => {
const updateNodeInternals = useUpdateNodeInternals();
const store = useAppStore();
const isWorkflowsFocused = useIsRegionFocused('workflows');
+ const isLocked = useIsWorkflowEditorLocked();
+
useFocusRegion('workflows', flowWrapper);
useSyncExecutionState();
@@ -215,7 +220,7 @@ export const Flow = memo(() => {
id: 'copySelection',
category: 'workflows',
callback: copySelection,
- options: { preventDefault: true },
+ options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
dependencies: [copySelection],
});
@@ -244,24 +249,24 @@ export const Flow = memo(() => {
id: 'selectAll',
category: 'workflows',
callback: selectAll,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
- dependencies: [selectAll, isWorkflowsFocused],
+ options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
+ dependencies: [selectAll, isWorkflowsFocused, isLocked],
});
useRegisteredHotkeys({
id: 'pasteSelection',
category: 'workflows',
callback: pasteSelection,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
- dependencies: [pasteSelection],
+ options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
+ dependencies: [pasteSelection, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
id: 'pasteSelectionWithEdges',
category: 'workflows',
callback: pasteSelectionWithEdges,
- options: { enabled: isWorkflowsFocused, preventDefault: true },
- dependencies: [pasteSelectionWithEdges],
+ options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true },
+ dependencies: [pasteSelectionWithEdges, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
@@ -270,8 +275,8 @@ export const Flow = memo(() => {
callback: () => {
dispatch(undo());
},
- options: { enabled: isWorkflowsFocused && mayUndo, preventDefault: true },
- dependencies: [mayUndo],
+ options: { enabled: isWorkflowsFocused && !isLocked && mayUndo, preventDefault: true },
+ dependencies: [mayUndo, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
@@ -280,8 +285,8 @@ export const Flow = memo(() => {
callback: () => {
dispatch(redo());
},
- options: { enabled: isWorkflowsFocused && mayRedo, preventDefault: true },
- dependencies: [mayRedo],
+ options: { enabled: isWorkflowsFocused && !isLocked && mayRedo, preventDefault: true },
+ dependencies: [mayRedo, isLocked, isWorkflowsFocused],
});
const onEscapeHotkey = useCallback(() => {
@@ -318,10 +323,22 @@ export const Flow = memo(() => {
id: 'deleteSelection',
category: 'workflows',
callback: deleteSelection,
- options: { preventDefault: true, enabled: isWorkflowsFocused },
- dependencies: [deleteSelection, isWorkflowsFocused],
+ options: { preventDefault: true, enabled: isWorkflowsFocused && !isLocked },
+ dependencies: [deleteSelection, isWorkflowsFocused, isLocked],
});
+ const onNodeClick = useCallback>((e, node) => {
+ if (!$isSelectingOutputNode.get()) {
+ return;
+ }
+ if (!isInvocationNode(node)) {
+ return;
+ }
+ const { id } = node.data;
+ $outputNodeId.set(id);
+ $isSelectingOutputNode.set(false);
+ }, []);
+
return (
id="workflow-editor"
@@ -332,6 +349,7 @@ export const Flow = memo(() => {
nodes={nodes}
edges={edges}
onInit={onInit}
+ onNodeClick={onNodeClick}
onMouseMove={onMouseMove}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
@@ -344,6 +362,12 @@ export const Flow = memo(() => {
onMoveEnd={handleMoveEnd}
connectionLineComponent={CustomConnectionLine}
isValidConnection={isValidConnection}
+ edgesFocusable={!isLocked}
+ edgesReconnectable={!isLocked}
+ nodesDraggable={!isLocked}
+ nodesConnectable={!isLocked}
+ nodesFocusable={!isLocked}
+ elementsSelectable={!isLocked}
minZoom={0.1}
snapToGrid={shouldSnapToGrid}
snapGrid={snapGrid}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx
index 456f89daa0..0f50e0595b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx
@@ -1,5 +1,5 @@
import { Handle, Position } from '@xyflow/react';
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
+import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { map } from 'lodash-es';
import type { CSSProperties } from 'react';
import { memo } from 'react';
@@ -19,7 +19,7 @@ const collapsedHandleStyles: CSSProperties = {
};
const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
if (!template) {
return null;
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx
index f563d0b68c..7b074580df 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx
@@ -1,9 +1,9 @@
import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { compare } from 'compare-versions';
-import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes';
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
+import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
+import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,9 +27,9 @@ InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon';
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
const notes = useInvocationNodeNotes(nodeId);
- const label = useNodeLabel(nodeId);
+ const label = useNodeUserTitleSafe(nodeId);
const version = useNodeVersion(nodeId);
- const nodeTemplate = useNodeTemplate(nodeId);
+ const nodeTemplate = useNodeTemplateOrThrow(nodeId);
const { t } = useTranslation();
const title = useMemo(() => {
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx
index cf832ef610..2aff85553e 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx
@@ -8,7 +8,7 @@ import {
Textarea,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
-import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
+import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
@@ -48,7 +48,7 @@ InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
const Content = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
- const description = useInputFieldDescriptionSafe(nodeId, fieldName);
+ const description = useInputFieldUserDescriptionSafe(nodeId, fieldName);
const onChange = useCallback(
(e: ChangeEvent) => {
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx
index 34b1373853..e2bede8a19 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx
@@ -7,7 +7,7 @@ import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/componen
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { memo, useRef } from 'react';
@@ -100,7 +100,7 @@ const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemp
const draggableRef = useRef(null);
const dragHandleRef = useRef(null);
- const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef);
+ const isDragging = useNodeFieldDnd(nodeId, fieldName, fieldTemplate, draggableRef, dragHandleRef);
return (
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx
index d25664070e..b526ca46a1 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx
@@ -7,7 +7,8 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
@@ -105,9 +106,16 @@ type HandleCommonProps = {
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
+ const isLocked = useIsWorkflowEditorLocked();
return (
-
+
{
if (connectionError !== null) {
@@ -140,7 +149,13 @@ const ConnectionInProgressHandle = memo(
return (
-
+
{
const { nodeId, fieldName, isInvalid, isDragging } = props;
const inputRef = useRef(null);
- const label = useInputFieldLabelSafe(nodeId, fieldName);
- const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
+ const label = useInputFieldUserTitleSafe(nodeId, fieldName);
+ const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const { t } = useTranslation();
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx
index e456f771aa..43ca1fc90c 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx
@@ -1,7 +1,7 @@
import { Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { useInputFieldErrors } from 'features/nodes/hooks/useInputFieldErrors';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx
index 536fb58e62..c76cf7e5d6 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx
@@ -7,6 +7,7 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
@@ -105,9 +106,17 @@ type HandleCommonProps = {
};
const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => {
+ const isLocked = useIsWorkflowEditorLocked();
+
return (
-
+
{
if (connectionErrorTKey !== null) {
@@ -140,7 +150,13 @@ const ConnectionInProgressHandle = memo(
return (
-
+
{
const dispatch = useAppDispatch();
- const label = useNodeLabel(nodeId);
+ const label = useNodeUserTitleSafe(nodeId);
const batchGroupId = useBatchGroupId(nodeId);
const batchGroupColorToken = useBatchGroupColorToken(batchGroupId);
- const templateTitle = useNodeTemplateTitle(nodeId);
+ const templateTitle = useNodeTemplateTitleSafe(nodeId);
const { t } = useTranslation();
const inputRef = useRef(null);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx
index 418b402e85..ab9743c155 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx
@@ -1,6 +1,7 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
@@ -62,6 +63,12 @@ const containerSx: SystemStyleObject = {
display: 'block',
shadow: '0 0 0 3px var(--invoke-colors-blue-300)',
},
+ '&[data-is-editor-locked="true"]': {
+ '& *': {
+ cursor: 'not-allowed',
+ pointerEvents: 'none',
+ },
+ },
};
const shadowsSx: SystemStyleObject = {
@@ -98,7 +105,8 @@ const NodeWrapper = (props: NodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const mouseOverNode = useMouseOverNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
- const zoomToNode = useZoomToNode();
+ const zoomToNode = useZoomToNode(nodeId);
+ const isLocked = useIsWorkflowEditorLocked();
const executionState = useNodeExecutionState(nodeId);
const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS;
@@ -126,9 +134,9 @@ const NodeWrapper = (props: NodeWrapperProps) => {
// This target is marked as not fitting the view on double click
return;
}
- zoomToNode(nodeId);
+ zoomToNode();
},
- [nodeId, zoomToNode]
+ [zoomToNode]
);
return (
@@ -141,6 +149,7 @@ const NodeWrapper = (props: NodeWrapperProps) => {
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
+ data-is-editor-locked={isLocked}
data-is-selected={selected}
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
>
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx
new file mode 100644
index 0000000000..ea4b3a9a43
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx
@@ -0,0 +1,15 @@
+import { Flex } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
+import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
+import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
+import { memo } from 'react';
+
+export const TopCenterPanel = memo(() => {
+ const name = useAppSelector(selectWorkflowName);
+ return (
+
+ {!!name.length && }
+
+ );
+});
+TopCenterPanel.displayName = 'TopCenterPanel';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx
new file mode 100644
index 0000000000..bed3fe8152
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx
@@ -0,0 +1,54 @@
+import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useAppSelector } from 'app/store/storeHooks';
+import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
+import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
+import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
+import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const TopLeftPanel = memo(() => {
+ const isLocked = useIsWorkflowEditorLocked();
+ const isInPublishFlow = useStore($isInPublishFlow);
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
+ const isValidationRunInProgress = useIsValidationRunInProgress();
+
+ const { t } = useTranslation();
+ return (
+
+ {!isLocked && (
+
+
+
+
+ )}
+ {isLocked && (
+
+
+
+ {t('workflows.builder.workflowLocked')}
+ {isValidationRunInProgress && (
+
+ {t('workflows.builder.publishingValidationRunInProgress')}
+
+ )}
+ {isInPublishFlow && !isValidationRunInProgress && (
+
+ {t('workflows.builder.workflowLockedDuringPublishing')}
+
+ )}
+ {isPublished && (
+
+ {t('workflows.builder.workflowLockedPublished')}
+
+ )}
+
+
+ )}
+
+ );
+});
+
+TopLeftPanel.displayName = 'TopLeftPanel';
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
deleted file mode 100644
index e45eb937dc..0000000000
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
-import { useAppSelector } from 'app/store/storeHooks';
-import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
-import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
-import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
-import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
-import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
-import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName';
-import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
-import { memo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiGearSixFill } from 'react-icons/pi';
-
-const TopCenterPanel = () => {
- const name = useAppSelector(selectWorkflowName);
- const modal = useWorkflowEditorSettingsModal();
-
- const { t } = useTranslation();
- return (
-
-
-
-
-
-
- {!!name.length && }
-
-
-
- }
- onClick={modal.setTrue}
- />
-
- );
-};
-
-export default memo(TopCenterPanel);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx
new file mode 100644
index 0000000000..af778d3a9f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx
@@ -0,0 +1,34 @@
+import { Flex, IconButton } from '@invoke-ai/ui-library';
+import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
+import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
+import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiGearSixFill } from 'react-icons/pi';
+
+export const TopRightPanel = memo(() => {
+ const modal = useWorkflowEditorSettingsModal();
+ const isLocked = useIsWorkflowEditorLocked();
+
+ const { t } = useTranslation();
+
+ if (isLocked) {
+ return null;
+ }
+
+ return (
+
+
+
+ }
+ onClick={modal.setTrue}
+ />
+
+ );
+});
+
+TopRightPanel.displayName = 'TopRightPanel';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx
index 24805a659a..e411a8976d 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx
@@ -1,5 +1,4 @@
import { Box } from '@invoke-ai/ui-library';
-import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle';
import type { CSSProperties } from 'react';
import { memo, useCallback, useRef } from 'react';
@@ -23,23 +22,21 @@ export const EditModeLeftPanelContent = memo(() => {
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx
new file mode 100644
index 0000000000..849bba652c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx
@@ -0,0 +1,25 @@
+import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library';
+import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiCopyBold, PiLockOpenBold } from 'react-icons/pi';
+
+export const PublishedWorkflowPanelContent = memo(() => {
+ const { t } = useTranslation();
+ const saveAs = useSaveOrSaveAsWorkflow();
+ return (
+
+
+ {t('workflows.builder.workflowLocked')}
+
+ {t('workflows.builder.publishedWorkflowsLocked')}
+ }>
+ {t('common.saveAs')}
+
+ }>
+ {t('workflows.builder.unpublish')}
+
+
+ );
+});
+PublishedWorkflowPanelContent.displayName = 'PublishedWorkflowPanelContent';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx
index 3bb2dff02b..eb0d851f28 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx
@@ -2,7 +2,7 @@ import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { WorkflowListMenuTrigger } from 'features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger';
import { WorkflowViewEditToggleButton } from 'features/nodes/components/sidePanel/WorkflowViewEditToggleButton';
-import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
+import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { WorkflowLibraryMenu } from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
@@ -10,12 +10,13 @@ import SaveWorkflowButton from './SaveWorkflowButton';
export const ActiveWorkflowNameAndActions = memo(() => {
const mode = useAppSelector(selectWorkflowMode);
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
return (
- {mode === 'edit' && }
+ {mode === 'edit' && !isPublished && }
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx
index 089be12f08..9789c179c3 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx
@@ -1,22 +1,30 @@
import { Flex } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { EditModeLeftPanelContent } from 'features/nodes/components/sidePanel/EditModeLeftPanelContent';
+import { PublishedWorkflowPanelContent } from 'features/nodes/components/sidePanel/PublishedWorkflowPanelContent';
+import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
+import { PublishWorkflowPanelContent } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent';
import { ActiveWorkflowDescription } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription';
import { ActiveWorkflowNameAndActions } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions';
-import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
+import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { ViewModeLeftPanelContent } from './viewMode/ViewModeLeftPanelContent';
const WorkflowsTabLeftPanel = () => {
const mode = useAppSelector(selectWorkflowMode);
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
+ const isInPublishFlow = useStore($isInPublishFlow);
return (
-
- {mode === 'view' && }
- {mode === 'view' && }
- {mode === 'edit' && }
+ {isInPublishFlow && }
+ {!isInPublishFlow && }
+ {!isInPublishFlow && !isPublished && mode === 'view' && }
+ {!isInPublishFlow && !isPublished && mode === 'view' && }
+ {!isInPublishFlow && !isPublished && mode === 'edit' && }
+ {isPublished && }
);
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx
index 5c24b26bc9..753f93f063 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx
@@ -67,11 +67,8 @@ FormElementEditModeHeader.displayName = 'FormElementEditModeHeader';
const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => {
const { t } = useTranslation();
const { nodeId } = element.data.fieldIdentifier;
- const zoomToNode = useZoomToNode();
+ const zoomToNode = useZoomToNode(nodeId);
const mouseOverFormField = useMouseOverFormField(nodeId);
- const onClick = useCallback(() => {
- zoomToNode(nodeId);
- }, [nodeId, zoomToNode]);
return (
{
onMouseOut={mouseOverFormField.handleMouseOut}
tooltip={t('workflows.builder.zoomToNode')}
aria-label={t('workflows.builder.zoomToNode')}
- onClick={onClick}
+ onClick={zoomToNode}
icon={}
variant="link"
size="sm"
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx
index 9f386b496b..08b31fe22b 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx
@@ -2,8 +2,8 @@ import { FormHelperText, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { linkifyOptions, linkifySx } from 'common/components/linkify';
import { useEditable } from 'common/hooks/useEditable';
-import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
+import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
@@ -13,7 +13,7 @@ export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeField
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
- const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
+ const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef(null);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx
index 8aa3dccc97..60fd35fd44 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx
@@ -39,7 +39,7 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement })
return (
-
+
);
@@ -105,9 +105,9 @@ const nodeFieldOverlaySx: SystemStyleObject = {
},
};
-const NodeFieldElementOverlay = memo(({ element }: { element: NodeFieldElement }) => {
- const mouseOverNode = useMouseOverNode(element.data.fieldIdentifier.nodeId);
- const mouseOverFormField = useMouseOverFormField(element.data.fieldIdentifier.nodeId);
+export const NodeFieldElementOverlay = memo(({ nodeId }: { nodeId: string }) => {
+ const mouseOverNode = useMouseOverNode(nodeId);
+ const mouseOverFormField = useMouseOverFormField(nodeId);
return (
{
const { data } = el;
const { fieldIdentifier } = data;
- const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
+ const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx
index b78315cc7c..570bf17570 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx
@@ -2,8 +2,8 @@ import { Flex, FormLabel, Input, Spacer } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton';
-import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
+import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo, useCallback, useRef } from 'react';
@@ -12,7 +12,7 @@ export const NodeFieldElementLabelEditable = memo(({ el }: { el: NodeFieldElemen
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
- const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
+ const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef(null);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx
index c3998b5317..4f0c3369bb 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx
@@ -15,7 +15,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings';
import { NodeFieldElementIntegerSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings';
import { NodeFieldElementStringSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringSettings';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import {
isFloatFieldInputTemplate,
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx
index 5f6dc87dbc..ded312661d 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx
@@ -5,8 +5,9 @@ import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { NodeFieldElementLabel } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabel';
-import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
-import { useInputFieldTemplateOrThrow, useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
+import { useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplateSafe';
+import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
@@ -36,7 +37,7 @@ const useFormatFallbackLabel = () => {
export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
- const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
+ const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const containerCtx = useContainerContext();
const formatFallbackLabel = useFormatFallbackLabel();
@@ -69,7 +70,7 @@ NodeFieldElementViewMode.displayName = 'NodeFieldElementViewMode';
const NodeFieldElementViewModeContent = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier, showDescription } = data;
- const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
+ const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const _description = useMemo(
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts
index 946e9cc22b..8b607f129f 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts
@@ -1,4 +1,6 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types';
+import type { ElementDragPayload } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
draggable,
dropTargetForElements,
@@ -33,7 +35,7 @@ import {
selectFormRootElementId,
selectWorkflowSlice,
} from 'features/nodes/store/workflowSlice';
-import type { FieldIdentifier, FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
+import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import type { ElementId, FormElement } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, isContainerElement } from 'features/nodes/types/workflow';
import type { RefObject } from 'react';
@@ -58,6 +60,27 @@ const isFormElementDndData = (data: Record): data is F
return uniqueFormElementDndKey in data;
};
+const uniqueNodeFieldDndKey = Symbol('node-field');
+type NodeFieldDndData = {
+ [uniqueNodeFieldDndKey]: true;
+ nodeId: string;
+ fieldName: string;
+ fieldTemplate: FieldInputTemplate;
+};
+const buildNodeFieldDndData = (
+ nodeId: string,
+ fieldName: string,
+ fieldTemplate: FieldInputTemplate
+): NodeFieldDndData => ({
+ [uniqueNodeFieldDndKey]: true,
+ nodeId,
+ fieldName,
+ fieldTemplate,
+});
+const isNodeFieldDndData = (data: Record): data is NodeFieldDndData => {
+ return uniqueNodeFieldDndKey in data;
+};
+
/**
* Flashes an element by changing its background color. Used to indicate that an element has been moved.
* @param elementId The id of the element to flash
@@ -133,6 +156,27 @@ const useGetInitialValue = () => {
return _getInitialValue;
};
+const getSourceElement = (source: ElementDragPayload) => {
+ if (isNodeFieldDndData(source.data)) {
+ const { nodeId, fieldName, fieldTemplate } = source.data;
+ return buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
+ }
+
+ if (isFormElementDndData(source.data)) {
+ return source.data.element;
+ }
+
+ return null;
+};
+
+const getTargetElement = (target: DropTargetRecord) => {
+ if (isFormElementDndData(target.data)) {
+ return target.data.element;
+ }
+
+ return null;
+};
+
/**
* Singleton hook that monitors for builder dnd events and dispatches actions accordingly.
*/
@@ -156,20 +200,20 @@ export const useBuilderDndMonitor = () => {
useEffect(() => {
return monitorForElements({
- canMonitor: ({ source }) => isFormElementDndData(source.data),
+ canMonitor: ({ source }) => isFormElementDndData(source.data) || isNodeFieldDndData(source.data),
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
- if (!isFormElementDndData(source.data) || !isFormElementDndData(target.data)) {
+ const sourceElement = getSourceElement(source);
+ const targetElement = getTargetElement(target);
+
+ if (!sourceElement || !targetElement) {
return;
}
- const sourceElement = source.data.element;
- const targetElement = target.data.element;
-
if (sourceElement.id === targetElement.id) {
// Dropping on self is a no-op
return;
@@ -359,8 +403,15 @@ export const useFormElementDnd = (
element: draggableElement,
// TODO(psyche): This causes a kinda jittery behaviour - need a better heuristic to determine stickiness
getIsSticky: () => false,
- canDrop: ({ source }) =>
- isFormElementDndData(source.data) && source.data.element.id !== getElement(elementId).parentId,
+ canDrop: ({ source }) => {
+ if (isNodeFieldDndData(source.data)) {
+ return true;
+ }
+ if (isFormElementDndData(source.data)) {
+ return source.data.element.id !== getElement(elementId).parentId;
+ }
+ return false;
+ },
getData: ({ input }) => {
const element = getElement(elementId);
@@ -423,8 +474,16 @@ export const useRootElementDropTarget = (droppableRef: RefObject
dropTargetForElements({
element: droppableElement,
getIsSticky: () => false,
- canDrop: ({ source }) =>
- getElement(rootElementId, isContainerElement).data.children.length === 0 && isFormElementDndData(source.data),
+ canDrop: ({ source }) => {
+ const rootElement = getElement(rootElementId, isContainerElement);
+ if (rootElement.data.children.length !== 0) {
+ return false;
+ }
+ if (isNodeFieldDndData(source.data) || isFormElementDndData(source.data)) {
+ return true;
+ }
+ return false;
+ },
getData: ({ input }) => {
const element = getElement(rootElementId, isContainerElement);
@@ -455,7 +514,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject
/**
* Hook that provides dnd functionality for node fields.
*
- * @param fieldIdentifier The identifier of the node field
+ * @param nodeId: The id of the node
+ * @param fieldName: The name of the field
* @param fieldTemplate The template of the node field, required to build the form element
* @param draggableRef The ref of the draggable HTML element
* @param dragHandleRef The ref of the drag handle HTML element
@@ -463,7 +523,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject
* @returns Whether the node field is currently being dragged
*/
export const useNodeFieldDnd = (
- fieldIdentifier: FieldIdentifier,
+ nodeId: string,
+ fieldName: string,
fieldTemplate: FieldInputTemplate,
draggableRef: RefObject,
dragHandleRef: RefObject
@@ -481,12 +542,7 @@ export const useNodeFieldDnd = (
draggable({
element: draggableElement,
dragHandle: dragHandleElement,
- getInitialData: () => {
- const { nodeId, fieldName } = fieldIdentifier;
- const { type } = fieldTemplate;
- const element = buildNodeFieldElement(nodeId, fieldName, type);
- return buildFormElementDndData(element);
- },
+ getInitialData: () => buildNodeFieldDndData(nodeId, fieldName, fieldTemplate),
onDragStart: () => {
setIsDragging(true);
},
@@ -495,7 +551,7 @@ export const useNodeFieldDnd = (
},
})
);
- }, [dragHandleRef, draggableRef, fieldIdentifier, fieldTemplate]);
+ }, [dragHandleRef, draggableRef, fieldName, fieldTemplate, nodeId]);
return isDragging;
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts
index ed2e6b7281..b9a306052a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts
@@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { formElementAdded, selectFormRootElementId } from 'features/nodes/store/workflowSlice';
import { buildNodeFieldElement } from 'features/nodes/types/workflow';
import { useCallback } from 'react';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx
index 2ffbdb0b9d..83f5713bff 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx
@@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import { InvocationNodeNotesTextarea } from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeNotesTextarea';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
+import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
@@ -36,7 +36,7 @@ export default memo(InspectorDetailsTab);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const version = useNodeVersion(nodeId);
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const needsUpdate = useNodeNeedsUpdate(nodeId);
return (
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx
index d1b5009f8d..8f23f747dc 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx
@@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
+import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -37,7 +37,7 @@ const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}`
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const nes = useNodeExecutionState(nodeId);
if (!nes || nes.outputs.length === 0) {
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx
index 8431187bd5..e0e1d26827 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx
@@ -1,8 +1,8 @@
import { Flex, Input, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
-import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
-import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
+import { useNodeTemplateTitleSafe } from 'features/nodes/hooks/useNodeTemplateTitleSafe';
+import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,8 +14,8 @@ type Props = {
const InspectorTabEditableNodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
- const label = useNodeLabel(nodeId);
- const templateTitle = useNodeTemplateTitle(nodeId);
+ const label = useNodeUserTitleSafe(nodeId);
+ const templateTitle = useNodeTemplateTitleSafe(nodeId);
const { t } = useTranslation();
const inputRef = useRef(null);
const onChange = useCallback(
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx
index d92366ce7b..685bd53f8e 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx
@@ -2,7 +2,7 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
+import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -29,7 +29,7 @@ export default memo(NodeTemplateInspector);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
return ;
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx
index 87ea032368..c6028dc1c0 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx
@@ -1,4 +1,4 @@
-import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplate';
+import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
import type { PropsWithChildren, ReactNode } from 'react';
import { memo } from 'react';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx
new file mode 100644
index 0000000000..e54455ad1c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx
@@ -0,0 +1,429 @@
+import {
+ Button,
+ ButtonGroup,
+ Divider,
+ Flex,
+ ListItem,
+ Spacer,
+ Text,
+ Tooltip,
+ UnorderedList,
+} from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { logger } from 'app/logging/logger';
+import { $projectUrl } from 'app/store/nanostores/projectId';
+import { useAppSelector } from 'app/store/storeHooks';
+import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
+import { withResultAsync } from 'common/util/result';
+import { parseify } from 'common/util/serialize';
+import { ExternalLink } from 'features/gallery/components/ImageViewer/NoContentForViewer';
+import { NodeFieldElementOverlay } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
+import {
+ $isInPublishFlow,
+ $isReadyToDoValidationRun,
+ $isSelectingOutputNode,
+ $outputNodeId,
+ $validationRunBatchId,
+ usePublishInputs,
+} from 'features/nodes/components/sidePanel/workflow/publish';
+import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow';
+import { useInputFieldUserTitleOrThrow } from 'features/nodes/hooks/useInputFieldUserTitleOrThrow';
+import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode';
+import { useNodeTemplateTitleOrThrow } from 'features/nodes/hooks/useNodeTemplateTitleOrThrow';
+import { useNodeUserTitleOrThrow } from 'features/nodes/hooks/useNodeUserTitleOrThrow';
+import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
+import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate';
+import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
+import { selectHasBatchOrGeneratorNodes } from 'features/nodes/store/selectors';
+import { selectIsWorkflowSaved } from 'features/nodes/store/workflowSlice';
+import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
+import { $isReadyToEnqueue } from 'features/queue/store/readiness';
+import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
+import { toast } from 'features/toast/toast';
+import type { PropsWithChildren } from 'react';
+import { memo, useCallback, useMemo } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import { PiLightningFill, PiSignOutBold, PiXBold } from 'react-icons/pi';
+import { serializeError } from 'serialize-error';
+import { assert } from 'tsafe';
+
+const log = logger('generation');
+
+export const PublishWorkflowPanelContent = memo(() => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+PublishWorkflowPanelContent.displayName = 'PublishWorkflowPanelContent';
+
+const OutputFields = memo(() => {
+ const { t } = useTranslation();
+ const outputNodeId = useStore($outputNodeId);
+
+ if (!outputNodeId) {
+ return (
+
+
+ {t('workflows.builder.noOutputNodeSelected')}
+
+
+ );
+ }
+
+ return ;
+});
+OutputFields.displayName = 'OutputFields';
+
+const OutputFieldsContent = memo(({ outputNodeId }: { outputNodeId: string }) => {
+ const { t } = useTranslation();
+ const outputFieldNames = useOutputFieldNames(outputNodeId);
+
+ return (
+
+ {t('workflows.builder.publishedWorkflowOutputs')}
+
+ {outputFieldNames.map((fieldName) => (
+
+ ))}
+
+ );
+});
+OutputFieldsContent.displayName = 'OutputFieldsContent';
+
+const PublishableInputFields = memo(() => {
+ const { t } = useTranslation();
+ const inputs = usePublishInputs();
+
+ if (inputs.publishable.length === 0) {
+ return (
+
+
+ {t('workflows.builder.noPublishableInputs')}
+
+
+ );
+ }
+
+ return (
+
+ {t('workflows.builder.publishedWorkflowInputs')}
+
+ {inputs.publishable.map(({ nodeId, fieldName }) => {
+ return ;
+ })}
+
+ );
+});
+PublishableInputFields.displayName = 'PublishableInputFields';
+
+const UnpublishableInputFields = memo(() => {
+ const { t } = useTranslation();
+ const inputs = usePublishInputs();
+
+ if (inputs.unpublishable.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {t('workflows.builder.unpublishableInputs')}
+
+
+ {inputs.unpublishable.map(({ nodeId, fieldName }) => {
+ return ;
+ })}
+
+ );
+});
+UnpublishableInputFields.displayName = 'UnpublishableInputFields';
+
+const SelectOutputNodeButton = memo(() => {
+ const { t } = useTranslation();
+ const outputNodeId = useStore($outputNodeId);
+ const isSelectingOutputNode = useStore($isSelectingOutputNode);
+ const onClick = useCallback(() => {
+ $outputNodeId.set(null);
+ $isSelectingOutputNode.set(true);
+ }, []);
+ return (
+ } isDisabled={isSelectingOutputNode} onClick={onClick}>
+ {outputNodeId ? t('workflows.builder.changeOutputNode') : t('workflows.builder.selectOutputNode')}
+
+ );
+});
+SelectOutputNodeButton.displayName = 'SelectOutputNodeButton';
+
+const CancelPublishButton = memo(() => {
+ const { t } = useTranslation();
+ const onClick = useCallback(() => {
+ $isInPublishFlow.set(false);
+ $isSelectingOutputNode.set(false);
+ $outputNodeId.set(null);
+ }, []);
+ return (
+ } onClick={onClick}>
+ {t('common.cancel')}
+
+ );
+});
+CancelPublishButton.displayName = 'CancelDeployButton';
+
+const PublishWorkflowButton = memo(() => {
+ const { t } = useTranslation();
+ const isReadyToDoValidationRun = useStore($isReadyToDoValidationRun);
+ const isReadyToEnqueue = useStore($isReadyToEnqueue);
+ const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
+ const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
+ const outputNodeId = useStore($outputNodeId);
+ const isSelectingOutputNode = useStore($isSelectingOutputNode);
+ const inputs = usePublishInputs();
+
+ const projectUrl = useStore($projectUrl);
+
+ const enqueue = useEnqueueWorkflows();
+ const onClick = useCallback(async () => {
+ const result = await withResultAsync(() => enqueue(true, true));
+ if (result.isErr()) {
+ toast({
+ id: 'TOAST_PUBLISH_FAILED',
+ status: 'error',
+ title: t('workflows.builder.publishFailed'),
+ description: t('workflows.builder.publishFailedDesc'),
+ duration: null,
+ });
+ log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch');
+ } else {
+ toast({
+ id: 'TOAST_PUBLISH_SUCCESSFUL',
+ status: 'success',
+ title: t('workflows.builder.publishSuccess'),
+ description: (
+ ,
+ }}
+ />
+ ),
+ duration: null,
+ });
+ assert(result.value.enqueueResult.batch.batch_id);
+ $validationRunBatchId.set(result.value.enqueueResult.batch.batch_id);
+ log.debug(parseify(result.value), 'Enqueued batch');
+ }
+ }, [enqueue, projectUrl, t]);
+
+ return (
+ 0}
+ hasUnpublishableInputs={inputs.unpublishable.length > 0}
+ >
+ }
+ isDisabled={
+ !isReadyToDoValidationRun ||
+ !isReadyToEnqueue ||
+ hasBatchOrGeneratorNodes ||
+ !(outputNodeId !== null && !isSelectingOutputNode)
+ }
+ onClick={onClick}
+ >
+ {t('workflows.builder.publish')}
+
+
+ );
+});
+PublishWorkflowButton.displayName = 'DoValidationRunButton';
+
+const NodeInputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
+ const mouseOverFormField = useMouseOverFormField(nodeId);
+ const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
+ const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
+ const fieldUserTitle = useInputFieldUserTitleOrThrow(nodeId, fieldName);
+ const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
+ const zoomToNode = useZoomToNode(nodeId);
+
+ return (
+
+ {`${nodeUserTitle || nodeTemplateTitle} -> ${fieldUserTitle || fieldTemplateTitle}`}
+ {`${nodeId} -> ${fieldName}`}
+
+
+ );
+});
+NodeInputFieldPreview.displayName = 'NodeInputFieldPreview';
+
+const NodeOutputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
+ const mouseOverFormField = useMouseOverFormField(nodeId);
+ const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
+ const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
+ const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
+ const zoomToNode = useZoomToNode(nodeId);
+
+ return (
+
+ {`${nodeUserTitle || nodeTemplateTitle} -> ${fieldTemplate.title}`}
+ {`${nodeId} -> ${fieldName}`}
+
+
+ );
+});
+NodeOutputFieldPreview.displayName = 'NodeOutputFieldPreview';
+
+export const StartPublishFlowButton = memo(() => {
+ const { t } = useTranslation();
+ const publishWorkflowIsEnabled = useFeatureStatus('publishWorkflow');
+ const isReadyToEnqueue = useStore($isReadyToEnqueue);
+ const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved);
+ const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes);
+ const inputs = usePublishInputs();
+
+ const onClick = useCallback(() => {
+ $isInPublishFlow.set(true);
+ }, []);
+
+ return (
+ 0}
+ hasUnpublishableInputs={inputs.unpublishable.length > 0}
+ >
+ }
+ variant="ghost"
+ size="sm"
+ isDisabled={!publishWorkflowIsEnabled || !isWorkflowSaved || hasBatchOrGeneratorNodes}
+ >
+ {t('workflows.builder.publish')}
+
+
+ );
+});
+
+StartPublishFlowButton.displayName = 'StartPublishFlowButton';
+
+const PublishTooltip = memo(
+ ({
+ isWorkflowSaved,
+ hasBatchOrGeneratorNodes,
+ isReadyToEnqueue,
+ hasOutputNode,
+ hasPublishableInputs,
+ hasUnpublishableInputs,
+ children,
+ }: PropsWithChildren<{
+ isWorkflowSaved: boolean;
+ hasBatchOrGeneratorNodes: boolean;
+ isReadyToEnqueue: boolean;
+ hasOutputNode: boolean;
+ hasPublishableInputs: boolean;
+ hasUnpublishableInputs: boolean;
+ }>) => {
+ const { t } = useTranslation();
+ const warnings = useMemo(() => {
+ const _warnings: string[] = [];
+ if (!hasPublishableInputs) {
+ _warnings.push(t('workflows.builder.warningWorkflowHasNoPublishableInputFields'));
+ }
+ if (hasUnpublishableInputs) {
+ _warnings.push(t('workflows.builder.warningWorkflowHasUnpublishableInputFields'));
+ }
+ return _warnings;
+ }, [hasPublishableInputs, hasUnpublishableInputs, t]);
+ const errors = useMemo(() => {
+ const _errors: string[] = [];
+ if (!isWorkflowSaved) {
+ _errors.push(t('workflows.builder.errorWorkflowHasUnsavedChanges'));
+ }
+ if (hasBatchOrGeneratorNodes) {
+ _errors.push(t('workflows.builder.errorWorkflowHasBatchOrGeneratorNodes'));
+ }
+ if (!isReadyToEnqueue) {
+ _errors.push(t('workflows.builder.errorWorkflowHasInvalidGraph'));
+ }
+ if (!hasOutputNode) {
+ _errors.push(t('workflows.builder.errorWorkflowHasNoOutputNode'));
+ }
+ return _errors;
+ }, [hasBatchOrGeneratorNodes, hasOutputNode, isReadyToEnqueue, isWorkflowSaved, t]);
+
+ if (errors.length === 0 && warnings.length === 0) {
+ return children;
+ }
+
+ return (
+
+ {errors.length > 0 && (
+ <>
+
+ {t('workflows.builder.cannotPublish')}:
+
+
+ {errors.map((problem, index) => (
+ {problem}
+ ))}
+
+ >
+ )}
+ {warnings.length > 0 && (
+ <>
+
+ {t('workflows.builder.publishWarnings')}:
+
+
+ {warnings.map((problem, index) => (
+ {problem}
+ ))}
+
+ >
+ )}
+
+ }
+ >
+ {children}
+
+ );
+ }
+);
+PublishTooltip.displayName = 'PublishTooltip';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx
new file mode 100644
index 0000000000..4b08b72bf7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx
@@ -0,0 +1,23 @@
+import { IconButton, Tooltip } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiLockBold } from 'react-icons/pi';
+
+export const LockedWorkflowIcon = memo(() => {
+ const { t } = useTranslation();
+
+ return (
+
+ }
+ />
+
+ );
+});
+
+LockedWorkflowIcon.displayName = 'LockedWorkflowIcon';
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 89763cb310..8c52d92959 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
@@ -26,6 +26,7 @@ import {
workflowLibraryTagToggled,
workflowLibraryViewChanged,
} from 'features/nodes/store/workflowLibrarySlice';
+import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
@@ -39,13 +40,12 @@ export const WorkflowLibrarySideNav = () => {
const { t } = useTranslation();
const categoryOptions = useStore($workflowLibraryCategoriesOptions);
const view = useAppSelector(selectWorkflowLibraryView);
+ const publishWorkflow = useFeatureStatus('publishWorkflow');
return (
-
+
{t('workflows.recentlyOpened')}
-
-
{t('workflows.yourWorkflows')}
{categoryOptions.includes('project') && (
@@ -60,6 +60,9 @@ export const WorkflowLibrarySideNav = () => {
)}
+ {publishWorkflow && (
+ {t('workflows.publishedWorkflows')}
+ )}
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 0eb60f490f..61802b37fb 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
@@ -36,6 +36,8 @@ const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => {
return ['user'];
case 'shared':
return ['project'];
+ case 'published':
+ return ['user', 'project', 'default'];
default:
assert>(false);
}
@@ -66,6 +68,7 @@ const useInfiniteQueryAry = () => {
query: debouncedSearchTerm,
tags: view === 'defaults' ? selectedTags : [],
has_been_opened: getHasBeenOpened(view),
+ is_published: view === 'published' ? true : undefined,
} satisfies Parameters[0];
}, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);
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 005b26f409..f1f27572a9 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,6 +1,7 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { LockedWorkflowIcon } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon';
import { ShareWorkflowButton } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow';
import { selectWorkflowId, workflowModeChanged } from 'features/nodes/store/workflowSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
@@ -54,7 +55,6 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
position="relative"
role="button"
onClick={handleClickLoad}
- cursor="pointer"
bg="base.750"
borderRadius="base"
w="full"
@@ -81,7 +81,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{workflow.name}
- {isActive && (
+ {isActive && !workflow.is_published && (
)}
+ {workflow.is_published && (
+
+ {t('workflows.builder.published')}
+
+ )}
{workflow.category === 'project' && }
{workflow.category === 'default' && (
)}
- {workflow.category === 'default' && }
- {workflow.category !== 'default' && (
+ {workflow.category === 'default' && !workflow.is_published && (
+
+ )}
+ {workflow.category !== 'default' && !workflow.is_published && (
<>
@@ -128,6 +142,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
>
)}
{workflow.category === 'project' && }
+ {workflow.is_published && }
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx
index 5541cacd72..85635d6ff5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx
@@ -1,5 +1,7 @@
-import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
+import { Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { WorkflowBuilder } from 'features/nodes/components/sidePanel/builder/WorkflowBuilder';
+import { StartPublishFlowButton } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent';
+import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,12 +10,15 @@ import WorkflowJSONTab from './WorkflowJSONTab';
const WorkflowFieldsLinearViewPanel = () => {
const { t } = useTranslation();
+ const publishWorkflowIsEnabled = useFeatureStatus('publishWorkflow');
return (
{t('workflows.builder.builder')}
{t('common.details')}
JSON
+
+ {publishWorkflowIsEnabled && }
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts
new file mode 100644
index 0000000000..0f5b53ba5b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts
@@ -0,0 +1,90 @@
+import { useStore } from '@nanostores/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useAppSelector } from 'app/store/storeHooks';
+import { $templates } from 'features/nodes/store/nodesSlice';
+import { selectNodesSlice } from 'features/nodes/store/selectors';
+import type { Templates } from 'features/nodes/store/types';
+import { selectWorkflowFormNodeFieldFieldIdentifiersDeduped } from 'features/nodes/store/workflowSlice';
+import type { FieldIdentifier } from 'features/nodes/types/field';
+import { isBoardFieldType } from 'features/nodes/types/field';
+import { isInvocationNode } from 'features/nodes/types/invocation';
+import { atom, computed } from 'nanostores';
+import { useMemo } from 'react';
+import { useGetBatchStatusQuery } from 'services/api/endpoints/queue';
+import { assert } from 'tsafe';
+
+export const $isInPublishFlow = atom(false);
+export const $outputNodeId = atom(null);
+export const $isSelectingOutputNode = atom(false);
+export const $isReadyToDoValidationRun = computed(
+ [$isInPublishFlow, $outputNodeId, $isSelectingOutputNode],
+ (isInPublishFlow, outputNodeId, isSelectingOutputNode) => {
+ return isInPublishFlow && outputNodeId !== null && !isSelectingOutputNode;
+ }
+);
+export const $validationRunBatchId = atom(null);
+
+export const useIsValidationRunInProgress = () => {
+ const validationRunBatchId = useStore($validationRunBatchId);
+ const { isValidationRunInProgress } = useGetBatchStatusQuery(
+ validationRunBatchId ? { batch_id: validationRunBatchId } : skipToken,
+ {
+ selectFromResult: ({ currentData }) => {
+ if (!currentData) {
+ return { isValidationRunInProgress: false };
+ }
+ if (currentData && currentData.in_progress > 0) {
+ return { isValidationRunInProgress: true };
+ }
+ return { isValidationRunInProgress: false };
+ },
+ }
+ );
+ return validationRunBatchId !== null || isValidationRunInProgress;
+};
+
+export const selectFieldIdentifiersWithInvocationTypes = createSelector(
+ selectWorkflowFormNodeFieldFieldIdentifiersDeduped,
+ selectNodesSlice,
+ (fieldIdentifiers, nodes) => {
+ const result: { nodeId: string; fieldName: string; type: string }[] = [];
+ for (const fieldIdentifier of fieldIdentifiers) {
+ const node = nodes.nodes.find((node) => node.id === fieldIdentifier.nodeId);
+ assert(isInvocationNode(node), `Node ${fieldIdentifier.nodeId} not found`);
+ result.push({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, type: node.data.type });
+ }
+
+ return result;
+ }
+);
+
+export const getPublishInputs = (fieldIdentifiers: (FieldIdentifier & { type: string })[], templates: Templates) => {
+ // Certain field types are not allowed to be input fields on a published workflow
+ const publishable: FieldIdentifier[] = [];
+ const unpublishable: FieldIdentifier[] = [];
+ for (const fieldIdentifier of fieldIdentifiers) {
+ const fieldTemplate = templates[fieldIdentifier.type]?.inputs[fieldIdentifier.fieldName];
+ if (!fieldTemplate) {
+ unpublishable.push(fieldIdentifier);
+ continue;
+ }
+ if (isBoardFieldType(fieldTemplate.type)) {
+ unpublishable.push(fieldIdentifier);
+ continue;
+ }
+ publishable.push(fieldIdentifier);
+ }
+ return { publishable, unpublishable };
+};
+
+export const usePublishInputs = () => {
+ const templates = useStore($templates);
+ const fieldIdentifiersWithInvocationTypes = useAppSelector(selectFieldIdentifiersWithInvocationTypes);
+ const fieldIdentifiers = useMemo(
+ () => getPublishInputs(fieldIdentifiersWithInvocationTypes, templates),
+ [fieldIdentifiersWithInvocationTypes, templates]
+ );
+
+ return fieldIdentifiers;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts
index 68aa97e13e..1c9c929b8c 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts
@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
+import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts
index bab0bbb66a..f665d37e6d 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts
@@ -1,10 +1,11 @@
import { useNodeData } from 'features/nodes/hooks/useNodeData';
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { useMemo } from 'react';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
const isConnectionInputField = (field: FieldInputTemplate) => {
return (
(field.input === 'connection' && !isSingleOrCollection(field.type)) || !(field.type.name in TEMPLATE_BUILDER_MAP)
@@ -19,7 +20,7 @@ const isAnyOrDirectInputField = (field: FieldInputTemplate) => {
};
export const useInputFieldNamesMissing = (nodeId: string) => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const node = useNodeData(nodeId);
const fieldNames = useMemo(() => {
const instanceFields = new Set(Object.keys(node.inputs));
@@ -30,7 +31,7 @@ export const useInputFieldNamesMissing = (nodeId: string) => {
};
export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => {
const anyOrDirectFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {
@@ -44,7 +45,7 @@ export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
};
export const useInputFieldNamesConnection = (nodeId: string) => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => {
const connectionFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateOrThrow.ts
similarity index 54%
rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplate.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateOrThrow.ts
index e5c2367bed..2683921c6f 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplate.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateOrThrow.ts
@@ -1,8 +1,9 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
/**
* Returns the template for a specific input field of a node.
*
@@ -13,7 +14,7 @@ import { assert } from 'tsafe';
* @throws Will throw an error if the template for the input field is not found.
*/
export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string): FieldInputTemplate => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.inputs[fieldName];
assert(_fieldTemplate, `Template for input field ${fieldName} not found.`);
@@ -21,17 +22,3 @@ export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string):
}, [fieldName, template.inputs]);
return fieldTemplate;
};
-
-/**
- * Returns the template for a specific input field of a node.
- *
- * **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found.
- *
- * @param nodeId - The ID of the node.
- * @param fieldName - The name of the input field.
- */
-export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
- const template = useNodeTemplate(nodeId);
- const fieldTemplate = useMemo(() => template.inputs[fieldName] ?? null, [fieldName, template.inputs]);
- return fieldTemplate;
-};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts
new file mode 100644
index 0000000000..0dd761919c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts
@@ -0,0 +1,17 @@
+import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
+import type { FieldInputTemplate } from 'features/nodes/types/field';
+import { useMemo } from 'react';
+
+/**
+ * Returns the template for a specific input field of a node.
+ *
+ * **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found.
+ *
+ * @param nodeId - The ID of the node.
+ * @param fieldName - The name of the input field.
+ */
+export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
+ const template = useNodeTemplateSafe(nodeId);
+ const fieldTemplate = useMemo(() => template?.inputs[fieldName] ?? null, [fieldName, template?.inputs]);
+ return fieldTemplate;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitle.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitleOrThrow.ts
similarity index 59%
rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitle.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitleOrThrow.ts
index 319c219faf..2c1aa1ca1c 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitle.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitleOrThrow.ts
@@ -1,9 +1,10 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useMemo } from 'react';
import { assert } from 'tsafe';
-export const useInputFieldTemplateTitle = (nodeId: string, fieldName: string): string => {
- const template = useNodeTemplate(nodeId);
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
+export const useInputFieldTemplateTitleOrThrow = (nodeId: string, fieldName: string): string => {
+ const template = useNodeTemplateOrThrow(nodeId);
const title = useMemo(() => {
const fieldTemplate = template.inputs[fieldName];
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDescriptionSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserDescriptionSafe.ts
similarity index 89%
rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDescriptionSafe.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserDescriptionSafe.ts
index 0aef7ddd7e..18ebcf0869 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDescriptionSafe.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserDescriptionSafe.ts
@@ -11,7 +11,7 @@ import { useMemo } from 'react';
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
-export const useInputFieldDescriptionSafe = (nodeId: string, fieldName: string) => {
+export const useInputFieldUserDescriptionSafe = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts
new file mode 100644
index 0000000000..f4db341a60
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts
@@ -0,0 +1,23 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
+import { useMemo } from 'react';
+
+/**
+ * Gets the user-defined title of an input field for a given node.
+ *
+ * If the node doesn't exist or is not an invocation node, an error is thrown.
+ *
+ * @param nodeId The ID of the node
+ * @param fieldName The name of the field
+ */
+export const useInputFieldUserTitleOrThrow = (nodeId: string, fieldName: string): string => {
+ const selector = useMemo(
+ () => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).label),
+ [fieldName, nodeId]
+ );
+
+ const title = useAppSelector(selector);
+
+ return title;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldLabelSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleSafe.ts
similarity index 74%
rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldLabelSafe.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleSafe.ts
index 8ee6d8716b..177357ff74 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldLabelSafe.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleSafe.ts
@@ -4,21 +4,21 @@ import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/s
import { useMemo } from 'react';
/**
- * Gets the user-defined label of an input field for a given node.
+ * Gets the user-defined title of an input field for a given node.
*
* If the node doesn't exist or is not an invocation node, an empty string is returned.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
-export const useInputFieldLabelSafe = (nodeId: string, fieldName: string): string => {
+export const useInputFieldUserTitleSafe = (nodeId: string, fieldName: string): string => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => selectFieldInputInstanceSafe(nodes, nodeId, fieldName)?.label ?? ''),
[fieldName, nodeId]
);
- const label = useAppSelector(selector);
+ const title = useAppSelector(selector);
- return label;
+ return title;
};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts b/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts
index 6c4d29f58a..2daaf4a11d 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts
@@ -1,9 +1,10 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { isBatchNodeType, isGeneratorNodeType } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
export const useIsExecutableNode = (nodeId: string) => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const isExecutableNode = useMemo(
() => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type),
[template]
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts b/invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts
new file mode 100644
index 0000000000..fae669adb7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts
@@ -0,0 +1,13 @@
+import { useStore } from '@nanostores/react';
+import { useAppSelector } from 'app/store/storeHooks';
+import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish';
+import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
+
+export const useIsWorkflowEditorLocked = () => {
+ const isInPublishFlow = useStore($isInPublishFlow);
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
+ const isValidationRunInProgress = useIsValidationRunInProgress();
+
+ const isLocked = isInPublishFlow || isPublished || isValidationRunInProgress;
+ return isLocked;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts
index 75431c949f..770d04462b 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts
@@ -1,9 +1,10 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { Classification } from 'features/nodes/types/common';
import { useMemo } from 'react';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
export const useNodeClassification = (nodeId: string): Classification => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const classification = useMemo(() => template.classification, [template]);
return classification;
};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts
index 5855f5542b..9407fb00ff 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts
@@ -1,9 +1,10 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { some } from 'lodash-es';
import { useMemo } from 'react';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
export const useNodeHasImageOutput = (nodeId: string): boolean => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const hasImageOutput = useMemo(
() =>
some(
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts
index 71c2138276..ae42f8fbd3 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts
@@ -1,12 +1,13 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { useMemo } from 'react';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
export const useNodeNeedsUpdate = (nodeId: string) => {
const type = useNodeType(nodeId);
const version = useNodeVersion(nodeId);
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const needsUpdate = useMemo(() => {
if (type !== template.type) {
return true;
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateOrThrow.ts
similarity index 63%
rename from invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplate.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateOrThrow.ts
index 0b15324b2c..dfad641b4a 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplate.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateOrThrow.ts
@@ -5,7 +5,7 @@ import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { assert } from 'tsafe';
-export const useNodeTemplate = (nodeId: string): InvocationTemplate => {
+export const useNodeTemplateOrThrow = (nodeId: string): InvocationTemplate => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => {
@@ -15,10 +15,3 @@ export const useNodeTemplate = (nodeId: string): InvocationTemplate => {
}, [templates, type]);
return template;
};
-
-export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
- const templates = useStore($templates);
- const type = useNodeType(nodeId);
- const template = useMemo(() => templates[type] ?? null, [templates, type]);
- return template;
-};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts
new file mode 100644
index 0000000000..bbe8e9d775
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts
@@ -0,0 +1,12 @@
+import { useStore } from '@nanostores/react';
+import { useNodeType } from 'features/nodes/hooks/useNodeType';
+import { $templates } from 'features/nodes/store/nodesSlice';
+import type { InvocationTemplate } from 'features/nodes/types/invocation';
+import { useMemo } from 'react';
+
+export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
+ const templates = useStore($templates);
+ const type = useNodeType(nodeId);
+ const template = useMemo(() => templates[type] ?? null, [templates, type]);
+ return template;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts
new file mode 100644
index 0000000000..a8b737e141
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts
@@ -0,0 +1,25 @@
+import { useStore } from '@nanostores/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector } from 'app/store/storeHooks';
+import { $templates } from 'features/nodes/store/nodesSlice';
+import { selectNodesSlice } from 'features/nodes/store/selectors';
+import { isInvocationNode } from 'features/nodes/types/invocation';
+import { useMemo } from 'react';
+import { assert } from 'tsafe';
+
+export const useNodeTemplateTitleOrThrow = (nodeId: string): string => {
+ const templates = useStore($templates);
+ const selector = useMemo(
+ () =>
+ createSelector(selectNodesSlice, (nodesSlice) => {
+ const node = nodesSlice.nodes.find((node) => node.id === nodeId);
+ assert(isInvocationNode(node), 'Node not found');
+ const template = templates[node.data.type];
+ assert(template, 'Template not found');
+ return template.title;
+ }),
+ [nodeId, templates]
+ );
+ const title = useAppSelector(selector);
+ return title;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitle.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleSafe.ts
similarity index 91%
rename from invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitle.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleSafe.ts
index 9855424ee3..2503d79a94 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitle.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleSafe.ts
@@ -6,7 +6,7 @@ import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
-export const useNodeTemplateTitle = (nodeId: string): string | null => {
+export const useNodeTemplateTitleSafe = (nodeId: string): string | null => {
const templates = useStore($templates);
const selector = useMemo(
() =>
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts
new file mode 100644
index 0000000000..ed5688fbe9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts
@@ -0,0 +1,21 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectNodesSlice } from 'features/nodes/store/selectors';
+import { isInvocationNode } from 'features/nodes/types/invocation';
+import { useMemo } from 'react';
+import { assert } from 'tsafe';
+
+export const useNodeUserTitleOrThrow = (nodeId: string) => {
+ const selector = useMemo(
+ () =>
+ createSelector(selectNodesSlice, (nodesSlice) => {
+ const node = nodesSlice.nodes.find((node) => node.id === nodeId);
+ assert(isInvocationNode(node), 'Node not found');
+ return node.data.label;
+ }),
+ [nodeId]
+ );
+
+ const title = useAppSelector(selector);
+ return title;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeLabel.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleSafe.ts
similarity index 72%
rename from invokeai/frontend/web/src/features/nodes/hooks/useNodeLabel.ts
rename to invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleSafe.ts
index cd589d6240..60679128b7 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeLabel.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleSafe.ts
@@ -3,16 +3,16 @@ import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
-export const useNodeLabel = (nodeId: string) => {
+export const useNodeUserTitleSafe = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
- return node?.data.label;
+ return node?.data.label ?? null;
}),
[nodeId]
);
- const label = useAppSelector(selector);
- return label;
+ const title = useAppSelector(selector);
+ return title;
};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts
index b19d20ab80..8a38377d01 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts
@@ -1,10 +1,11 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames';
import { map } from 'lodash-es';
import { useMemo } from 'react';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
export const useOutputFieldNames = (nodeId: string): string[] => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => getSortedFilteredFieldNames(map(template.outputs)), [template.outputs]);
return fieldNames;
};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts
index d30b4a8bb5..8554fdf12d 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts
@@ -1,10 +1,11 @@
-import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldOutputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
+import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
+
export const useOutputFieldTemplate = (nodeId: string, fieldName: string): FieldOutputTemplate => {
- const template = useNodeTemplate(nodeId);
+ const template = useNodeTemplateOrThrow(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.outputs[fieldName];
assert(_fieldTemplate, `Template for output field ${fieldName} not found`);
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts b/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts
index f306bfbe02..a57f380559 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts
@@ -4,14 +4,14 @@ import { useCallback } from 'react';
const log = logger('workflows');
-export const useZoomToNode = () => {
- const zoomToNode = useCallback((nodeId: string) => {
+export const useZoomToNode = (nodeId: string) => {
+ const zoomToNode = useCallback(() => {
const flow = $flow.get();
if (!flow) {
log.warn('No flow instance found, cannot zoom to node');
return;
}
flow.fitView({ duration: 300, maxZoom: 1.5, nodes: [{ id: nodeId }] });
- }, []);
+ }, [nodeId]);
return zoomToNode;
};
diff --git a/invokeai/frontend/web/src/features/nodes/store/selectors.ts b/invokeai/frontend/web/src/features/nodes/store/selectors.ts
index 99decb90ff..7994b12b34 100644
--- a/invokeai/frontend/web/src/features/nodes/store/selectors.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/selectors.ts
@@ -4,7 +4,7 @@ import type { RootState } from 'app/store/store';
import type { NodesState } from 'features/nodes/store/types';
import type { FieldInputInstance } from 'features/nodes/types/field';
import type { AnyNode, InvocationNode, InvocationNodeData } from 'features/nodes/types/invocation';
-import { isInvocationNode } from 'features/nodes/types/invocation';
+import { isBatchNode, isGeneratorNode, isInvocationNode } from 'features/nodes/types/invocation';
import { assert } from 'tsafe';
export const selectNode = (nodesSlice: NodesState, nodeId: string): AnyNode => {
@@ -81,3 +81,7 @@ export const selectMayRedo = createSelector(
(state: RootState) => state.nodes,
(nodes) => nodes.future.length > 0
);
+
+export const selectHasBatchOrGeneratorNodes = createSelector(selectNodes, (nodes) =>
+ nodes.filter(isInvocationNode).some((node) => isBatchNode(node) || isGeneratorNode(node))
+);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index 907b905a73..e2cf6f7e26 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -5,7 +5,7 @@ import type { WorkflowCategory } from 'features/nodes/types/workflow';
import { atom, computed } from 'nanostores';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
-export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults';
+export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults' | 'published';
type WorkflowLibraryState = {
view: WorkflowLibraryView;
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
index fafb121cd4..c9b1d33915 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -33,7 +33,7 @@ import {
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
-import { isEqual } from 'lodash-es';
+import { isEqual, uniqBy } from 'lodash-es';
import { useMemo } from 'react';
import { selectNodesSlice } from './selectors';
@@ -67,8 +67,11 @@ const getBlankWorkflow = (): Omit => {
notes: '',
exposedFields: [],
meta: { version: '3.0.0', category: 'user' },
- id: undefined,
form: getDefaultForm(),
+ // Even though these values are `undefined`, the keys _must_ be present for the presistence layer to rehydrate
+ // them correctly. It uses a merge strategy that relies on the keys being present.
+ id: undefined,
+ is_published: undefined,
};
};
@@ -123,6 +126,9 @@ export const workflowSlice = createSlice({
workflowIDChanged: (state, action: PayloadAction) => {
state.id = action.payload;
},
+ workflowIsPublishedChanged(state, action: PayloadAction) {
+ state.is_published = action.payload;
+ },
workflowSaved: (state) => {
state.isTouched = false;
},
@@ -285,6 +291,7 @@ export const {
workflowVersionChanged,
workflowContactChanged,
workflowIDChanged,
+ workflowIsPublishedChanged,
workflowSaved,
formReset,
formElementAdded,
@@ -350,6 +357,10 @@ export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.
export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched);
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form);
+export const selectWorkflowIsPublished = createWorkflowSelector((workflow) => workflow.is_published);
+export const selectIsWorkflowSaved = createSelector(selectWorkflowId, selectWorkflowIsTouched, (id, isTouched) => {
+ return id !== undefined && !isTouched;
+});
export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => {
const noNodes = !nodes.nodes.length;
@@ -375,6 +386,14 @@ export const selectFormInitialValues = createWorkflowSelector((workflow) => work
export const selectNodeFieldElements = createWorkflowSelector((workflow) =>
Object.values(workflow.form.elements).filter(isNodeFieldElement)
);
+export const selectWorkflowFormNodeFieldFieldIdentifiersDeduped = createSelector(
+ selectNodeFieldElements,
+ (nodeFieldElements) =>
+ uniqBy(nodeFieldElements, (el) => `${el.data.fieldIdentifier.nodeId}-${el.data.fieldIdentifier.fieldName}`).map(
+ (el) => el.data.fieldIdentifier
+ )
+);
+
const buildSelectElement = (id: string) => createWorkflowSelector((workflow) => workflow.form?.elements[id]);
export const useElement = (id: string): FormElement | undefined => {
const selector = useMemo(() => buildSelectElement(id), [id]);
diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts
index 90fec2d464..41914dc279 100644
--- a/invokeai/frontend/web/src/features/nodes/types/field.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/field.ts
@@ -153,6 +153,9 @@ const zBoardFieldType = zFieldTypeBase.extend({
name: z.literal('BoardField'),
originalType: zStatelessFieldType.optional(),
});
+export const isBoardFieldType = (fieldType: FieldType): fieldType is z.infer =>
+ fieldType.name === zBoardFieldType.shape.name.value;
+
const zColorFieldType = zFieldTypeBase.extend({
name: z.literal('ColorField'),
originalType: zStatelessFieldType.optional(),
diff --git a/invokeai/frontend/web/src/features/nodes/types/invocation.ts b/invokeai/frontend/web/src/features/nodes/types/invocation.ts
index 9a70812c29..f3d864aa63 100644
--- a/invokeai/frontend/web/src/features/nodes/types/invocation.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/invocation.ts
@@ -100,7 +100,7 @@ export const isGeneratorNodeType = (type: string) =>
export const isBatchNode = (node: InvocationNode) => isBatchNodeType(node.data.type);
-const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type);
+export const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type);
export const isExecutableNode = (node: InvocationNode) => {
return !isBatchNode(node) && !isGeneratorNode(node);
diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts
index 071417dc2e..c6804a56c0 100644
--- a/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts
@@ -3,6 +3,7 @@ import type { WorkflowCategory, WorkflowV3, XYPosition } from 'features/nodes/ty
import type { S } from 'services/api/types';
import type { Equals, Extends } from 'tsafe';
import { assert } from 'tsafe';
+import type { SetRequired } from 'type-fest';
import { describe, test } from 'vitest';
/**
@@ -13,6 +14,5 @@ import { describe, test } from 'vitest';
describe('Workflow types', () => {
test('XYPosition', () => assert>());
test('WorkflowCategory', () => assert>());
- // @ts-expect-error TODO(psyche): Need to revise server types!
- test('WorkflowV3', () => assert>());
+ test('WorkflowV3', () => assert, S['Workflow']>>());
});
diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts
index 3a852d6deb..77576d882e 100644
--- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts
@@ -377,6 +377,7 @@ export const zWorkflowV3 = z.object({
}),
// Use the validated form schema!
form: zValidatedBuilderForm,
+ is_published: z.boolean().nullish(),
});
export type WorkflowV3 = z.infer;
// #endregion
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts
index 665efeeefd..94b0a1e5a7 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts
@@ -3,7 +3,7 @@ import { generateSeeds } from 'common/util/generateSeeds';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
import { range } from 'lodash-es';
import type { components } from 'services/api/schema';
-import type { Batch, BatchConfig, Invocation } from 'services/api/types';
+import type { Batch, EnqueueBatchArg, Invocation } from 'services/api/types';
export const prepareLinearUIBatch = (
state: RootState,
@@ -13,7 +13,7 @@ export const prepareLinearUIBatch = (
posCond: Invocation<'compel' | 'sdxl_compel_prompt' | 'flux_text_encoder' | 'sd3_text_encoder'>,
origin: 'canvas' | 'workflows' | 'upscaling',
destination: 'canvas' | 'gallery'
-): BatchConfig => {
+): EnqueueBatchArg => {
const { iterations, model, shouldRandomizeSeed, seed, shouldConcatPrompts } = state.params;
const { prompts, seedBehaviour } = state.dynamicPrompts;
@@ -99,7 +99,7 @@ export const prepareLinearUIBatch = (
data.push(firstBatchDatumList);
- const enqueueBatchArg: BatchConfig = {
+ const enqueueBatchArg: EnqueueBatchArg = {
prepend,
batch: {
graph: g.getGraph(),
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
index 0f86341ea3..6926b5ed82 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
@@ -37,7 +37,7 @@ const getBoardField = (field: BoardFieldInputInstance, state: RootState): BoardF
/**
* Builds a graph from the node editor state.
*/
-export const buildNodesGraph = (state: RootState, templates: Templates): Graph => {
+export const buildNodesGraph = (state: RootState, templates: Templates): Required => {
const { nodes, edges } = selectNodesSlice(state);
// Exclude all batch nodes - we will handle these in the batch setup in a diff function
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts
index 47dc48e748..dc584ee4f5 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts
@@ -78,6 +78,8 @@ const migrateV2toV3 = (workflowToMigrate: WorkflowV2): WorkflowV3 => {
/**
* Parses a workflow and migrates it to the latest version if necessary.
+ *
+ * This function will return a new workflow object, so the original workflow is not modified.
*/
export const parseAndMigrateWorkflow = (data: unknown): WorkflowV3 => {
const workflowVersionResult = zWorkflowMetaVersion.safeParse(data);
diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx
index d5f608e97e..d5eb9e3032 100644
--- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx
@@ -6,8 +6,10 @@ import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsS
import { selectIterations } from 'features/controlLayers/store/paramsSlice';
import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
+import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
+import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
import type { BatchSizeResult } from 'features/nodes/util/node/resolveBatchValue';
import { getBatchSize } from 'features/nodes/util/node/resolveBatchValue';
import type { Reason } from 'features/queue/store/readiness';
@@ -175,6 +177,8 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
const { t } = useTranslation();
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
const [_, enqueueMutation] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions);
+ const isInPublishFlow = useStore($isInPublishFlow);
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
const text = useMemo(() => {
if (enqueueMutation.isLoading) {
@@ -183,6 +187,12 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
if (isLoadingDynamicPrompts) {
return t('dynamicPrompts.loading');
}
+ if (isInPublishFlow) {
+ return t('workflows.builder.publishInProgress');
+ }
+ if (isPublished) {
+ return t('workflows.builder.publishedWorkflowIsLocked');
+ }
if (isReady) {
if (prepend) {
return t('queue.queueFront');
@@ -190,7 +200,7 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
return t('queue.queueBack');
}
return t('queue.notReady');
- }, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isReady, prepend, t]);
+ }, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isInPublishFlow, isPublished, isReady, t, prepend]);
return {text};
});
diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx
index 33863d6634..b175e4d8b0 100644
--- a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx
@@ -20,7 +20,7 @@ export const InvokeButton = memo(() => {
)}
+
+ {!isValidationRun && {t('workflows.builder.publishingValidationRun')}}
+
{(!isFailed || !isRetryEnabled) && (
diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts
index 1f66622857..13bbdd0c98 100644
--- a/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts
+++ b/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts
@@ -7,5 +7,6 @@ export const COLUMN_WIDTHS = {
destination: '6rem',
batchId: '5rem',
fieldValues: 'auto',
+ validationRun: 'auto',
actions: 'auto',
} as const;
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueWorkflows.ts
similarity index 57%
rename from invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts
rename to invokeai/frontend/web/src/features/queue/hooks/useEnqueueWorkflows.ts
index 838019c428..abf89dc12a 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts
+++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueWorkflows.ts
@@ -1,25 +1,29 @@
-import { logger } from 'app/logging/logger';
-import { enqueueRequested } from 'app/store/actions';
-import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
-import { parseify } from 'common/util/serialize';
+import { createAction } from '@reduxjs/toolkit';
+import { useAppStore } from 'app/store/nanostores/store';
+import {
+ $outputNodeId,
+ getPublishInputs,
+ selectFieldIdentifiersWithInvocationTypes,
+} from 'features/nodes/components/sidePanel/workflow/publish';
import { $templates } from 'features/nodes/store/nodesSlice';
-import { selectNodesSlice } from 'features/nodes/store/selectors';
+import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
import { groupBy } from 'lodash-es';
-import { serializeError } from 'serialize-error';
+import { useCallback } from 'react';
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
-import type { Batch, BatchConfig } from 'services/api/types';
+import type { Batch, EnqueueBatchArg } from 'services/api/types';
+import { assert } from 'tsafe';
-const log = logger('generation');
+const enqueueRequestedWorkflows = createAction('app/enqueueRequestedWorkflows');
-export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) => {
- startAppListening({
- predicate: (action): action is ReturnType =>
- enqueueRequested.match(action) && action.payload.tabName === 'workflows',
- effect: async (action, { getState, dispatch }) => {
+export const useEnqueueWorkflows = () => {
+ const { getState, dispatch } = useAppStore();
+ const enqueue = useCallback(
+ async (prepend: boolean, isApiValidationRun: boolean) => {
+ dispatch(enqueueRequestedWorkflows());
const state = getState();
const nodesState = selectNodesSlice(state);
const workflow = state.workflow;
@@ -91,7 +95,7 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
}
}
- const batchConfig: BatchConfig = {
+ const batchConfig: EnqueueBatchArg = {
batch: {
graph,
workflow: builtWorkflow,
@@ -100,18 +104,53 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
destination: 'gallery',
data,
},
- prepend: action.payload.prepend,
+ prepend,
};
- const req = dispatch(queueApi.endpoints.enqueueBatch.initiate(batchConfig, enqueueMutationFixedCacheKeyOptions));
- try {
- await req.unwrap();
- log.debug(parseify({ batchConfig }), 'Enqueued batch');
- } catch (error) {
- log.error({ error: serializeError(error) }, 'Failed to enqueue batch');
- } finally {
- req.reset();
+ if (isApiValidationRun) {
+ // Derive the input fields from the builder's selected node field elements
+ const fieldIdentifiers = selectFieldIdentifiersWithInvocationTypes(state);
+ const inputs = getPublishInputs(fieldIdentifiers, templates);
+ const api_input_fields = inputs.publishable.map(({ nodeId, fieldName }) => {
+ return {
+ kind: 'input',
+ node_id: nodeId,
+ field_name: fieldName,
+ } as const;
+ });
+
+ // Derive the output fields from the builder's selected output node
+ const outputNodeId = $outputNodeId.get();
+ assert(outputNodeId !== null, 'Output node not selected');
+ const outputNodeType = selectNodeData(selectNodesSlice(state), outputNodeId).type;
+ const outputNodeTemplate = templates[outputNodeType];
+ assert(outputNodeTemplate, `Template for node type ${outputNodeType} not found`);
+ const outputFieldNames = Object.keys(outputNodeTemplate.outputs);
+ const api_output_fields = outputFieldNames.map((fieldName) => {
+ return {
+ kind: 'output',
+ node_id: outputNodeId,
+ field_name: fieldName,
+ } as const;
+ });
+
+ batchConfig.is_api_validation_run = true;
+ batchConfig.api_input_fields = api_input_fields;
+ batchConfig.api_output_fields = api_output_fields;
+
+ // If the batch is an API validation run, we only want to run it once
+ batchConfig.batch.runs = 1;
}
+
+ const req = dispatch(
+ queueApi.endpoints.enqueueBatch.initiate(batchConfig, { ...enqueueMutationFixedCacheKeyOptions, track: false })
+ );
+
+ const enqueueResult = await req.unwrap();
+ return { batchConfig, enqueueResult };
},
- });
+ [dispatch, getState]
+ );
+
+ return enqueue;
};
diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts
index 26c369fe1e..99e4419aca 100644
--- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts
+++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts
@@ -1,29 +1,66 @@
import { useStore } from '@nanostores/react';
-import { enqueueRequested } from 'app/store/actions';
+import { logger } from 'app/logging/logger';
+import { enqueueRequestedCanvas } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
+import { enqueueRequestedUpscaling } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { withResultAsync } from 'common/util/result';
+import { parseify } from 'common/util/serialize';
+import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
+import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
import { $isReadyToEnqueue } from 'features/queue/store/readiness';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react';
+import { serializeError } from 'serialize-error';
import { enqueueMutationFixedCacheKeyOptions, useEnqueueBatchMutation } from 'services/api/endpoints/queue';
+const log = logger('generation');
+
export const useInvoke = () => {
const dispatch = useAppDispatch();
const tabName = useAppSelector(selectActiveTab);
const isReady = useStore($isReadyToEnqueue);
+ const isLocked = useIsWorkflowEditorLocked();
+ const enqueueWorkflows = useEnqueueWorkflows();
const [_, { isLoading }] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions);
- const queueBack = useCallback(() => {
- if (!isReady) {
- return;
- }
- dispatch(enqueueRequested({ tabName, prepend: false }));
- }, [dispatch, isReady, tabName]);
- const queueFront = useCallback(() => {
- if (!isReady) {
- return;
- }
- dispatch(enqueueRequested({ tabName, prepend: true }));
- }, [dispatch, isReady, tabName]);
- return { queueBack, queueFront, isLoading, isDisabled: !isReady };
+ const enqueue = useCallback(
+ async (prepend: boolean, isApiValidationRun: boolean) => {
+ if (!isReady) {
+ return;
+ }
+
+ if (tabName === 'workflows') {
+ const result = await withResultAsync(() => enqueueWorkflows(prepend, isApiValidationRun));
+ if (result.isErr()) {
+ log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch');
+ } else {
+ log.debug(parseify(result.value), 'Enqueued batch');
+ }
+ }
+
+ if (tabName === 'upscaling') {
+ dispatch(enqueueRequestedUpscaling({ prepend }));
+ return;
+ }
+
+ if (tabName === 'canvas') {
+ dispatch(enqueueRequestedCanvas({ prepend }));
+ return;
+ }
+
+ // Else we are not on a generation tab and should not queue
+ },
+ [dispatch, enqueueWorkflows, isReady, tabName]
+ );
+
+ const enqueueBack = useCallback(() => {
+ enqueue(false, false);
+ }, [enqueue]);
+
+ const enqueueFront = useCallback(() => {
+ enqueue(true, false);
+ }, [enqueue]);
+
+ return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady || isLocked, enqueue };
};
diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts
index 64c402e11c..2d1f4284f6 100644
--- a/invokeai/frontend/web/src/features/queue/store/readiness.ts
+++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts
@@ -22,6 +22,7 @@ import {
import type { DynamicPromptsState } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
+import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState, Templates } from 'features/nodes/store/types';
@@ -84,7 +85,8 @@ const debouncedUpdateReasons = debounce(
templates: Templates,
upscale: UpscaleState,
config: AppConfig,
- store: AppStore
+ store: AppStore,
+ isInPublishFlow: boolean
) => {
if (tab === 'canvas') {
const model = selectMainModelConfig(store.getState());
@@ -108,6 +110,7 @@ const debouncedUpdateReasons = debounce(
workflowSettingsState: workflowSettings,
isConnected,
templates,
+ isInPublishFlow,
});
$reasonsWhyCannotEnqueue.set(reasons);
} else if (tab === 'upscaling') {
@@ -144,6 +147,7 @@ export const useReadinessWatcher = () => {
const canvasIsRasterizing = useStore(canvasManager?.stateApi.$isRasterizing ?? $true);
const canvasIsSelectingObject = useStore(canvasManager?.stateApi.$isSegmenting ?? $true);
const canvasIsCompositing = useStore(canvasManager?.compositor.$isBusy ?? $true);
+ const isInPublishFlow = useStore($isInPublishFlow);
useEffect(() => {
debouncedUpdateReasons(
@@ -162,7 +166,8 @@ export const useReadinessWatcher = () => {
templates,
upscale,
config,
- store
+ store,
+ isInPublishFlow
);
}, [
store,
@@ -181,6 +186,7 @@ export const useReadinessWatcher = () => {
templates,
upscale,
workflowSettings,
+ isInPublishFlow,
]);
};
@@ -192,15 +198,16 @@ const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: {
workflowSettingsState: WorkflowSettingsState;
isConnected: boolean;
templates: Templates;
+ isInPublishFlow: boolean;
}): Promise => {
- const { dispatch, nodesState, workflowSettingsState, isConnected, templates } = arg;
+ const { dispatch, nodesState, workflowSettingsState, isConnected, templates, isInPublishFlow } = arg;
const reasons: Reason[] = [];
if (!isConnected) {
reasons.push(disconnectedReason(i18n.t));
}
- if (workflowSettingsState.shouldValidateGraph) {
+ if (workflowSettingsState.shouldValidateGraph || isInPublishFlow) {
const { nodes, edges } = nodesState;
const invocationNodes = nodes.filter(isInvocationNode);
const batchNodes = invocationNodes.filter(isBatchNode);
diff --git a/invokeai/frontend/web/src/features/system/store/configSlice.ts b/invokeai/frontend/web/src/features/system/store/configSlice.ts
index e48226ea1c..94d1633f7b 100644
--- a/invokeai/frontend/web/src/features/system/store/configSlice.ts
+++ b/invokeai/frontend/web/src/features/system/store/configSlice.ts
@@ -22,7 +22,7 @@ const initialConfigState: AppConfig = {
allowPrivateStylePresets: false,
allowClientSideUpload: false,
disabledTabs: [],
- disabledFeatures: ['lightbox', 'faceRestore', 'batches'],
+ disabledFeatures: ['lightbox', 'faceRestore', 'batches', 'publishWorkflow'],
disabledSDFeatures: ['variation', 'symmetry', 'hires', 'perlinNoise', 'noiseThreshold'],
nodesAllowlist: undefined,
nodesDenylist: undefined,
diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx
index f0de83b2af..a731d40ad9 100644
--- a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx
@@ -82,7 +82,7 @@ const InvokeIconButton = memo(() => {
void;
onError?: () => void;
onCompleted?: () => void;
};
-type LoadLibraryWorkflowData = Callbacks & {
+type LoadLibraryWorkflowData = LoadWorkflowOptions & {
type: 'library';
data: string;
};
-type LoadWorkflowFromObjectData = Callbacks & {
+type LoadWorkflowFromObjectData = LoadWorkflowOptions & {
type: 'object';
data: unknown;
};
-type LoadWorkflowFromFileData = Callbacks & {
+type LoadWorkflowFromFileData = LoadWorkflowOptions & {
type: 'file';
data: File;
};
-type LoadWorkflowFromImageData = Callbacks & {
+type LoadWorkflowFromImageData = LoadWorkflowOptions & {
type: 'image';
data: string;
};
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
index 1bdee73d7d..f7d5ec40ec 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
@@ -115,6 +115,7 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
workflow.id = undefined;
workflow.name = name;
workflow.meta.category = shouldSaveToProject ? 'project' : 'user';
+ workflow.is_published = false;
// We've just made the workflow a draft, but TS doesn't know that. We need to assert it.
assert(isDraftWorkflow(workflow));
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 14ceb159cf..79d8c2004f 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
@@ -1,6 +1,6 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
-import { selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
+import { selectWorkflowIsPublished, selectWorkflowIsTouched } from 'features/nodes/store/workflowSlice';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,9 +10,15 @@ const SaveWorkflowMenuItem = () => {
const { t } = useTranslation();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
const isTouched = useAppSelector(selectWorkflowIsTouched);
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
return (
- } onClick={saveOrSaveAsWorkflow}>
+ }
+ onClick={saveOrSaveAsWorkflow}
+ >
{t('workflows.saveWorkflow')}
);
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
index 95281640fc..5c1e88cf58 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
@@ -5,6 +5,7 @@ import {
formFieldInitialValuesChanged,
workflowCategoryChanged,
workflowIDChanged,
+ workflowIsPublishedChanged,
workflowNameChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
@@ -65,6 +66,7 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
meta: { category },
} = data.workflow;
dispatch(workflowIDChanged(id));
+ dispatch(workflowIsPublishedChanged(false));
dispatch(workflowNameChanged(name));
dispatch(workflowCategoryChanged(category));
dispatch(newWorkflowSaved({ category }));
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts
index 21ec1bbb60..8c58d7ba6c 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow.ts
@@ -1,24 +1,29 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
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.
+ * Returns a function that saves the current workflow if it's a library workflow, or opens the save as dialog.
+ *
+ * Published workflows are always saved as a new workflow.
*/
export const useSaveOrSaveAsWorkflow = () => {
const buildWorkflow = useBuildWorkflowFast();
+ const isPublished = useAppSelector(selectWorkflowIsPublished);
const { saveWorkflow } = useSaveLibraryWorkflow();
const saveOrSaveAsWorkflow = useCallback(() => {
const workflow = buildWorkflow();
- if (isLibraryWorkflow(workflow)) {
+ if (isLibraryWorkflow(workflow) && !isPublished) {
saveWorkflow(workflow);
} else {
saveWorkflowAs(workflow);
}
- }, [buildWorkflow, saveWorkflow]);
+ }, [buildWorkflow, isPublished, saveWorkflow]);
return saveOrSaveAsWorkflow;
};
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index cf498377e3..52a8e70d92 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -20,7 +20,7 @@ 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 BatchConfig =
+export type EnqueueBatchArg =
paths['/api/v1/queue/{queue_id}/enqueue_batch']['post']['requestBody']['content']['application/json'];
export type InputFieldJSONSchemaExtra = S['InputFieldJSONSchemaExtra'];
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index 616f84613c..5e1e529a02 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -6,7 +6,13 @@ import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId';
import { $queueId } from 'app/store/nanostores/queueId';
import type { AppStore } from 'app/store/store';
import { deepClone } from 'common/util/deepClone';
+import {
+ $isInPublishFlow,
+ $outputNodeId,
+ $validationRunBatchId,
+} from 'features/nodes/components/sidePanel/workflow/publish';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
+import { workflowIsPublishedChanged } from 'features/nodes/store/workflowSlice';
import { zNodeStatus } from 'features/nodes/types/invocation';
import ErrorToastDescription, { getTitle } from 'features/toast/ErrorToastDescription';
import { toast } from 'features/toast/toast';
@@ -394,28 +400,39 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
clone.outputs = [];
$nodeExecutionStates.setKey(clone.nodeId, clone);
});
- } else if (status === 'failed' && error_type) {
- const isLocal = getState().config.isLocal ?? true;
- const sessionId = session_id;
-
- toast({
- id: `INVOCATION_ERROR_${error_type}`,
- title: getTitle(error_type),
- status: 'error',
- duration: null,
- updateDescription: isLocal,
- description: (
-
- ),
- });
} else if (status === 'completed' || status === 'failed' || status === 'canceled') {
+ if (status === 'failed' && error_type) {
+ const isLocal = getState().config.isLocal ?? true;
+ const sessionId = session_id;
+
+ toast({
+ id: `INVOCATION_ERROR_${error_type}`,
+ title: getTitle(error_type),
+ status: 'error',
+ duration: null,
+ updateDescription: isLocal,
+ description: (
+
+ ),
+ });
+ }
// If the queue item is completed, failed, or cancelled, we want to clear the last progress event
$lastProgressEvent.set(null);
+
+ // When a validation run is completed, we want to clear the validation run batch ID & set the workflow as published
+ if (batch_status.batch_id === $validationRunBatchId.get()) {
+ $validationRunBatchId.set(null);
+ if (status === 'completed') {
+ dispatch(workflowIsPublishedChanged(true));
+ $isInPublishFlow.set(false);
+ $outputNodeId.set(null);
+ }
+ }
}
});