From 9fee1dd56d9acef29f5564d39eac1b02320679e3 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Mon, 3 Aug 2026 22:32:26 +0200 Subject: [PATCH] feat(ui): add Use Cache and Save To Gallery to the form builder, make them connectable The two node footer toggles were the only node controls that could not be added to a workflow's form, because they are node attributes stored on `node.data` rather than input fields in `node.data.inputs`. Add a `node-setting` form element type for them, with an add/remove button and drag handle in the node footer mirroring how node fields are added. The label is editable, since two nodes' "Use Cache" entries would otherwise be indistinguishable in a form. Also expose the underlying fields as connection targets. `BaseInvocation` already declares `is_intermediate` and `use_cache` as pydantic fields, so they only needed `Input.Any` and, for `is_intermediate`, dropping the `_IsIntermediate` ui_type that prevented a BooleanField output from connecting. The frontend now parses them into invocation templates - required for handles and connection validation - but still filters them out of the node's input list and never creates field instances for them. Their value keeps living on the node, so `buildNodesGraph` is unchanged and no workflow migration is needed. Graph execution applies edge values in `GraphExecutionState.next()`, before the cache is consulted and before the output image is saved. Node attribute fields are only reachable on nodes that render a footer, so connections to them are rejected elsewhere - otherwise the edge would have no handle to attach to and, on batch and generator nodes, no effect at all. When an edge drives a setting, the node's checkbox is dropped and the form's toggle is disabled, since the local value is no longer what the node runs with. --- invokeai/app/invocations/baseinvocation.py | 10 +- invokeai/frontend/web/public/locales/en.json | 5 + .../WorkflowFormPreview.tsx | 6 + .../nodes/Invocation/InvocationNodeFooter.tsx | 47 ++++---- .../NodeSettingAddRemoveFormRoot.tsx | 47 ++++++++ .../Invocation/NodeSettingFooterControl.tsx | 78 +++++++++++++ .../Invocation/SaveToGalleryCheckbox.tsx | 40 ------- .../nodes/Invocation/UseCacheCheckbox.tsx | 42 ------- .../sidePanel/builder/ContainerElement.tsx | 6 + .../builder/FormElementEditModeHeader.tsx | 10 +- .../builder/FormElementNodeOverlay.tsx | 37 ++++++ .../builder/NodeFieldElementEditMode.tsx | 35 +----- .../sidePanel/builder/NodeSettingElement.tsx | 34 ++++++ .../builder/NodeSettingElementEditMode.tsx | 80 +++++++++++++ .../NodeSettingElementLabelEditable.tsx | 50 ++++++++ .../builder/NodeSettingElementViewMode.tsx | 52 +++++++++ .../sidePanel/builder/WorkflowBuilder.tsx | 3 + .../components/sidePanel/builder/dnd-hooks.ts | 110 +++++++++++++++++- .../builder/use-add-remove-form-element.ts | 40 ++++++- .../nodes/hooks/useInputFieldNamesByStatus.ts | 5 +- .../features/nodes/hooks/useIsBatchNode.ts | 13 --- .../nodes/hooks/useNodeHasGalleryOutput.ts | 27 ----- .../features/nodes/hooks/useNodeSetting.ts | 75 ++++++++++++ .../src/features/nodes/hooks/useWithFooter.ts | 16 ++- .../src/features/nodes/store/nodesSlice.ts | 14 ++- .../web/src/features/nodes/store/selectors.ts | 10 +- .../features/nodes/store/util/testUtils.ts | 102 ++++++++++++++-- .../store/util/validateConnection.test.ts | 69 +++++++++++ .../nodes/store/util/validateConnection.ts | 15 ++- .../src/features/nodes/types/invocation.ts | 27 ++++- .../nodes/types/nodeAttributeFields.ts | 16 +++ .../web/src/features/nodes/types/workflow.ts | 43 ++++++- .../nodes/util/node/buildInvocationNode.ts | 8 ++ .../util/node/getSortedFilteredFieldNames.ts | 5 +- .../features/nodes/util/schema/parseSchema.ts | 18 +-- .../util/workflow/validateWorkflow.test.ts | 46 +++++++- .../nodes/util/workflow/validateWorkflow.ts | 23 +++- tests/test_node_graph.py | 48 ++++++++ 38 files changed, 1080 insertions(+), 232 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingAddRemoveFormRoot.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingFooterControl.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementNodeOverlay.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElement.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementEditMode.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementLabelEditable.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementViewMode.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeSetting.ts create mode 100644 invokeai/frontend/web/src/features/nodes/types/nodeAttributeFields.ts diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py index 1c1a35d700e..6a2cd915beb 100644 --- a/invokeai/app/invocations/baseinvocation.py +++ b/invokeai/app/invocations/baseinvocation.py @@ -260,17 +260,23 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi description="The id of this instance of an invocation. Must be unique among all instances of invocations.", json_schema_extra={"field_kind": FieldKind.NodeAttribute}, ) + # `is_intermediate` and `use_cache` remain node attributes - the workflow editor stores them on the node itself + # and renders them in the node footer, not in the node's input list. They declare `Input.Any` so that a graph edge + # may drive them; edge values are applied in `GraphExecutionState.next()` before the invocation runs, so both are + # resolved by the time the cache is consulted and by the time the output image is saved. is_intermediate: bool = Field( default=False, description="Whether or not this is an intermediate invocation.", json_schema_extra=InputFieldJSONSchemaExtra( - input=Input.Direct, field_kind=FieldKind.NodeAttribute, ui_type=UIType._IsIntermediate + input=Input.Any, field_kind=FieldKind.NodeAttribute, orig_required=False ).model_dump(exclude_none=True), ) use_cache: bool = Field( default=True, description="Whether or not to use the cache", - json_schema_extra={"field_kind": FieldKind.NodeAttribute}, + json_schema_extra=InputFieldJSONSchemaExtra( + input=Input.Any, field_kind=FieldKind.NodeAttribute, orig_required=False + ).model_dump(exclude_none=True), ) bottleneck: ClassVar[Bottleneck] diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a4c7986b10c..6ab6f5ec746 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1559,6 +1559,7 @@ "groupNodesByCategoryHelp": "Group nodes by category in the add node dialog", "hideLegendNodes": "Hide Field Type Legend", "hideMinimapnodes": "Hide MiniMap", + "cannotConnectToUnavailableNodeSetting": "This setting is not available on this node", "inputMayOnlyHaveOneConnection": "Input may only have one connection", "integer": "Integer", "ipAdapter": "IP-Adapter", @@ -1583,6 +1584,7 @@ "targetNodeFieldDoesNotExist": "Invalid edge: target/input field {{node}}.{{field}} does not exist", "deletedInvalidEdge": "Deleted invalid edge {{source}} -> {{target}}", "deletedMissingNodeFieldFormElement": "Deleted missing form field: node {{nodeId}} field {{fieldName}}", + "deletedMissingNodeSettingFormElement": "Deleted missing form setting: node {{nodeId}} setting {{setting}}", "noConnectionInProgress": "No connection in progress", "node": "Node", "nodeOutputs": "Node Outputs", @@ -2778,8 +2780,11 @@ "text": "Text", "divider": "Divider", "nodeField": "Node Field", + "nodeSetting": "Node Setting", "zoomToNode": "Zoom to Node", "nodeFieldTooltip": "To add a node field, click the small plus sign button on the field in the Workflow Editor, or drag the field by its name into the form.", + "nodeSettingTooltip": "To add a node setting like Use Cache or Save To Gallery, click the small plus sign button next to it in the node's footer in the Workflow Editor, or drag it by its name into the form.", + "nodeSettingNotApplicable": "This setting does not apply to this node and will be hidden.", "addToForm": "Add to Form", "removeFromForm": "Remove from Form", "label": "Label", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx index be8ee0668e9..eec4801818c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasWorkflowIntegration/WorkflowFormPreview.tsx @@ -27,6 +27,7 @@ import { isDividerElement, isHeadingElement, isNodeFieldElement, + isNodeSettingElement, isTextElement, ROOT_CONTAINER_CLASS_NAME, } from 'features/nodes/types/workflow'; @@ -241,6 +242,11 @@ const FormElementComponentPreview = memo(({ id, elements }: { id: string; elemen return ; } + if (isNodeSettingElement(el)) { + // Node settings act on the workflow editor's node state, which this preview does not own - nothing to render. + return null; + } + // If we get here, it's an unknown element type // eslint-disable-next-line @typescript-eslint/no-explicit-any log.warn({ id, type: (el as any).type }, 'Unknown element type - not rendering'); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx index e025fe39fe2..ccb16bdc370 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx @@ -1,38 +1,37 @@ -import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Flex, FormControlGroup } from '@invoke-ai/ui-library'; -import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode'; -import { useNodeHasGalleryOutput } from 'features/nodes/hooks/useNodeHasGalleryOutput'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; import { memo } from 'react'; -import SaveToGalleryCheckbox from './SaveToGalleryCheckbox'; -import UseCacheCheckbox from './UseCacheCheckbox'; +import { NodeSettingFooterControl } from './NodeSettingFooterControl'; type Props = { nodeId: string; }; -const props: ChakraProps = { w: 'unset' }; +const sx: SystemStyleObject = { + w: 'full', + borderBottomRadius: 'base', + // One row per setting, so each connection handle lines up with the label it belongs to + flexDir: 'column', + px: 2, + py: 1, + // The add/remove form element buttons are hidden by default and shown on hover + '& .node-setting-action-button': { + display: 'none', + }, + _hover: { + '& .node-setting-action-button': { + display: 'inline-flex', + }, + }, +}; const InvocationNodeFooter = ({ nodeId }: Props) => { - const hasGalleryOutput = useNodeHasGalleryOutput(); - const isExecutableNode = useIsExecutableNode(); return ( - - - {isExecutableNode && } - {isExecutableNode && hasGalleryOutput && } - + + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingAddRemoveFormRoot.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingAddRemoveFormRoot.tsx new file mode 100644 index 00000000000..23bd3fe98ad --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingAddRemoveFormRoot.tsx @@ -0,0 +1,47 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAddRemoveNodeSettingFormElement } from 'features/nodes/components/sidePanel/builder/use-add-remove-form-element'; +import { NO_DRAG_CLASS } from 'features/nodes/types/constants'; +import type { NodeSettingName } from 'features/nodes/types/workflow'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiMinusBold, PiPlusBold } from 'react-icons/pi'; + +type Props = { + nodeId: string; + setting: NodeSettingName; +}; + +export const NodeSettingAddRemoveFormRoot = memo(({ nodeId, setting }: Props) => { + const { t } = useTranslation(); + const { isAddedToRoot, addNodeSettingToRoot, removeNodeSettingFromRoot } = useAddRemoveNodeSettingFormElement( + nodeId, + setting + ); + + const description = useMemo(() => { + return isAddedToRoot ? t('workflows.builder.removeFromForm') : t('workflows.builder.addToForm'); + }, [isAddedToRoot, t]); + + const icon = useMemo(() => { + return isAddedToRoot ? : ; + }, [isAddedToRoot]); + + const onClick = useCallback(() => { + return isAddedToRoot ? removeNodeSettingFromRoot() : addNodeSettingToRoot(); + }, [isAddedToRoot, addNodeSettingToRoot, removeNodeSettingFromRoot]); + + return ( + + ); +}); + +NodeSettingAddRemoveFormRoot.displayName = 'NodeSettingAddRemoveFormRoot'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingFooterControl.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingFooterControl.tsx new file mode 100644 index 00000000000..f378a45887d --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NodeSettingFooterControl.tsx @@ -0,0 +1,78 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Checkbox, Flex, FormControl, FormLabel, Spacer } from '@invoke-ai/ui-library'; +import { useIsAdmin } from 'features/auth/hooks/useIsAdmin'; +import { InputFieldHandle } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle'; +import { useNodeSettingDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks'; +import { useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplateSafe'; +import { + NODE_SETTING_FIELD_NAMES, + useNodeSetting, + useNodeSettingDefaultLabel, +} from 'features/nodes/hooks/useNodeSetting'; +import { NO_DRAG_CLASS, NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants'; +import type { NodeSettingName } from 'features/nodes/types/workflow'; +import { memo, useRef } from 'react'; + +import { NodeSettingAddRemoveFormRoot } from './NodeSettingAddRemoveFormRoot'; + +// Mirrors `InputFieldWrapper`: one row per field. The connection handle is absolutely positioned against this row and +// keeps its static vertical position, so it lines up with the label it belongs to. +const sx: SystemStyleObject = { + position: 'relative', + w: 'full', + minH: 6, + alignItems: 'center', + // Clear the half of the handle that overlaps the node's interior + ps: 2, + '&[data-is-dragging="true"]': { + opacity: 0.3, + }, +}; + +const formControlSx: SystemStyleObject = { w: 'full', alignItems: 'center', gap: 2 }; + +type Props = { + nodeId: string; + setting: NodeSettingName; +}; + +/** + * A node attribute toggle in the node footer. + * + * The value lives on the node (`data.useCache` / `data.isIntermediate`), but the underlying field is declared on the + * backend as a connectable input, so this also hosts the field's connection handle. When an edge drives the value the + * checkbox is dropped and only the label and handle remain, matching how a connected input field renders. + */ +export const NodeSettingFooterControl = memo(({ nodeId, setting }: Props) => { + const fieldName = NODE_SETTING_FIELD_NAMES[setting]; + const label = useNodeSettingDefaultLabel(setting); + const { isChecked, onChange, isConnected } = useNodeSetting(nodeId, setting); + // The template is what the handle needs. It should always be present, but a node whose template failed to parse + // this field must not take the whole footer down with it. + const fieldTemplate = useInputFieldTemplateSafe(fieldName); + const isAdmin = useIsAdmin(); + const draggableRef = useRef(null); + const dragHandleRef = useRef(null); + const isDragging = useNodeSettingDnd(nodeId, setting, draggableRef, dragHandleRef); + + // Node-cache control is admin-only (single-user mode counts as admin). + if (setting === 'use_cache' && !isAdmin) { + return null; + } + + return ( + + + + {label} + + + + {!isConnected && } + + {fieldTemplate && } + + ); +}); + +NodeSettingFooterControl.displayName = 'NodeSettingFooterControl'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx deleted file mode 100644 index 4e9912a87be..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { useNodeHasGalleryOutput } from 'features/nodes/hooks/useNodeHasGalleryOutput'; -import { useNodeIsIntermediate } from 'features/nodes/hooks/useNodeIsIntermediate'; -import { nodeIsIntermediateChanged } from 'features/nodes/store/nodesSlice'; -import { NO_PAN_CLASS } from 'features/nodes/types/constants'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const hasGalleryOutput = useNodeHasGalleryOutput(); - const isIntermediate = useNodeIsIntermediate(); - const handleChange = useCallback( - (e: ChangeEvent) => { - dispatch( - nodeIsIntermediateChanged({ - nodeId, - isIntermediate: !e.target.checked, - }) - ); - }, - [dispatch, nodeId] - ); - - if (!hasGalleryOutput) { - return null; - } - - return ( - - {t('nodes.saveToGallery')} - - - ); -}; - -export default memo(SaveToGalleryCheckbox); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx deleted file mode 100644 index b0f1fdead32..00000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { useIsAdmin } from 'features/auth/hooks/useIsAdmin'; -import { useUseCache } from 'features/nodes/hooks/useUseCache'; -import { nodeUseCacheChanged } from 'features/nodes/store/nodesSlice'; -import { NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => { - const dispatch = useAppDispatch(); - const useCache = useUseCache(); - // Node-cache control is admin-only (single-user mode counts as admin). - const isVisible = useIsAdmin(); - - const handleChange = useCallback( - (e: ChangeEvent) => { - dispatch( - nodeUseCacheChanged({ - nodeId, - useCache: e.target.checked, - }) - ); - }, - [dispatch, nodeId] - ); - const { t } = useTranslation(); - - if (!isVisible) { - return null; - } - - return ( - - {t('invocationCache.useCache')} - - - ); -}; - -export default memo(UseCacheCheckbox); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElement.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElement.tsx index 76799396086..bd8eeb33e11 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElement.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElement.tsx @@ -15,6 +15,7 @@ import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/ import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader'; import { HeadingElement } from 'features/nodes/components/sidePanel/builder/HeadingElement'; import { NodeFieldElement } from 'features/nodes/components/sidePanel/builder/NodeFieldElement'; +import { NodeSettingElement } from 'features/nodes/components/sidePanel/builder/NodeSettingElement'; import { TextElement } from 'features/nodes/components/sidePanel/builder/TextElement'; import { useElement } from 'features/nodes/components/sidePanel/builder/use-element'; import { selectFormRootElement } from 'features/nodes/store/selectors'; @@ -26,6 +27,7 @@ import { isDividerElement, isHeadingElement, isNodeFieldElement, + isNodeSettingElement, isTextElement, ROOT_CONTAINER_CLASS_NAME, } from 'features/nodes/types/workflow'; @@ -308,6 +310,10 @@ const FormElementComponent = memo(({ id }: { id: string }) => { return ; } + if (isNodeSettingElement(el)) { + return ; + } + if (isDividerElement(el)) { return ; } 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 c50f6f1b3ba..6c5aecb5554 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 @@ -10,8 +10,8 @@ import { NodeFieldElementSettings } from 'features/nodes/components/sidePanel/bu import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode'; import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode'; import { formElementRemoved } from 'features/nodes/store/nodesSlice'; -import type { FormElement, NodeFieldElement } from 'features/nodes/types/workflow'; -import { isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow'; +import type { FormElement } from 'features/nodes/types/workflow'; +import { isContainerElement, isNodeFieldElement, isNodeSettingElement } from 'features/nodes/types/workflow'; import type { RefObject } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -56,20 +56,20 @@ export const FormElementEditModeHeader = memo(({ element, dragHandleRef, ...rest fieldName={element.data.fieldIdentifier.fieldName} fallback={null} // Do not render these buttons if the field is not found > - + )} + {isNodeSettingElement(element) && } ); }); FormElementEditModeHeader.displayName = 'FormElementEditModeHeader'; -const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => { +const ZoomToNodeButton = memo(({ nodeId }: { nodeId: string }) => { const { t } = useTranslation(); - const { nodeId } = element.data.fieldIdentifier; const zoomToNode = useZoomToNode(nodeId); const mouseOverFormField = useMouseOverFormField(nodeId); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementNodeOverlay.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementNodeOverlay.tsx new file mode 100644 index 00000000000..3d3c0362cc8 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementNodeOverlay.tsx @@ -0,0 +1,37 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box } from '@invoke-ai/ui-library'; +import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; +import { memo } from 'react'; + +const sx: SystemStyleObject = { + position: 'absolute', + top: 0, + insetInlineEnd: 0, + bottom: 0, + insetInlineStart: 0, + borderRadius: 'base', + transitionProperty: 'none', + pointerEvents: 'none', + display: 'none', + '&[data-is-mouse-over-node-or-form-field="true"]': { + display: 'block', + bg: 'invokeBlueAlpha.100', + }, +}; + +/** + * Highlights a form element that references a node while the mouse is over that node (or vice versa), so it is easy + * to see which node a form element belongs to. Must be rendered inside a relatively-positioned element. + */ +export const FormElementNodeOverlay = memo(({ nodeId }: { nodeId: string }) => { + const mouseOverNode = useMouseOverNode(nodeId); + const mouseOverFormField = useMouseOverFormField(nodeId); + + return ( + + ); +}); +FormElementNodeOverlay.displayName = 'FormElementNodeOverlay'; 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 26b96f466fb..e7bac2c6c7a 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 @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Divider, Flex, FormControl } from '@invoke-ai/ui-library'; +import { Divider, Flex, FormControl } from '@invoke-ai/ui-library'; import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context'; import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate'; import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer'; @@ -8,10 +8,10 @@ import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/d import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator'; import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent'; import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader'; +import { FormElementNodeOverlay } from 'features/nodes/components/sidePanel/builder/FormElementNodeOverlay'; import { NodeFieldElementDescriptionEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable'; import { NodeFieldElementLabelEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable'; import { NodeFieldElementStringDropdownSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringDropdownSettings'; -import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow'; import type { RefObject } from 'react'; @@ -40,7 +40,7 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement }) return ( - + ); @@ -91,32 +91,3 @@ const NodeFieldElementEditModeContent = memo( } ); NodeFieldElementEditModeContent.displayName = 'NodeFieldElementEditModeContent'; - -const nodeFieldOverlaySx: SystemStyleObject = { - position: 'absolute', - top: 0, - insetInlineEnd: 0, - bottom: 0, - insetInlineStart: 0, - borderRadius: 'base', - transitionProperty: 'none', - pointerEvents: 'none', - display: 'none', - '&[data-is-mouse-over-node-or-form-field="true"]': { - display: 'block', - bg: 'invokeBlueAlpha.100', - }, -}; - -const NodeFieldElementOverlay = memo(({ nodeId }: { nodeId: string }) => { - const mouseOverNode = useMouseOverNode(nodeId); - const mouseOverFormField = useMouseOverFormField(nodeId); - - return ( - - ); -}); -NodeFieldElementOverlay.displayName = 'NodeFieldElementOverlay'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElement.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElement.tsx new file mode 100644 index 00000000000..de7ece40bda --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElement.tsx @@ -0,0 +1,34 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context'; +import { NodeSettingElementEditMode } from 'features/nodes/components/sidePanel/builder/NodeSettingElementEditMode'; +import { NodeSettingElementViewMode } from 'features/nodes/components/sidePanel/builder/NodeSettingElementViewMode'; +import { useElement } from 'features/nodes/components/sidePanel/builder/use-element'; +import { selectWorkflowMode } from 'features/nodes/store/workflowLibrarySlice'; +import { isNodeSettingElement } from 'features/nodes/types/workflow'; +import { memo } from 'react'; + +export const NodeSettingElement = memo(({ id }: { id: string }) => { + const el = useElement(id); + const mode = useAppSelector(selectWorkflowMode); + + if (!el || !isNodeSettingElement(el)) { + return null; + } + + if (mode === 'view') { + return ( + + + + ); + } + + // mode === 'edit' + return ( + + + + ); +}); + +NodeSettingElement.displayName = 'NodeSettingElement'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementEditMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementEditMode.tsx new file mode 100644 index 00000000000..748e842a0ac --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementEditMode.tsx @@ -0,0 +1,80 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, FormControl, Switch, Text } from '@invoke-ai/ui-library'; +import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts'; +import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks'; +import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator'; +import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/builder/FormElementEditModeContent'; +import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader'; +import { FormElementNodeOverlay } from 'features/nodes/components/sidePanel/builder/FormElementNodeOverlay'; +import { NodeSettingElementLabelEditable } from 'features/nodes/components/sidePanel/builder/NodeSettingElementLabelEditable'; +import { useIsNodeSettingApplicable, useNodeSetting } from 'features/nodes/hooks/useNodeSetting'; +import type { NodeSettingElement } from 'features/nodes/types/workflow'; +import { NODE_SETTING_CLASS_NAME } from 'features/nodes/types/workflow'; +import { memo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +const sx: SystemStyleObject = { + position: 'relative', + borderRadius: 'base', + '&[data-parent-layout="column"]': { + w: 'full', + h: 'min-content', + }, + '&[data-parent-layout="row"]': { + flex: '1 1 0', + }, + flexDir: 'column', +}; + +export const NodeSettingElementEditMode = memo(({ el }: { el: NodeSettingElement }) => { + const draggableRef = useRef(null); + const dragHandleRef = useRef(null); + const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef); + const containerCtx = useContainerContext(); + const { id } = el; + + return ( + + + + + + + + + ); +}); +NodeSettingElementEditMode.displayName = 'NodeSettingElementEditMode'; + +const NodeSettingElementEditModeContent = memo(({ el }: { el: NodeSettingElement }) => { + const { t } = useTranslation(); + const { data } = el; + const isApplicable = useIsNodeSettingApplicable(); + const { isChecked, onChange, isConnected } = useNodeSetting(data.nodeId, data.setting); + + if (!isApplicable) { + // The element is hidden in view mode, so surface why it won't render while editing. + return ( + + {t('workflows.builder.nodeSettingNotApplicable')} + + ); + } + + return ( + + + + {/* An edge drives the value, so the local one is stale - show it, but don't let it be changed here */} + + + + ); +}); +NodeSettingElementEditModeContent.displayName = 'NodeSettingElementEditModeContent'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementLabelEditable.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementLabelEditable.tsx new file mode 100644 index 00000000000..647a50366d6 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementLabelEditable.tsx @@ -0,0 +1,50 @@ +import { Flex, FormLabel, Input } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useEditable } from 'common/hooks/useEditable'; +import { useNodeSettingDefaultLabel } from 'features/nodes/hooks/useNodeSetting'; +import { formElementNodeSettingDataChanged } from 'features/nodes/store/nodesSlice'; +import type { NodeSettingElement } from 'features/nodes/types/workflow'; +import { memo, useCallback, useRef } from 'react'; + +export const NodeSettingElementLabelEditable = memo(({ el }: { el: NodeSettingElement }) => { + const { id, data } = el; + const dispatch = useAppDispatch(); + const defaultLabel = useNodeSettingDefaultLabel(data.setting); + const inputRef = useRef(null); + + const onChange = useCallback( + (label: string) => { + dispatch(formElementNodeSettingDataChanged({ id, changes: { label } })); + }, + [dispatch, id] + ); + + const editable = useEditable({ + value: data.label || defaultLabel, + defaultValue: defaultLabel, + inputRef, + onChange, + }); + + if (!editable.isEditing) { + return ( + + + {editable.value} + + + ); + } + + return ( + + ); +}); +NodeSettingElementLabelEditable.displayName = 'NodeSettingElementLabelEditable'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementViewMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementViewMode.tsx new file mode 100644 index 00000000000..d40ab9503b2 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeSettingElementViewMode.tsx @@ -0,0 +1,52 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import { useIsAdmin } from 'features/auth/hooks/useIsAdmin'; +import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts'; +import { + useIsNodeSettingApplicable, + useNodeSetting, + useNodeSettingDefaultLabel, +} from 'features/nodes/hooks/useNodeSetting'; +import type { NodeSettingElement } from 'features/nodes/types/workflow'; +import { NODE_SETTING_CLASS_NAME } from 'features/nodes/types/workflow'; +import { memo } from 'react'; + +const sx: SystemStyleObject = { + pb: 2, + '&[data-parent-layout="column"]': { + w: 'full', + h: 'min-content', + }, + '&[data-parent-layout="row"]': { + flex: '1 1 0', + minW: 32, + }, +}; + +export const NodeSettingElementViewMode = memo(({ el }: { el: NodeSettingElement }) => { + const { id, data } = el; + const containerCtx = useContainerContext(); + const defaultLabel = useNodeSettingDefaultLabel(data.setting); + const isApplicable = useIsNodeSettingApplicable(); + const isAdmin = useIsAdmin(); + const { isChecked, onChange, isConnected } = useNodeSetting(data.nodeId, data.setting); + + // Settings that no longer apply to their node would render as no-op toggles, so hide them. Node-cache control is + // admin-only (single-user mode counts as admin), matching the node footer. + if (!isApplicable || (data.setting === 'use_cache' && !isAdmin)) { + return null; + } + + return ( + + + {data.label || defaultLabel} + + {/* An edge drives the value, so the local one is stale - show it, but don't let it be changed here */} + + + + + ); +}); +NodeSettingElementViewMode.displayName = 'NodeSettingElementViewMode'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx index 513cc006028..29e396f5fc0 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx @@ -44,6 +44,9 @@ export const WorkflowBuilder = memo(() => { + 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 0524f8fcfda..aea1e83882b 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 @@ -33,8 +33,14 @@ import { } from 'features/nodes/store/nodesSlice'; import { selectFormRootElementId, selectNodesSlice, selectWorkflowForm } from 'features/nodes/store/selectors'; import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field'; -import type { ElementId, FormElement } from 'features/nodes/types/workflow'; -import { buildNodeFieldElement, isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow'; +import type { ElementId, FormElement, NodeSettingName } from 'features/nodes/types/workflow'; +import { + buildNodeFieldElement, + buildNodeSettingElement, + isContainerElement, + isNodeFieldElement, + isNodeSettingElement, +} from 'features/nodes/types/workflow'; import type { RefObject } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { flushSync } from 'react-dom'; @@ -78,6 +84,21 @@ const isNodeFieldDndData = (data: Record): data is Nod return uniqueNodeFieldDndKey in data; }; +const uniqueNodeSettingDndKey = Symbol('node-setting'); +type NodeSettingDndData = { + [uniqueNodeSettingDndKey]: true; + nodeId: string; + setting: NodeSettingName; +}; +const buildNodeSettingDndData = (nodeId: string, setting: NodeSettingName): NodeSettingDndData => ({ + [uniqueNodeSettingDndKey]: true, + nodeId, + setting, +}); +const isNodeSettingDndData = (data: Record): data is NodeSettingDndData => { + return uniqueNodeSettingDndKey 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 @@ -144,6 +165,29 @@ const useNodeFieldElementExists = () => { return nodeFieldElementExists; }; +/** + * Checks if a node setting element exists in the form. + * + * @param form The form to check + * @param nodeId The id of the node + * @param setting The name of the setting + * + * @returns True if the element exists, false otherwise + */ +const useNodeSettingElementExists = () => { + const store = useAppStore(); + const nodeSettingElementExists = useCallback( + (nodeId: string, setting: NodeSettingName): boolean => { + const form = selectWorkflowForm(store.getState()); + return Object.values(form.elements) + .filter(isNodeSettingElement) + .some((el) => el.data.nodeId === nodeId && el.data.setting === setting); + }, + [store] + ); + return nodeSettingElementExists; +}; + /** * Wrapper around `getAllowedDropRegions` that provides the form state from the store. * @see {@link getAllowedDropRegions} @@ -182,6 +226,11 @@ const getSourceElement = (source: ElementDragPayload) => { return buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type); } + if (isNodeSettingDndData(source.data)) { + const { nodeId, setting } = source.data; + return buildNodeSettingElement(nodeId, setting); + } + if (isFormElementDndData(source.data)) { return source.data.element; } @@ -220,7 +269,8 @@ export const useBuilderDndMonitor = () => { useEffect(() => { return monitorForElements({ - canMonitor: ({ source }) => isFormElementDndData(source.data) || isNodeFieldDndData(source.data), + canMonitor: ({ source }) => + isFormElementDndData(source.data) || isNodeFieldDndData(source.data) || isNodeSettingDndData(source.data), onDrop: ({ location, source }) => { const target = location.current.dropTargets[0]; if (!target) { @@ -392,6 +442,7 @@ export const useFormElementDnd = ( const getElement = useGetElement(); const getAllowedDropRegions = useGetAllowedDropRegions(); const nodeFieldElementExists = useNodeFieldElementExists(); + const nodeSettingElementExists = useNodeSettingElementExists(); useEffect(() => { if (isRootElement) { @@ -428,6 +479,9 @@ export const useFormElementDnd = ( if (isNodeFieldDndData(source.data) && !nodeFieldElementExists(source.data.nodeId, source.data.fieldName)) { return true; } + if (isNodeSettingDndData(source.data) && !nodeSettingElementExists(source.data.nodeId, source.data.setting)) { + return true; + } if (isFormElementDndData(source.data)) { return source.data.element.id !== getElement(elementId).parentId; } @@ -480,6 +534,7 @@ export const useFormElementDnd = ( getAllowedDropRegions, getElement, nodeFieldElementExists, + nodeSettingElementExists, isRootElement, ]); @@ -508,7 +563,11 @@ export const useRootElementDropTarget = (droppableRef: RefObject, + dragHandleRef: RefObject +) => { + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + const draggableElement = draggableRef.current; + const dragHandleElement = dragHandleRef.current; + if (!draggableElement || !dragHandleElement) { + return; + } + return combine( + dndInputFix(draggableElement), + draggable({ + element: draggableElement, + dragHandle: dragHandleElement, + getInitialData: () => buildNodeSettingDndData(nodeId, setting), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + }) + ); + }, [dragHandleRef, draggableRef, nodeId, setting]); + + return isDragging; +}; + /** * Hook that returns whether an element is the root element. * @param elementId The id of the element diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-remove-form-element.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-remove-form-element.ts index e7e32345862..8af5ecec0d3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-remove-form-element.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-remove-form-element.ts @@ -2,8 +2,13 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance'; import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; import { formElementAdded, formElementRemoved } from 'features/nodes/store/nodesSlice'; -import { buildSelectWorkflowFormNodeElement, selectFormRootElementId } from 'features/nodes/store/selectors'; -import { buildNodeFieldElement } from 'features/nodes/types/workflow'; +import { + buildSelectWorkflowFormNodeElement, + buildSelectWorkflowFormNodeSettingElement, + selectFormRootElementId, +} from 'features/nodes/store/selectors'; +import type { NodeSettingName } from 'features/nodes/types/workflow'; +import { buildNodeFieldElement, buildNodeSettingElement } from 'features/nodes/types/workflow'; import { useCallback, useMemo } from 'react'; export const useAddRemoveFormElement = (nodeId: string, fieldName: string) => { @@ -44,3 +49,34 @@ export const useAddRemoveFormElement = (nodeId: string, fieldName: string) => { return { isAddedToRoot, addNodeFieldToRoot, removeNodeFieldFromRoot }; }; + +export const useAddRemoveNodeSettingFormElement = (nodeId: string, setting: NodeSettingName) => { + const dispatch = useAppDispatch(); + const rootElementId = useAppSelector(selectFormRootElementId); + const selectWorkflowFormNodeSettingElement = useMemo( + () => buildSelectWorkflowFormNodeSettingElement(nodeId, setting), + [nodeId, setting] + ); + const workflowFormNodeSettingElement = useAppSelector(selectWorkflowFormNodeSettingElement); + const isAddedToRoot = useMemo(() => { + return !!workflowFormNodeSettingElement; + }, [workflowFormNodeSettingElement]); + + const addNodeSettingToRoot = useCallback(() => { + const element = buildNodeSettingElement(nodeId, setting); + dispatch(formElementAdded({ element, parentId: rootElementId })); + }, [nodeId, setting, dispatch, rootElementId]); + + const removeNodeSettingFromRoot = useCallback(() => { + if (!workflowFormNodeSettingElement) { + return; + } + dispatch( + formElementRemoved({ + id: workflowFormNodeSettingElement.id, + }) + ); + }, [workflowFormNodeSettingElement, dispatch]); + + return { isAddedToRoot, addNodeSettingToRoot, removeNodeSettingFromRoot }; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts index bd7f9b1e964..b03b593197a 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts @@ -4,11 +4,14 @@ import { isNil } from 'es-toolkit/compat'; import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context'; import type { FieldInputTemplate } from 'features/nodes/types/field'; import { isSingleOrCollection, isStatefulFieldType } from 'features/nodes/types/field'; +import { isNodeAttributeFieldName } from 'features/nodes/types/nodeAttributeFields'; import { useMemo } from 'react'; /** * Sort input fields: unordered fields first (preserving original order), * then explicitly ordered fields sorted by ui_order ascending. + * + * Node attribute fields are dropped - they live in the node footer, not in the input list. */ const sortInputFields = (fields: FieldInputTemplate[]): string[] => { const visibleFields = fields.filter((field) => !field.ui_hidden); @@ -21,7 +24,7 @@ const sortInputFields = (fields: FieldInputTemplate[]): string[] => { return unorderedFields .concat(orderedFields) .map((f) => f.name) - .filter((fieldName) => fieldName !== 'is_intermediate'); + .filter((fieldName) => !isNodeAttributeFieldName(fieldName)); }; const isConnectionInputField = (field: FieldInputTemplate) => { diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts b/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts deleted file mode 100644 index 7f5bd6c69f0..00000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isBatchNodeType, isGeneratorNodeType } from 'features/nodes/types/invocation'; -import { useMemo } from 'react'; - -import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; - -export const useIsExecutableNode = () => { - const template = useNodeTemplateOrThrow(); - const isExecutableNode = useMemo( - () => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type), - [template] - ); - return isExecutableNode; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts deleted file mode 100644 index d1e028d2b5f..00000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { some } from 'es-toolkit/compat'; -import { useMemo } from 'react'; - -import { useNodeTemplateSafe } from './useNodeTemplateSafe'; - -/** - * True when the node produces an output that lands in the gallery — currently ImageField or - * VideoField. Used to gate the "Save in gallery" checkbox and the footer that contains it. - * - * The `image` and `video` primitive nodes are excluded because they pass through an existing - * asset without saving a new copy. - */ -export const useNodeHasGalleryOutput = (): boolean => { - const template = useNodeTemplateSafe(); - const hasGalleryOutput = useMemo( - () => - some( - template?.outputs, - (output) => - (output.type.name === 'ImageField' && template?.type !== 'image') || - (output.type.name === 'VideoField' && template?.type !== 'video') - ), - [template] - ); - - return hasGalleryOutput; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeSetting.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeSetting.ts new file mode 100644 index 00000000000..bc50882cf2f --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeSetting.ts @@ -0,0 +1,75 @@ +import { useAppDispatch } from 'app/store/storeHooks'; +import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected'; +import { useNodeIsIntermediate } from 'features/nodes/hooks/useNodeIsIntermediate'; +import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe'; +import { useUseCache } from 'features/nodes/hooks/useUseCache'; +import { nodeIsIntermediateChanged, nodeUseCacheChanged } from 'features/nodes/store/nodesSlice'; +import { getHasNodeFooter } from 'features/nodes/types/invocation'; +import type { NodeAttributeFieldName } from 'features/nodes/types/nodeAttributeFields'; +import type { NodeSettingName } from 'features/nodes/types/workflow'; +import type { ChangeEvent } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * The backend field each node setting maps to. Note that `save_to_gallery` is the inverse of `is_intermediate`. + */ +export const NODE_SETTING_FIELD_NAMES: Record = { + use_cache: 'use_cache', + save_to_gallery: 'is_intermediate', +}; + +/** + * The label shown for a node setting when the workflow author has not provided their own. + */ +export const useNodeSettingDefaultLabel = (setting: NodeSettingName): string => { + const { t } = useTranslation(); + return useMemo( + () => (setting === 'use_cache' ? t('invocationCache.useCache') : t('nodes.saveToGallery')), + [setting, t] + ); +}; + +/** + * Whether a node setting applies to the node it belongs to. A setting applies exactly when the node renders a + * footer, since that is where the setting lives on the node itself. + * + * Must be used within an `InvocationNodeContextProvider`. + */ +export const useIsNodeSettingApplicable = (): boolean => { + const template = useNodeTemplateSafe(); + return useMemo(() => (template ? getHasNodeFooter(template) : false), [template]); +}; + +/** + * Provides the checked state and change handler for a node setting, plus whether an edge is driving it. + * + * When connected, the node runs with the edge's value, so the local value is stale and must not be presented as + * something the user can change. + * + * Must be used within an `InvocationNodeContextProvider`. + */ +export const useNodeSetting = (nodeId: string, setting: NodeSettingName) => { + const dispatch = useAppDispatch(); + const useCache = useUseCache(); + const isIntermediate = useNodeIsIntermediate(); + const isConnected = useInputFieldIsConnected(NODE_SETTING_FIELD_NAMES[setting]); + + const isChecked = useMemo( + () => (setting === 'use_cache' ? useCache : !isIntermediate), + [isIntermediate, setting, useCache] + ); + + const onChange = useCallback( + (e: ChangeEvent) => { + if (setting === 'use_cache') { + dispatch(nodeUseCacheChanged({ nodeId, useCache: e.target.checked })); + } else { + dispatch(nodeIsIntermediateChanged({ nodeId, isIntermediate: !e.target.checked })); + } + }, + [dispatch, nodeId, setting] + ); + + return { isChecked, onChange, isConnected }; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts b/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts index 14383affd82..515a572fdaa 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts @@ -1,9 +1,13 @@ -import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode'; - -import { useNodeHasGalleryOutput } from './useNodeHasGalleryOutput'; +import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; +import { getHasNodeFooter } from 'features/nodes/types/invocation'; +import { useMemo } from 'react'; +/** + * Whether the node renders a footer. The footer hosts the node attribute fields and their connection handles. + * + * @see {@link getHasNodeFooter} + */ export const useWithFooter = () => { - const hasGalleryOutput = useNodeHasGalleryOutput(); - const isExecutableNode = useIsExecutableNode(); - return isExecutableNode && hasGalleryOutput; + const template = useNodeTemplateOrThrow(); + return useMemo(() => getHasNodeFooter(template), [template]); }; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 25fdb606a90..f7386fc132a 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -87,6 +87,7 @@ import type { FormElement, HeadingElement, NodeFieldElement, + NodeSettingElement, TextElement, WorkflowCategory, WorkflowV3, @@ -96,6 +97,7 @@ import { isContainerElement, isHeadingElement, isNodeFieldElement, + isNodeSettingElement, isTextElement, } from 'features/nodes/types/workflow'; import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; @@ -327,10 +329,14 @@ const slice = createSlice({ // and add changes for each exposed field. If the remove change comes after the add change, we remove the exposed // field. for (const el of Object.values(state.form.elements)) { - if (!isNodeFieldElement(el)) { + let nodeId: string; + if (isNodeFieldElement(el)) { + nodeId = el.data.fieldIdentifier.nodeId; + } else if (isNodeSettingElement(el)) { + nodeId = el.data.nodeId; + } else { continue; } - const { nodeId } = el.data.fieldIdentifier; const removeIndex = action.payload.findLastIndex( (change) => change.type === 'remove' && change.id === nodeId ); @@ -775,6 +781,9 @@ const slice = createSlice({ formElementNodeFieldDataChanged: (state, action: FormElementDataChangedAction) => { formElementDataChangedReducer(state, action, isNodeFieldElement); }, + formElementNodeSettingDataChanged: (state, action: FormElementDataChangedAction) => { + formElementDataChangedReducer(state, action, isNodeSettingElement); + }, formElementContainerDataChanged: (state, action: FormElementDataChangedAction) => { formElementDataChangedReducer(state, action, isContainerElement); }, @@ -856,6 +865,7 @@ export const { formElementHeadingDataChanged, formElementTextDataChanged, formElementNodeFieldDataChanged, + formElementNodeSettingDataChanged, formElementContainerDataChanged, formFieldInitialValuesChanged, workflowLoaded, diff --git a/invokeai/frontend/web/src/features/nodes/store/selectors.ts b/invokeai/frontend/web/src/features/nodes/store/selectors.ts index 5e8b734fbe5..1778197e4f7 100644 --- a/invokeai/frontend/web/src/features/nodes/store/selectors.ts +++ b/invokeai/frontend/web/src/features/nodes/store/selectors.ts @@ -6,7 +6,8 @@ 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 { isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow'; +import type { NodeSettingName } from 'features/nodes/types/workflow'; +import { isContainerElement, isNodeFieldElement, isNodeSettingElement } from 'features/nodes/types/workflow'; import { assert } from 'tsafe'; export const selectNode = (nodesSlice: NodesState, nodeId: string): AnyNode => { @@ -93,6 +94,9 @@ export const selectFormInitialValues = createNodesSelector((workflow) => workflo export const selectNodeFieldElements = createNodesSelector((workflow) => Object.values(workflow.form.elements).filter(isNodeFieldElement) ); +const selectNodeSettingElements = createNodesSelector((workflow) => + Object.values(workflow.form.elements).filter(isNodeSettingElement) +); export const buildSelectElement = (id: string) => createNodesSelector((workflow) => workflow.form?.elements[id]); export const buildSelectWorkflowFormNodeElement = (nodeId: string, fieldName: string) => @@ -102,3 +106,7 @@ export const buildSelectWorkflowFormNodeElement = (nodeId: string, fieldName: st element.data.fieldIdentifier.nodeId === nodeId && element.data.fieldIdentifier.fieldName === fieldName ) ); +export const buildSelectWorkflowFormNodeSettingElement = (nodeId: string, setting: NodeSettingName) => + createSelector(selectNodeSettingElements, (elements) => + elements.find((element) => element.data.nodeId === nodeId && element.data.setting === setting) + ); diff --git a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts index 67c477408f3..666743ab14a 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts @@ -14,6 +14,36 @@ export const buildEdge = (source: string, sourceHandle: string, target: string, export const buildNode = (template: InvocationTemplate) => buildInvocationNode({ x: 0, y: 0 }, template); +/** + * `is_intermediate` and `use_cache` are declared on every invocation by the backend's `BaseInvocation`, so every + * parsed template carries them. They are node attributes - never instantiated on a node - but they are parsed all the + * same so they can be connection targets. + */ +const nodeAttributeFieldTemplates = (useCacheDefault: boolean): InvocationTemplate['inputs'] => ({ + is_intermediate: { + name: 'is_intermediate', + title: 'Is Intermediate', + required: false, + description: 'Whether or not this is an intermediate invocation.', + fieldKind: 'input', + input: 'any', + ui_hidden: false, + type: { name: 'BooleanField', cardinality: 'SINGLE', batch: false }, + default: false, + }, + use_cache: { + name: 'use_cache', + title: 'Use Cache', + required: false, + description: 'Whether or not to use the cache', + fieldKind: 'input', + input: 'any', + ui_hidden: false, + type: { name: 'BooleanField', cardinality: 'SINGLE', batch: false }, + default: useCacheDefault, + }, +}); + export const add: InvocationTemplate = { title: 'Add Integers', type: 'add', @@ -22,6 +52,7 @@ export const add: InvocationTemplate = { description: 'Adds two numbers', outputType: 'integer_output', inputs: { + ...nodeAttributeFieldTemplates(true), a: { name: 'a', title: 'A', @@ -82,6 +113,7 @@ export const call_saved_workflow: InvocationTemplate = { category: 'workflow', outputType: 'integer_output', inputs: { + ...nodeAttributeFieldTemplates(false), workflow_id: { name: 'workflow_id', title: 'Workflow Id', @@ -132,6 +164,7 @@ export const workflow_return: InvocationTemplate = { category: 'workflow', outputType: 'workflow_return_output', inputs: { + ...nodeAttributeFieldTemplates(false), collection: { name: 'collection', title: 'Collection', @@ -177,6 +210,7 @@ export const sub: InvocationTemplate = { description: 'Subtracts two numbers', outputType: 'integer_output', inputs: { + ...nodeAttributeFieldTemplates(true), a: { name: 'a', title: 'A', @@ -236,6 +270,7 @@ export const collect: InvocationTemplate = { description: 'Collects values into a collection', outputType: 'collect_output', inputs: { + ...nodeAttributeFieldTemplates(true), collection: { name: 'collection', title: 'Collection', @@ -298,6 +333,7 @@ const scheduler: InvocationTemplate = { description: 'Selects a scheduler.', outputType: 'scheduler_output', inputs: { + ...nodeAttributeFieldTemplates(true), scheduler: { name: 'scheduler', title: 'Scheduler', @@ -355,6 +391,7 @@ export const main_model_loader: InvocationTemplate = { description: 'Loads a main model, outputting its submodels.', outputType: 'model_loader_output', inputs: { + ...nodeAttributeFieldTemplates(true), model: { name: 'model', title: 'Model', @@ -424,6 +461,7 @@ export const img_resize: InvocationTemplate = { description: 'Resizes an image to specific dimensions', outputType: 'image_output', inputs: { + ...nodeAttributeFieldTemplates(true), board: { name: 'board', title: 'Board', @@ -569,6 +607,7 @@ const iterate: InvocationTemplate = { description: 'Iterates over a list of items', outputType: 'iterate_output', inputs: { + ...nodeAttributeFieldTemplates(true), collection: { name: 'collection', title: 'Collection', @@ -666,7 +705,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -674,6 +715,9 @@ export const schema = { description: 'Whether or not to use the cache', default: false, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, workflow_id: { type: 'string', @@ -724,7 +768,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -732,6 +778,9 @@ export const schema = { description: 'Whether or not to use the cache', default: false, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, collection: { type: 'array', @@ -806,7 +855,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -814,6 +865,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, a: { type: 'integer', @@ -898,7 +952,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -906,6 +962,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, scheduler: { type: 'string', @@ -1031,7 +1090,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -1039,6 +1100,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, model: { allOf: [ @@ -1394,7 +1458,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -1402,6 +1468,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, item: { anyOf: [ @@ -1490,7 +1559,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -1498,6 +1569,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, a: { type: 'integer', @@ -1588,7 +1662,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -1596,6 +1672,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, image: { allOf: [ @@ -1757,7 +1836,9 @@ export const schema = { description: 'Whether or not this is an intermediate invocation.', default: false, field_kind: 'node_attribute', - ui_type: 'IsIntermediate', + input: 'any', + orig_required: false, + ui_hidden: false, }, use_cache: { type: 'boolean', @@ -1765,6 +1846,9 @@ export const schema = { description: 'Whether or not to use the cache', default: true, field_kind: 'node_attribute', + input: 'any', + orig_required: false, + ui_hidden: false, }, collection: { items: {}, diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts index 1eef0794436..4fa3e1c58aa 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.test.ts @@ -816,6 +816,75 @@ describe(validateConnection.name, () => { }); }); + describe('node attribute fields', () => { + // `use_cache` and `is_intermediate` are parsed into every template so they can be connection targets, but they + // are only reachable on nodes that render a footer - that is where their handle lives. + const booleanPrimitive: InvocationTemplate = { + title: 'Boolean Primitive', + type: 'boolean', + version: '1.0.1', + tags: ['primitives', 'boolean'], + description: 'A boolean primitive value', + outputType: 'boolean_output', + inputs: {}, + outputs: { + value: { + fieldKind: 'output', + name: 'value', + title: 'Value', + description: 'The output boolean', + type: { name: 'BooleanField', cardinality: 'SINGLE', batch: false }, + ui_hidden: false, + }, + }, + useCache: true, + nodePack: 'invokeai', + classification: 'stable', + category: 'primitives', + }; + const templatesWithBoolean = { ...templates, boolean: booleanPrimitive }; + + it.each(['use_cache', 'is_intermediate'])( + 'should accept a connection to %s on a node that renders a footer', + (targetHandle) => { + const n1 = buildNode(booleanPrimitive); + const n2 = buildNode(img_resize); + const c = { source: n1.id, sourceHandle: 'value', target: n2.id, targetHandle }; + const r = validateConnection(c, [n1, n2], [], templatesWithBoolean, null); + expect(r).toEqual(null); + } + ); + + it.each(['use_cache', 'is_intermediate'])( + 'should reject a connection to %s on a node with no footer', + (targetHandle) => { + // `add` has no gallery output, so it renders no footer and there is no handle to attach to + const n1 = buildNode(booleanPrimitive); + const n2 = buildNode(add); + const c = { source: n1.id, sourceHandle: 'value', target: n2.id, targetHandle }; + const r = validateConnection(c, [n1, n2], [], templatesWithBoolean, null); + expect(r).toEqual('nodes.cannotConnectToUnavailableNodeSetting'); + } + ); + + it('should still reject a type mismatch on a reachable node attribute field', () => { + const n1 = buildNode(add); + const n2 = buildNode(img_resize); + const c = { source: n1.id, sourceHandle: 'value', target: n2.id, targetHandle: 'use_cache' }; + const r = validateConnection(c, [n1, n2], [], templatesWithBoolean, null); + expect(r).toEqual('nodes.fieldTypesMustMatch'); + }); + + it('should not create field instances for node attribute fields', () => { + // The value lives on the node itself; an instance would be a second, conflicting source of truth + const node = buildNode(img_resize); + expect(node.data.inputs.use_cache).toBeUndefined(); + expect(node.data.inputs.is_intermediate).toBeUndefined(); + expect(node.data.useCache).toBe(true); + expect(node.data.isIntermediate).toBe(true); + }); + }); + describe('non-strict mode', () => { it('should reject connections from self to self in non-strict mode', () => { const c = { source: 'add', sourceHandle: 'value', target: 'add', targetHandle: 'a' }; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts index 710e49de7cf..68b721250e4 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/validateConnection.ts @@ -11,7 +11,13 @@ import { getHasCycles } from 'features/nodes/store/util/getHasCycles'; import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; import type { FieldType } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode, InvocationNode } from 'features/nodes/types/invocation'; -import { getInvocationNodeInputTemplate, isConnectorNode, isInvocationNode } from 'features/nodes/types/invocation'; +import { + getHasNodeFooter, + getInvocationNodeInputTemplate, + isConnectorNode, + isInvocationNode, +} from 'features/nodes/types/invocation'; +import { isNodeAttributeFieldName } from 'features/nodes/types/nodeAttributeFields'; import type { SetNonNullable } from 'type-fest'; type Connection = SetNonNullable; @@ -300,6 +306,13 @@ export const validateConnection: ValidateConnectionFunc = ( return 'nodes.cannotConnectToDirectInput'; } + if (isNodeAttributeFieldName(c.targetHandle) && !getHasNodeFooter(targetTemplate)) { + // Node attribute fields are exposed in the node footer. Without a footer there is no handle to attach to and + // no way for the user to see the connection, so the edge would be invisible and - for batch and generator + // nodes, which `buildNodesGraph` drops entirely - have no effect at all. + return 'nodes.cannotConnectToUnavailableNodeSetting'; + } + if (!effectiveSource) { if (sourceNode && isConnectorNode(sourceNode) && c.sourceHandle === CONNECTOR_OUTPUT_HANDLE) { const existingTerminalTargetEdges = getConnectorTerminalTargetEdges(sourceNode.id, nodes, filteredEdges).filter( diff --git a/invokeai/frontend/web/src/features/nodes/types/invocation.ts b/invokeai/frontend/web/src/features/nodes/types/invocation.ts index 4013e2518cd..d60d2b11cbc 100644 --- a/invokeai/frontend/web/src/features/nodes/types/invocation.ts +++ b/invokeai/frontend/web/src/features/nodes/types/invocation.ts @@ -178,10 +178,10 @@ export const zAnyEdge = z.union([zDefaultInvocationNodeEdge, zCollapsedInvocatio export type AnyEdge = z.infer; // #endregion -export const isBatchNodeType = (type: string) => +const isBatchNodeType = (type: string) => ['image_batch', 'string_batch', 'integer_batch', 'float_batch'].includes(type); -export const isGeneratorNodeType = (type: string) => +const isGeneratorNodeType = (type: string) => ['image_generator', 'string_generator', 'integer_generator', 'float_generator'].includes(type); export const isBatchNode = (node: InvocationNode) => isBatchNodeType(node.data.type); @@ -191,6 +191,29 @@ export const isExecutableNode = (node: InvocationNode) => { return !isBatchNode(node) && !isGeneratorNode(node); }; +/** + * True when the node produces an output that lands in the gallery - currently ImageField or VideoField. + * + * The `image` and `video` primitive nodes are excluded because they pass through an existing asset without saving a + * new copy. + */ +const getNodeHasGalleryOutput = (template: InvocationTemplate): boolean => + Object.values(template.outputs).some( + (output) => + (output.type.name === 'ImageField' && template.type !== 'image') || + (output.type.name === 'VideoField' && template.type !== 'video') + ); + +/** + * True when the node renders a footer. + * + * The footer hosts the node attribute fields (`use_cache`, `is_intermediate`), including their connection handles. + * A node without a footer therefore has nowhere to attach an edge for those fields, so this predicate also decides + * whether such a connection may be made at all. + */ +export const getHasNodeFooter = (template: InvocationTemplate): boolean => + !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type) && getNodeHasGalleryOutput(template); + export const getInvocationNodeInputTemplate = ( nodeData: Pick & Partial>, template: InvocationTemplate, diff --git a/invokeai/frontend/web/src/features/nodes/types/nodeAttributeFields.ts b/invokeai/frontend/web/src/features/nodes/types/nodeAttributeFields.ts new file mode 100644 index 00000000000..9895f182653 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/types/nodeAttributeFields.ts @@ -0,0 +1,16 @@ +/** + * Node attribute fields are declared on every invocation by the backend's `BaseInvocation`, but the workflow editor + * treats them differently from ordinary inputs: + * + * - Their value lives on the node itself (`node.data.useCache` / `node.data.isIntermediate`), never in + * `node.data.inputs`. No field instance is ever created for them. + * - They are parsed into invocation templates all the same, because a template entry is what connection validation + * and the connection handle need. + * - They are filtered out of the node's input list and rendered in the node footer instead. + */ +const NODE_ATTRIBUTE_FIELD_NAMES = ['use_cache', 'is_intermediate'] as const; + +export type NodeAttributeFieldName = (typeof NODE_ATTRIBUTE_FIELD_NAMES)[number]; + +export const isNodeAttributeFieldName = (fieldName: string): fieldName is NodeAttributeFieldName => + NODE_ATTRIBUTE_FIELD_NAMES.includes(fieldName as NodeAttributeFieldName); diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts index bfb7b92b18f..c35ff4c08b0 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -175,6 +175,40 @@ export const buildNodeFieldElement = ( return element; }; +const NODE_SETTING_TYPE = 'node-setting'; +export const NODE_SETTING_CLASS_NAME = `form-builder-${NODE_SETTING_TYPE}`; +/** + * Node settings are node-level toggles that are not input fields, so they cannot be represented as node field + * elements. They live directly on the node's data instead of in its inputs. + */ +const zNodeSettingName = z.enum(['use_cache', 'save_to_gallery']); +export type NodeSettingName = z.infer; +const zNodeSettingData = z.object({ + nodeId: z.string().trim().min(1), + setting: zNodeSettingName, + // Unlike node fields, there is nowhere on the node to store a user-provided label, so it is stored on the element. + label: z.string().default(''), +}); +const zNodeSettingElement = zElementBase.extend({ + type: z.literal(NODE_SETTING_TYPE), + data: zNodeSettingData, +}); +export type NodeSettingElement = z.infer; +export const isNodeSettingElement = (el: FormElement): el is NodeSettingElement => el.type === NODE_SETTING_TYPE; +export const buildNodeSettingElement = ( + nodeId: NodeSettingElement['data']['nodeId'], + setting: NodeSettingElement['data']['setting'], + parentId?: NodeSettingElement['parentId'] +): NodeSettingElement => { + const element: NodeSettingElement = { + id: getPrefixedId(NODE_SETTING_TYPE, '-'), + type: NODE_SETTING_TYPE, + parentId, + data: { nodeId, setting, label: '' }, + }; + return element; +}; + const HEADING_TYPE = 'heading'; export const HEADING_CLASS_NAME = `form-builder-${HEADING_TYPE}`; const zHeadingElement = zElementBase.extend({ @@ -262,7 +296,14 @@ export const buildContainer = ( return element; }; -const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElement, zTextElement, zDividerElement]); +const zFormElement = z.union([ + zContainerElement, + zNodeFieldElement, + zNodeSettingElement, + zHeadingElement, + zTextElement, + zDividerElement, +]); export type FormElement = z.infer; diff --git a/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts b/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts index 2af152e6bb8..e2b32d6e081 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts @@ -3,6 +3,7 @@ import { reduce } from 'es-toolkit/compat'; import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; import type { FieldInputInstance } from 'features/nodes/types/field'; import type { InvocationNode, InvocationTemplate } from 'features/nodes/types/invocation'; +import { isNodeAttributeFieldName } from 'features/nodes/types/nodeAttributeFields'; import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; import { v4 as uuidv4 } from 'uuid'; @@ -13,6 +14,13 @@ export const buildInvocationNode = (position: XYPosition, template: InvocationTe const inputs = reduce( template.inputs, (inputsAccumulator, inputTemplate, inputName) => { + if (isNodeAttributeFieldName(inputName)) { + // Node attribute fields have a template so they can be connection targets, but their value lives on the node + // (`data.useCache` / `data.isIntermediate`). Creating an instance here would give them a second, conflicting + // source of truth. `updateNode` derives its allowed keys from this object, so it drops them too. + return inputsAccumulator; + } + const fieldId = uuidv4(); const inputFieldValue: FieldInputInstance = buildFieldInputInstance(fieldId, inputTemplate); diff --git a/invokeai/frontend/web/src/features/nodes/util/node/getSortedFilteredFieldNames.ts b/invokeai/frontend/web/src/features/nodes/util/node/getSortedFilteredFieldNames.ts index 9a64fca1710..c9472c232ab 100644 --- a/invokeai/frontend/web/src/features/nodes/util/node/getSortedFilteredFieldNames.ts +++ b/invokeai/frontend/web/src/features/nodes/util/node/getSortedFilteredFieldNames.ts @@ -1,5 +1,6 @@ import { isNil } from 'es-toolkit/compat'; import type { FieldInputTemplate, FieldOutputTemplate } from 'features/nodes/types/field'; +import { isNodeAttributeFieldName } from 'features/nodes/types/nodeAttributeFields'; export const getSortedFilteredFieldNames = (fields: FieldInputTemplate[] | FieldOutputTemplate[]): string[] => { const visibleFields = fields.filter((field) => !field.ui_hidden); @@ -10,9 +11,9 @@ export const getSortedFilteredFieldNames = (fields: FieldInputTemplate[] | Field .sort((a, b) => (a.ui_order ?? 0) - (b.ui_order ?? 0)); const unorderedFields = visibleFields.filter((f) => isNil(f.ui_order)); - // concat the lists, and return the field names, skipping `is_intermediate` + // concat the lists, and return the field names, skipping node attribute fields - they live in the node footer return orderedFields .concat(unorderedFields) .map((f) => f.name) - .filter((fieldName) => fieldName !== 'is_intermediate'); + .filter((fieldName) => !isNodeAttributeFieldName(fieldName)); }; diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts index 47be2c62ec7..4f80f1f26d3 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts @@ -29,9 +29,11 @@ import { isCollectionFieldType, parseFieldType } from './parseFieldType'; const log = logger('system'); -const RESERVED_INPUT_FIELD_NAMES = ['id', 'type', 'use_cache']; +// `use_cache` and `is_intermediate` are deliberately absent here. They are node attributes, but they are still parsed +// into the template so they can be connection targets - see `nodeAttributeFields.ts`. They never get a field instance +// and are filtered out of the node's input list at render time. +const RESERVED_INPUT_FIELD_NAMES = ['id', 'type']; const RESERVED_OUTPUT_FIELD_NAMES = ['type']; -const RESERVED_FIELD_TYPES = ['IsIntermediate']; const invocationDenylist: string[] = ['graph', 'linear_ui_output']; @@ -45,13 +47,6 @@ const isReservedInputField = (nodeType: string, fieldName: string) => { return false; }; -const isReservedFieldType = (fieldType: string) => { - if (RESERVED_FIELD_TYPES.includes(fieldType)) { - return true; - } - return false; -}; - const isAllowedOutputField = (nodeType: string, fieldName: string) => { if (RESERVED_OUTPUT_FIELD_NAMES.includes(fieldName)) { return false; @@ -147,11 +142,6 @@ export const parseSchema = ( return inputsAccumulator; } - if (isReservedFieldType(fieldType.name)) { - log.trace({ node: type, field: propertyName, schema: parseify(property) }, 'Skipped reserved input field'); - return inputsAccumulator; - } - if (isStatefulFieldType(fieldType) && originalFieldType && !objectEquals(originalFieldType, fieldType)) { fieldType.originalType = deepClone(originalFieldType); } diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts index 7d5abf34e5e..dc678b14c30 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.test.ts @@ -10,7 +10,12 @@ import { } from 'features/nodes/store/util/testUtils'; import type { InvocationTemplate } from 'features/nodes/types/invocation'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; -import { buildNodeFieldElement, getDefaultForm, isNodeFieldElement } from 'features/nodes/types/workflow'; +import { + buildNodeFieldElement, + buildNodeSettingElement, + getDefaultForm, + isNodeFieldElement, +} from 'features/nodes/types/workflow'; import { buildInvocationNode } from 'features/nodes/util/node/buildInvocationNode'; import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'; @@ -504,6 +509,45 @@ describe('validateWorkflow', () => { expect(updatedElement.data.fieldIdentifier.fieldName).toBe('images'); }); + it('should retain node setting form elements whose node still exists', async () => { + const workflow = getWorkflow(); + const element = buildNodeSettingElement('afad11b4-bb5c-45d1-b956-6c8e2357ee11', 'save_to_gallery'); + addElement({ form: workflow.form, element, parentId: workflow.form.rootElementId }); + + const validationResult = await validateWorkflow({ + workflow, + templates: { img_resize, main_model_loader }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + expect(validationResult.workflow.form.elements[element.id]).toBeDefined(); + }); + + it('should delete node setting form elements whose node no longer exists', async () => { + const workflow = getWorkflow(); + const element = buildNodeSettingElement('does-not-exist', 'use_cache'); + addElement({ form: workflow.form, element, parentId: workflow.form.rootElementId }); + + const validationResult = await validateWorkflow({ + workflow, + templates: { img_resize, main_model_loader }, + checkImageAccess: resolveTrue, + checkVideoAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + }); + + const { form } = validationResult.workflow; + expect(form.elements[element.id]).toBeUndefined(); + // The dangling child reference must be cleaned up too, else the form fails structure validation on the next load + const rootElement = form.elements[form.rootElementId]; + expect(rootElement?.type === 'container' && rootElement.data.children).toEqual([]); + expect(validationResult.warnings.length).toBe(1); + }); + it('should refresh call_saved_workflow dynamic inputs while loading a stale serialized workflow', async () => { const workflow = getWorkflow(); const callNode = buildInvocationNode({ x: 0, y: 0 }, call_saved_workflow); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts index 4775b2cfc4d..65fe0285327 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -1,6 +1,10 @@ import { parseify } from 'common/util/serialize'; import { getSavedWorkflowDynamicFields } from 'features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils'; -import { addElement, getIsFormEmpty } from 'features/nodes/components/sidePanel/builder/form-manipulation'; +import { + addElement, + getIsFormEmpty, + removeElement, +} from 'features/nodes/components/sidePanel/builder/form-manipulation'; import { CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX } from 'features/nodes/store/nodesSlice'; import type { Templates } from 'features/nodes/store/types'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; @@ -19,6 +23,7 @@ import { buildNodeFieldElement, getDefaultForm, isNodeFieldElement, + isNodeSettingElement, isWorkflowInvocationNode, } from 'features/nodes/types/workflow'; import { @@ -419,5 +424,21 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === nodeId); + if (!node) { + removeElement({ form: _workflow.form, id: element.id }); + warnings.push({ + message: t('nodes.deletedMissingNodeSettingFormElement', { nodeId, setting }), + data: { nodeId, setting }, + }); + } + } + return { workflow: _workflow, warnings }; }; diff --git a/tests/test_node_graph.py b/tests/test_node_graph.py index 64e652a61d6..1392b0f9fd4 100644 --- a/tests/test_node_graph.py +++ b/tests/test_node_graph.py @@ -14,6 +14,8 @@ ) from invokeai.app.invocations.math import AddInvocation from invokeai.app.invocations.primitives import ( + BooleanInvocation, + BooleanOutput, ColorInvocation, FloatCollectionInvocation, FloatInvocation, @@ -525,6 +527,52 @@ def test_graph_validates(): assert g.is_valid() is True +@pytest.mark.parametrize("field", ["use_cache", "is_intermediate"]) +def test_graph_validates_edges_into_node_attribute_fields(field: str): + # `use_cache` and `is_intermediate` are node attributes, but they are ordinary pydantic fields declaring + # `Input.Any`, so the workflow editor may drive them with an edge. + g = Graph() + n1 = BooleanInvocation(id="1", value=True) + n2 = ESRGANInvocation(id="2") + g.add_node(n1) + g.add_node(n2) + g.add_edge(create_edge("1", "value", "2", field)) + + assert g.is_valid() is True + + +def test_graph_rejects_type_mismatched_edge_into_node_attribute_field(): + g = Graph() + n1 = IntegerInvocation(id="1", value=1) + n2 = ESRGANInvocation(id="2") + g.add_node(n1) + g.add_node(n2) + + with pytest.raises(InvalidEdgeError): + g.add_edge(create_edge("1", "value", "2", "use_cache")) + + +def test_graph_execution_state_applies_node_attribute_edges_before_invoking(): + # The cache is consulted inside `_invoke_internal`, which runs after `next()` has applied edge values, so a + # connected `use_cache` is resolved by the time it is read. + g = Graph() + n1 = BooleanInvocation(id="1", value=False) + n2 = ESRGANInvocation(id="2") + g.add_node(n1) + g.add_node(n2) + g.add_edge(create_edge("1", "value", "2", "use_cache")) + + state = GraphExecutionState(graph=g) + source = state.next() + assert isinstance(source, BooleanInvocation) + state.complete(source.id, BooleanOutput(value=False)) + + prepared = state.next() + assert isinstance(prepared, ESRGANInvocation) + # The node's own default is True; the edge value won before the invocation was handed out + assert prepared.use_cache is False + + def test_graph_invalid_if_edges_reference_missing_nodes(): g = Graph() n1 = TextToImageTestInvocation(id="1", prompt="Banana sushi")