Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions invokeai/app/invocations/baseinvocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
isDividerElement,
isHeadingElement,
isNodeFieldElement,
isNodeSettingElement,
isTextElement,
ROOT_CONTAINER_CLASS_NAME,
} from 'features/nodes/types/workflow';
Expand Down Expand Up @@ -241,6 +242,11 @@ const FormElementComponentPreview = memo(({ id, elements }: { id: string; elemen
return <WorkflowFieldRenderer el={el} />;
}

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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
className={DRAG_HANDLE_CLASSNAME}
layerStyle="nodeFooter"
w="full"
borderBottomRadius="base"
gap={4}
px={2}
py={0}
h={8}
justifyContent="space-between"
>
<FormControlGroup formControlProps={props} formLabelProps={props}>
{isExecutableNode && <UseCacheCheckbox nodeId={nodeId} />}
{isExecutableNode && hasGalleryOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
</FormControlGroup>
<Flex className={DRAG_HANDLE_CLASSNAME} layerStyle="nodeFooter" sx={sx}>
<NodeSettingFooterControl nodeId={nodeId} setting="use_cache" />
<NodeSettingFooterControl nodeId={nodeId} setting="save_to_gallery" />
</Flex>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ? <PiMinusBold /> : <PiPlusBold />;
}, [isAddedToRoot]);

const onClick = useCallback(() => {
return isAddedToRoot ? removeNodeSettingFromRoot() : addNodeSettingToRoot();
}, [isAddedToRoot, addNodeSettingToRoot, removeNodeSettingFromRoot]);

return (
<IconButton
className={`${NO_DRAG_CLASS} node-setting-action-button`}
variant="ghost"
tooltip={description}
aria-label={description}
icon={icon}
pointerEvents="auto"
size="xs"
onClick={onClick}
/>
);
});

NodeSettingAddRemoveFormRoot.displayName = 'NodeSettingAddRemoveFormRoot';
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(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 (
<Flex ref={draggableRef} sx={sx} data-is-dragging={isDragging}>
<FormControl className={`${NO_FIT_ON_DOUBLE_CLICK_CLASS} ${NO_PAN_CLASS}`} sx={formControlSx}>
<Flex className={NO_DRAG_CLASS} ref={dragHandleRef}>
<FormLabel m={0}>{label}</FormLabel>
</Flex>
<Spacer />
<NodeSettingAddRemoveFormRoot nodeId={nodeId} setting={setting} />
{!isConnected && <Checkbox className={NO_PAN_CLASS} onChange={onChange} isChecked={isChecked} />}
</FormControl>
{fieldTemplate && <InputFieldHandle nodeId={nodeId} fieldName={fieldName} />}
</Flex>
);
});

NodeSettingFooterControl.displayName = 'NodeSettingFooterControl';

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,7 @@ import {
isDividerElement,
isHeadingElement,
isNodeFieldElement,
isNodeSettingElement,
isTextElement,
ROOT_CONTAINER_CLASS_NAME,
} from 'features/nodes/types/workflow';
Expand Down Expand Up @@ -308,6 +310,10 @@ const FormElementComponent = memo(({ id }: { id: string }) => {
return <NodeFieldElement key={id} id={id} />;
}

if (isNodeSettingElement(el)) {
return <NodeSettingElement key={id} id={id} />;
}

if (isDividerElement(el)) {
return <DividerElement key={id} id={id} />;
}
Expand Down
Loading
Loading