diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py index a32d9d01f68..930737a40fe 100644 --- a/invokeai/app/services/shared/workflow_graph_builder.py +++ b/invokeai/app/services/shared/workflow_graph_builder.py @@ -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 the "auto" sentinel; graph nodes model automatic + # board selection as the board field default, None. + if field_name == "board" and field_value["value"] == "auto": + continue + graph_node[field_name] = field_value["value"] parsed_nodes[node_id] = graph_node diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 7f4f88f8bb4..d6963c1d66e 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2489,6 +2489,7 @@ "noRecentWorkflows": "No Recent Workflows", "private": "Private", "shared": "Shared", + "callable": "Callable", "savedWorkflowUnsupportedDescription": "This workflow cannot currently be used by Call Saved Workflow.", "savedWorkflowCompatibility": { "missingWorkflowReturn": "The workflow must contain a Workflow Return node.", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts index 68290298bb5..4941e14579f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.test.ts @@ -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' }], diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts index fbc600f85c2..eafd546ac55 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/callSavedWorkflowFormUtils.ts @@ -105,6 +105,74 @@ const buildFormFromExposedFields = ( return form; }; +const getFormFieldKey = (nodeId: string, fieldName: string) => `${nodeId}.${fieldName}`; + +const getFormFieldKeys = (form: BuilderForm): Set => { + const fieldKeys = new Set(); + + 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 @@ -112,7 +180,7 @@ export const getRenderableWorkflowForm = ( const storedForm = getStoredForm(workflow); if (storedForm && validateFormStructure(storedForm) && !getIsFormEmpty(storedForm)) { - return storedForm; + return addMissingExposedFieldsToForm(storedForm, workflow, templates); } const fallbackForm = buildFormFromExposedFields(workflow, templates); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx index 468a69db256..be5bbc2632c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -133,6 +133,18 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi {t('workflows.shared')} )} + {listItemState.showCallableBadge && ( + + {t('workflows.callable')} + + )} {listItemState.showUnsupportedBadge && ( { } expect(updatedElement.data.fieldIdentifier.fieldName).toBe('images'); }); + + 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); + const workflowIdInput = callNode.data.inputs.workflow_id; + if (!workflowIdInput) { + throw new Error('Expected workflow_id input'); + } + workflowIdInput.value = 'saved-workflow-1'; + const addInputA = add.inputs.a; + if (!addInputA) { + throw new Error('Expected add.a input template'); + } + const oldFieldName = 'saved_workflow_input::child-add::a'; + const oldFieldTemplate = structuredClone(addInputA); + oldFieldTemplate.name = oldFieldName; + oldFieldTemplate.title = 'A'; + oldFieldTemplate.input = 'any'; + oldFieldTemplate.ui_hidden = false; + callNode.data.dynamicInputTemplates[oldFieldName] = oldFieldTemplate; + callNode.data.inputs[oldFieldName] = buildFieldInputInstance(oldFieldName, oldFieldTemplate); + callNode.data.inputs[oldFieldName]!.value = 99; + workflow.nodes = [callNode]; + + const validationResult = await validateWorkflow({ + workflow, + templates: { add, call_saved_workflow }, + checkImageAccess: resolveTrue, + checkBoardAccess: resolveTrue, + checkModelAccess: resolveTrue, + getWorkflow: (workflowId) => { + expect(workflowId).toBe('saved-workflow-1'); + return Promise.resolve({ + workflow_id: 'saved-workflow-1', + name: 'Saved workflow', + created_at: '2026-04-08T00:00:00Z', + updated_at: '2026-04-08T00:00:00Z', + opened_at: null, + user_id: 'user-1', + is_public: false, + thumbnail_url: null, + workflow: { + id: 'saved-workflow-1', + name: 'Saved workflow', + author: '', + description: '', + version: '', + contact: '', + tags: '', + notes: '', + exposedFields: [ + { nodeId: 'child-add', fieldName: 'a' }, + { nodeId: 'child-add', fieldName: 'b' }, + ], + meta: { version: '4.0.0', category: 'user' }, + form: getDefaultForm(), + nodes: [ + { + id: 'child-add', + type: 'invocation', + data: { + id: 'child-add', + type: 'add', + version: '1.0.1', + label: '', + notes: '', + dynamicInputTemplates: {}, + isOpen: true, + isIntermediate: true, + useCache: true, + nodePack: 'invokeai', + inputs: { + a: { name: 'a', label: 'A', description: '', value: 1 }, + b: { name: 'b', label: 'B', description: '', value: 2 }, + }, + }, + position: { x: 0, y: 0 }, + }, + ], + edges: [], + }, + }); + }, + }); + + const refreshedCallNode = validationResult.workflow.nodes[0]; + if (!refreshedCallNode || refreshedCallNode.type !== 'invocation') { + throw new Error('Expected invocation node'); + } + expect(refreshedCallNode.data.inputs['saved_workflow_input::child-add::a']?.value).toBe(99); + expect(refreshedCallNode.data.inputs['saved_workflow_input::child-add::b']?.value).toBe(2); + expect(refreshedCallNode.data.dynamicInputTemplates['saved_workflow_input::child-add::b']).toBeDefined(); + }); }); 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 2e2ab16bba6..3b1752635bf 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts @@ -1,5 +1,7 @@ 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 { 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'; import { @@ -23,6 +25,7 @@ import { getUpdatedFieldName, updateNode, } from 'features/nodes/util/node/nodeUpdate'; +import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance'; import { t } from 'i18next'; import type { JsonObject } from 'type-fest'; @@ -40,6 +43,7 @@ type ValidateWorkflowArgs = { checkImageAccess: (name: string) => Promise; checkBoardAccess: (id: string) => Promise; checkModelAccess: (key: string) => Promise; + getWorkflow?: (workflowId: string) => Promise[0]>>; }; type ValidateWorkflowResult = { @@ -47,6 +51,80 @@ type ValidateWorkflowResult = { warnings: WorkflowWarning[]; }; +const refreshCallSavedWorkflowDynamicInputs = async ({ + workflow, + templates, + getWorkflow, + warnings, +}: { + workflow: WorkflowV3; + templates: Templates; + getWorkflow: ValidateWorkflowArgs['getWorkflow']; + warnings: WorkflowWarning[]; +}) => { + if (!getWorkflow) { + return; + } + + for (const node of workflow.nodes) { + if (!isWorkflowInvocationNode(node) || node.data.type !== 'call_saved_workflow') { + continue; + } + + const workflowId = node.data.inputs.workflow_id?.value; + if (typeof workflowId !== 'string' || !workflowId) { + continue; + } + + try { + const savedWorkflow = await getWorkflow(workflowId); + const fields = getSavedWorkflowDynamicFields(savedWorkflow, templates); + const nextFieldNames = new Set(fields.map((field) => field.fieldName)); + + for (const fieldName of Object.keys(node.data.inputs)) { + if (fieldName.startsWith(CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX) && !nextFieldNames.has(fieldName)) { + delete node.data.inputs[fieldName]; + delete node.data.dynamicInputTemplates[fieldName]; + } + } + + for (const { fieldName, fieldTemplate, label, description, initialValue } of fields) { + const existingTemplate = node.data.dynamicInputTemplates[fieldName]; + node.data.dynamicInputTemplates[fieldName] = fieldTemplate; + const existing = node.data.inputs[fieldName]; + if (existing) { + if ( + existingTemplate?.type.name !== fieldTemplate.type.name || + existingTemplate?.type.cardinality !== fieldTemplate.type.cardinality || + existingTemplate?.type.batch !== fieldTemplate.type.batch + ) { + const instance = buildFieldInputInstance(fieldName, fieldTemplate); + instance.label = label; + instance.description = description; + instance.value = initialValue; + node.data.inputs[fieldName] = instance; + continue; + } + existing.label = label; + existing.description = description; + continue; + } + + const instance = buildFieldInputInstance(fieldName, fieldTemplate); + instance.label = label; + instance.description = description; + instance.value = initialValue; + node.data.inputs[fieldName] = instance; + } + } catch { + warnings.push({ + message: t('toast.problemRetrievingWorkflow'), + data: { workflowId }, + }); + } + } +}; + /** * Parses and validates a workflow: * - Parses the workflow schema, and migrates it to the latest version if necessary. @@ -57,7 +135,7 @@ type ValidateWorkflowResult = { * @throws {z.ZodError} If there is a validation error. */ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise => { - const { workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess } = args; + const { workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess, getWorkflow } = args; // Parse the raw workflow data & migrate it to the latest version const _workflow = parseAndMigrateWorkflow(workflow); @@ -66,6 +144,8 @@ export const validateWorkflow = async (args: ValidateWorkflowArgs): Promise id === edge.source); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts index d2c7ff2a913..7fcfe26b4bd 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useValidateAndLoadWorkflow.ts @@ -14,6 +14,7 @@ import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared' import { t } from 'i18next'; import { useCallback } from 'react'; import { serializeError } from 'serialize-error'; +import { workflowsApi } from 'services/api/endpoints/workflows'; import { checkBoardAccess, checkImageAccess, checkModelAccess } from 'services/api/hooks/accessChecks'; import { z } from 'zod'; import { fromZodError } from 'zod-validation-error'; @@ -57,6 +58,10 @@ export const useValidateAndLoadWorkflow = () => { checkImageAccess, checkBoardAccess, checkModelAccess, + getWorkflow: (workflowId) => + dispatch( + workflowsApi.endpoints.getWorkflow.initiate(workflowId, { forceRefetch: true, subscribe: false }) + ).unwrap(), }); if (origin !== 'library') { diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts index 9f7e680b047..d78d0ddd928 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { getWorkflowLibraryListItemState } from './workflowLibraryListItemState'; describe('workflowLibraryListItemState', () => { - it('marks unsupported workflows with their localized reason key', () => { + it('does not mark ordinary workflows unsupported when they are not callable as sub-workflows', () => { expect( getWorkflowLibraryListItemState({ category: 'user', @@ -15,8 +15,29 @@ describe('workflowLibraryListItemState', () => { }, }) ).toEqual({ - showUnsupportedBadge: true, - unsupportedMessageKey: 'workflows.savedWorkflowCompatibility.missingWorkflowReturn', + showUnsupportedBadge: false, + unsupportedMessageKey: null, + showCallableBadge: false, + showSharedBadge: false, + showDefaultIcon: false, + }); + }); + + it('marks callable workflows with a positive callable badge', () => { + expect( + getWorkflowLibraryListItemState({ + category: 'user', + is_public: false, + call_saved_workflow_compatibility: { + is_callable: true, + reason: 'ok', + message: null, + }, + }) + ).toEqual({ + showUnsupportedBadge: false, + unsupportedMessageKey: null, + showCallableBadge: true, showSharedBadge: false, showDefaultIcon: false, }); @@ -36,6 +57,7 @@ describe('workflowLibraryListItemState', () => { ).toEqual({ showUnsupportedBadge: false, unsupportedMessageKey: null, + showCallableBadge: true, showSharedBadge: true, showDefaultIcon: false, }); @@ -55,6 +77,7 @@ describe('workflowLibraryListItemState', () => { ).toEqual({ showUnsupportedBadge: false, unsupportedMessageKey: null, + showCallableBadge: true, showSharedBadge: false, showDefaultIcon: true, }); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts index 13fcf429468..c086583ed6e 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/util/workflowLibraryListItemState.ts @@ -1,12 +1,9 @@ -import { - getWorkflowCallCompatibilityState, - type WorkflowCallCompatibilityMessageKey, -} from 'features/workflowLibrary/util/workflowCallCompatibility'; import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types'; type WorkflowLibraryListItemState = { showUnsupportedBadge: boolean; - unsupportedMessageKey: WorkflowCallCompatibilityMessageKey | null; + unsupportedMessageKey: null; + showCallableBadge: boolean; showSharedBadge: boolean; showDefaultIcon: boolean; }; @@ -14,11 +11,10 @@ type WorkflowLibraryListItemState = { export const getWorkflowLibraryListItemState = ( workflow: Pick ): WorkflowLibraryListItemState => { - const compatibilityState = getWorkflowCallCompatibilityState(workflow); - return { - showUnsupportedBadge: compatibilityState.isUnsupported, - unsupportedMessageKey: compatibilityState.messageKey, + showUnsupportedBadge: false, + unsupportedMessageKey: null, + showCallableBadge: workflow.call_saved_workflow_compatibility?.is_callable === true, showSharedBadge: workflow.is_public && workflow.category !== 'default', showDefaultIcon: workflow.category === 'default', }; diff --git a/tests/app/services/test_workflow_graph_builder.py b/tests/app/services/test_workflow_graph_builder.py index 071f55e46f8..67b1a3d65b2 100644 --- a/tests/app/services/test_workflow_graph_builder.py +++ b/tests/app/services/test_workflow_graph_builder.py @@ -172,6 +172,57 @@ def test_build_graph_from_workflow_flattens_connector_edges(): assert graph.nodes["return-1"].values == [] +def test_build_graph_from_workflow_uses_defaults_for_inputs_without_saved_values(): + collect_node = _build_workflow_node("return-collect-1", "collect", {}) + collect_node["data"]["inputs"] = { + "item": {"name": "item", "label": "", "description": ""}, + "collection": {"name": "collection", "label": "", "description": ""}, + } + workflow = _build_workflow( + nodes=[ + _build_workflow_node("return-value-1", "workflow_return_value", {"key": "result", "value": None}), + collect_node, + _build_workflow_node("return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-return-collect", + "type": "default", + "source": "return-value-1", + "sourceHandle": "value", + "target": "return-collect-1", + "targetHandle": "item", + }, + { + "id": "edge-return-values", + "type": "default", + "source": "return-collect-1", + "sourceHandle": "collection", + "target": "return-1", + "targetHandle": "values", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert graph.nodes["return-collect-1"].collection == [] + + +def test_build_graph_from_workflow_uses_default_for_legacy_auto_board_values(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("image-1", "blank_image", {"board": "auto", "width": 64, "height": 64}), + *_build_named_return_nodes(), + ], + edges=_build_named_return_edges("image-1", "image"), + ) + + graph = build_graph_from_workflow(workflow) + + assert graph.nodes["image-1"].board is None + + def test_build_graph_from_workflow_rejects_batch_special_nodes_with_clear_error(): workflow = _build_workflow( nodes=[_build_workflow_node("image-batch-1", "image_batch", {"images": []})],