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
9 changes: 8 additions & 1 deletion invokeai/app/services/shared/workflow_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,14 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph:
for field_name, field_value in inputs.items():
if not isinstance(field_name, str) or not _is_mapping(field_value):
continue
graph_node[field_name] = field_value.get("value")
# Saved workflows may include input metadata for unfilled optional fields without a "value".
# Omit those fields so invocation defaults are applied instead of forcing None.
if "value" in field_value:
# The frontend board picker may persist sentinel strings; graph nodes model both
# automatic and no-board selection as the board field default, None.
if field_name == "board" and field_value["value"] in ("auto", "none"):
continue
graph_node[field_name] = field_value["value"]

parsed_nodes[node_id] = graph_node

Expand Down
2 changes: 1 addition & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2490,7 +2490,7 @@
"noRecentWorkflows": "No Recent Workflows",
"private": "Private",
"shared": "Shared",
"savedWorkflowUnsupportedDescription": "This workflow cannot currently be used by Call Saved Workflow.",
"callable": "Callable",
"savedWorkflowCompatibility": {
"missingWorkflowReturn": "The workflow must contain a Workflow Return node.",
"multipleWorkflowReturn": "The workflow may contain only one Workflow Return node.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ describe('callSavedWorkflowFormUtils', () => {
expect(getRenderableWorkflowForm(workflow, templates)).toBe(form);
});

it('adds exposed fields missing from a non-empty stored form', () => {
const form = getDefaultForm();
const element = buildNodeFieldElement('node-1', 'a', addInputA.type);
form.elements[element.id] = { ...element, parentId: form.rootElementId };
getRootChildren(form).push(element.id);
const workflow = buildWorkflowResponse({
exposedFields: [
{ nodeId: 'node-1', fieldName: 'a' },
{ nodeId: 'node-1', fieldName: 'b' },
],
form,
});

const dynamicFields = getSavedWorkflowDynamicFields(workflow, templates);

expect(dynamicFields.map((field) => field.fieldName)).toEqual([
'saved_workflow_input::node-1::a',
'saved_workflow_input::node-1::b',
]);
});

it('builds a fallback form from exposed fields when the stored form is empty', () => {
const workflow = buildWorkflowResponse({
exposedFields: [{ nodeId: 'node-1', fieldName: 'a' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,82 @@ const buildFormFromExposedFields = (
return form;
};

const getFormFieldKey = (nodeId: string, fieldName: string) => `${nodeId}.${fieldName}`;

const getFormFieldKeys = (form: BuilderForm): Set<string> => {
const fieldKeys = new Set<string>();

for (const element of Object.values(form.elements)) {
if (!element || element.type !== 'node-field') {
continue;
}
const { nodeId, fieldName } = element.data.fieldIdentifier;
fieldKeys.add(getFormFieldKey(nodeId, fieldName));
}

return fieldKeys;
};

const addMissingExposedFieldsToForm = (
form: BuilderForm,
workflow: WorkflowResponse | undefined,
templates: Templates
): BuilderForm => {
const exposedFields = (workflow?.workflow.exposedFields ?? []) as ExposedFieldLike[];
if (exposedFields.length === 0) {
return form;
}

const nodes = getWorkflowNodes(workflow);
const fieldKeys = getFormFieldKeys(form);
const nextForm = structuredClone(form);

for (const { nodeId, fieldName } of exposedFields) {
if (!nodeId || !fieldName || fieldKeys.has(getFormFieldKey(nodeId, fieldName))) {
continue;
}

const node = nodes.find((candidate) => candidate.data?.id === nodeId);
const nodeType = node?.data?.type;
if (!nodeType) {
continue;
}

const fieldTemplate = templates[nodeType]?.inputs[fieldName];
if (!fieldTemplate) {
continue;
}

const element = buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
element.data.showDescription = false;
addElement({
form: nextForm,
element,
parentId: nextForm.rootElementId,
index: getRootChildCount(nextForm),
});
fieldKeys.add(getFormFieldKey(nodeId, fieldName));
}

return nextForm;
};

const getRootChildCount = (form: BuilderForm): number => {
const rootElement = form.elements[form.rootElementId];
if (!rootElement || !isContainerElement(rootElement)) {
return 0;
}
return rootElement.data.children.length;
};

export const getRenderableWorkflowForm = (
workflow: WorkflowResponse | undefined,
templates: Templates
): BuilderForm | null => {
const storedForm = getStoredForm(workflow);

if (storedForm && validateFormStructure(storedForm) && !getIsFormEmpty(storedForm)) {
return storedForm;
return addMissingExposedFieldsToForm(storedForm, workflow, templates);
}

const fallbackForm = buildFormFromExposedFields(workflow, templates);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,14 @@ describe('savedWorkflowFieldUtils', () => {
it('queries owned/default workflows and shared public workflows separately', () => {
expect(getSavedWorkflowPickerOwnedQueryArg('landscape')).toMatchObject({
page: 0,
per_page: 50,
per_page: undefined,
query: 'landscape',
categories: ['user', 'default'],
is_public: undefined,
});
expect(getSavedWorkflowPickerSharedQueryArg('landscape')).toMatchObject({
page: 0,
per_page: 50,
per_page: undefined,
query: 'landscape',
categories: ['user'],
is_public: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
import type { S, WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';

export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
const SAVED_WORKFLOW_PICKER_PAGE_SIZE = 50;
type SavedWorkflowBadge = 'unsupported' | 'default' | 'shared';

type SavedWorkflowSelectionState =
Expand All @@ -24,7 +23,7 @@ export const buildSavedWorkflowOptions = (workflows: WorkflowRecordListItemWithT

const baseSavedWorkflowPickerQueryArg = {
page: 0,
per_page: SAVED_WORKFLOW_PICKER_PAGE_SIZE,
per_page: undefined,
order_by: 'name',
direction: 'ASC',
tags: [] as string[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,25 +133,17 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.shared')}
</Badge>
)}
{listItemState.showUnsupportedBadge && (
<Tooltip
label={
listItemState.unsupportedMessageKey
? t(listItemState.unsupportedMessageKey)
: t('workflows.savedWorkflowUnsupportedDescription')
}
{listItemState.showCallableBadge && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
borderWidth={1}
bg="transparent"
flexShrink={0}
variant="subtle"
>
<Badge
color="warning.300"
borderColor="warning.600"
borderWidth={1}
bg="transparent"
flexShrink={0}
variant="subtle"
>
{t('nodes.savedWorkflowUnsupported')}
</Badge>
</Tooltip>
{t('workflows.callable')}
</Badge>
)}
{listItemState.showDefaultIcon && (
<Image
Expand All @@ -170,13 +162,6 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
<Text variant="subtext" fontSize="xs" noOfLines={3}>
{workflow.description}
</Text>
{listItemState.showUnsupportedBadge && (
<Text variant="subtext" fontSize="xs" noOfLines={2} color="warning.300">
{listItemState.unsupportedMessageKey
? t(listItemState.unsupportedMessageKey)
: t('workflows.savedWorkflowUnsupportedDescription')}
</Text>
)}
{tags.length > 0 && (
<Text fontSize="xs" noOfLines={1}>
<Text as="span" color="base.400">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
callSavedWorkflowDynamicFieldsChanged,
connectorInserted,
fieldIntegerValueChanged,
fieldStringValueChanged,
nodesChanged,
nodesSliceConfig,
} from './nodesSlice';
Expand Down Expand Up @@ -274,6 +275,64 @@ describe('callSavedWorkflowDynamicFieldsChanged', () => {

expect(nextState.edges).toHaveLength(0);
});

it('clears dynamic fields and inbound dynamic field edges when the selected workflow is cleared', () => {
const state = nodesSliceConfig.getInitialState();
const sourceNode = buildNode(addTemplate);
const targetNode = buildNode(callSavedWorkflowTemplate);
const workflowIdInput = targetNode.data.inputs.workflow_id;
if (!workflowIdInput) {
throw new Error('Expected workflow_id input');
}
workflowIdInput.value = 'workflow-1';
state.nodes.push(sourceNode, targetNode);

const fieldName = 'saved_workflow_input::node-1::a';
state.edges.push({
id: 'edge-1',
type: 'default',
source: sourceNode.id,
sourceHandle: 'value',
target: targetNode.id,
targetHandle: fieldName,
});

let nextState = nodesSliceConfig.slice.reducer(
state,
callSavedWorkflowDynamicFieldsChanged({
nodeId: targetNode.id,
fields: [
{
fieldName,
fieldTemplate: buildDynamicIntegerTemplate(fieldName),
label: 'Left Addend',
description: 'The first number',
initialValue: 23,
},
],
edgeIdsToRemove: [],
})
);

nextState = nodesSliceConfig.slice.reducer(
nextState,
fieldStringValueChanged({
nodeId: targetNode.id,
fieldName: 'workflow_id',
value: '',
})
);

const clearedNode = nextState.nodes.find((node) => node.id === targetNode.id);
if (!clearedNode || clearedNode.type !== 'invocation') {
throw new Error('Expected invocation node');
}

expect(clearedNode.data.inputs.workflow_id?.value).toBe('');
expect(clearedNode.data.inputs[fieldName]).toBeUndefined();
expect(clearedNode.data.dynamicInputTemplates[fieldName]).toBeUndefined();
expect(nextState.edges).toHaveLength(0);
});
});

describe('nodesSlice connector actions', () => {
Expand Down
25 changes: 25 additions & 0 deletions invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,28 @@ const fieldValueReducer = <T extends FieldValue>(
field.value = result.data;
};

const clearCallSavedWorkflowDynamicFields = (state: NodesState, nodeId: string) => {
const node = state.nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node) || node.data.type !== 'call_saved_workflow') {
return;
}

for (const fieldName of Object.keys(node.data.inputs)) {
if (fieldName.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)) {
delete node.data.inputs[fieldName];
delete node.data.dynamicInputTemplates[fieldName];
}
}

state.edges = state.edges.filter((edge) => {
return (
edge.type !== 'default' ||
edge.target !== nodeId ||
!edge.targetHandle?.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX)
);
});
};

const slice = createSlice({
name: 'nodes',
initialState: getInitialState(),
Expand Down Expand Up @@ -492,6 +514,9 @@ const slice = createSlice({
},
fieldStringValueChanged: (state, action: FieldValueAction<StringFieldValue>) => {
fieldValueReducer(state, action, zStringFieldValue);
if (action.payload.fieldName === 'workflow_id' && action.payload.value === '') {
clearCallSavedWorkflowDynamicFields(state, action.payload.nodeId);
}
},
fieldStringCollectionValueChanged: (state, action: FieldValueAction<StringFieldCollectionValue>) => {
fieldValueReducer(state, action, zStringFieldCollectionValue);
Expand Down
Loading
Loading