Skip to content

Commit 18125ec

Browse files
committed
Add persisted call-saved-workflows node stub
Stacked on top of origin PR invoke-ai#9018 (shared/private workflows and boards) for multiuser workflow visibility semantics.
1 parent c2e7a5d commit 18125ec

9 files changed

Lines changed: 316 additions & 3 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
2+
from invokeai.app.invocations.fields import InputField
3+
from invokeai.app.invocations.primitives import IntegerOutput
4+
from invokeai.app.services.shared.invocation_context import InvocationContext
5+
6+
7+
@invocation(
8+
"call_saved_workflows",
9+
title="Call Saved Workflows",
10+
tags=["workflow", "saved", "library"],
11+
category="workflow",
12+
version="1.0.0",
13+
use_cache=False,
14+
classification=Classification.Beta,
15+
)
16+
class CallSavedWorkflowsInvocation(BaseInvocation):
17+
"""Displays and later executes against the saved workflow library."""
18+
19+
workflow_id: str = InputField(
20+
default="",
21+
description="The selected saved workflow ID, managed by the workflow editor UI.",
22+
ui_hidden=True,
23+
)
24+
25+
def invoke(self, context: InvocationContext) -> IntegerOutput:
26+
return IntegerOutput(value=0)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
2+
import { Badge, Flex, Spinner, Text } from '@invoke-ai/ui-library';
3+
import { EMPTY_ARRAY } from 'app/store/constants';
4+
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
5+
import { memo, useMemo } from 'react';
6+
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
7+
import type { S } from 'services/api/types';
8+
9+
import InvocationNodeHeader from './InvocationNodeHeader';
10+
11+
type Props = {
12+
nodeId: string;
13+
isOpen: boolean;
14+
};
15+
16+
const bodySx: SystemStyleObject = {
17+
flexDirection: 'column',
18+
w: 'full',
19+
h: 'full',
20+
py: 2,
21+
gap: 2,
22+
borderBottomRadius: 'base',
23+
'&[data-is-open="false"]': {
24+
display: 'none',
25+
},
26+
};
27+
28+
const queryArg = {
29+
page: 0,
30+
per_page: 50,
31+
order_by: 'name',
32+
direction: 'ASC',
33+
categories: ['user', 'default'],
34+
query: '',
35+
tags: [],
36+
has_been_opened: undefined,
37+
is_public: undefined,
38+
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[0];
39+
40+
const queryOptions = {
41+
selectFromResult: ({ data, ...rest }) => ({
42+
items: data?.pages.flatMap(({ items }) => items) ?? EMPTY_ARRAY,
43+
...rest,
44+
}),
45+
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[1];
46+
47+
const CallSavedWorkflowsNode = ({ nodeId, isOpen }: Props) => {
48+
const { items, isLoading, isFetching } = useListWorkflowsInfiniteInfiniteQuery(queryArg, queryOptions);
49+
50+
return (
51+
<>
52+
<InvocationNodeHeader nodeId={nodeId} isOpen={isOpen} />
53+
<Flex layerStyle="nodeBody" sx={bodySx} data-is-open={isOpen}>
54+
<Flex flexDir="column" px={2} gap={2} w="full">
55+
<Flex alignItems="center" justifyContent="space-between">
56+
<Text fontSize="sm" fontWeight="semibold">
57+
Saved Workflows
58+
</Text>
59+
<Badge variant="subtle">{items.length}</Badge>
60+
</Flex>
61+
{isLoading ? <LoadingState /> : <WorkflowItems items={items} isFetching={isFetching} />}
62+
</Flex>
63+
</Flex>
64+
</>
65+
);
66+
};
67+
68+
export default memo(CallSavedWorkflowsNode);
69+
70+
const LoadingState = memo(() => {
71+
return (
72+
<Flex alignItems="center" justifyContent="center" minH={24}>
73+
<Spinner size="sm" />
74+
</Flex>
75+
);
76+
});
77+
LoadingState.displayName = 'LoadingState';
78+
79+
const WorkflowItems = memo(
80+
({ items, isFetching }: { items: S['WorkflowRecordListItemWithThumbnailDTO'][]; isFetching: boolean }) => {
81+
const visibleItems = useMemo(() => items.slice(0, 8), [items]);
82+
83+
if (visibleItems.length === 0) {
84+
return <IAINoContentFallback icon={null} label="No saved workflows" fontSize="sm" py={4} />;
85+
}
86+
87+
return (
88+
<Flex flexDir="column" gap={1}>
89+
{visibleItems.map((workflow) => (
90+
<Flex
91+
key={workflow.workflow_id}
92+
alignItems="center"
93+
justifyContent="space-between"
94+
gap={2}
95+
borderRadius="base"
96+
bg="base.800"
97+
px={2}
98+
py={1.5}
99+
>
100+
<Text fontSize="sm" noOfLines={1}>
101+
{workflow.name}
102+
</Text>
103+
<Flex alignItems="center" gap={1} flexShrink={0}>
104+
{workflow.category === 'default' && <Badge variant="subtle">Default</Badge>}
105+
{workflow.is_public && workflow.category !== 'default' && <Badge variant="subtle">Shared</Badge>}
106+
</Flex>
107+
</Flex>
108+
))}
109+
{items.length > visibleItems.length && (
110+
<Text variant="subtext" fontSize="xs">
111+
Showing {visibleItems.length} of {items.length} workflows
112+
</Text>
113+
)}
114+
{isFetching && (
115+
<Text variant="subtext" fontSize="xs">
116+
Updating...
117+
</Text>
118+
)}
119+
</Flex>
120+
);
121+
}
122+
);
123+
WorkflowItems.displayName = 'WorkflowItems';

invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeWrapper.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import { selectNodes } from 'features/nodes/store/selectors';
99
import type { InvocationNodeData } from 'features/nodes/types/invocation';
1010
import { memo, useMemo } from 'react';
1111

12+
import CallSavedWorkflowsNode from './CallSavedWorkflowsNode';
1213
import { InvocationNodeContextProvider } from './context';
14+
import { getInvocationNodeBodyComponentKey } from './getInvocationNodeBodyComponent';
1315
import InvocationNodeUnknownFallback from './InvocationNodeUnknownFallback';
1416

1517
const InvocationNodeWrapper = (props: NodeProps<Node<InvocationNodeData>>) => {
1618
const { data, selected } = props;
1719
const { id: nodeId, type, isOpen, label } = data;
1820
const templates = useStore($templates);
1921
const hasTemplate = useMemo(() => Boolean(templates[type]), [templates, type]);
22+
const bodyComponentKey = useMemo(() => getInvocationNodeBodyComponentKey(type), [type]);
2023
const selectNodeExists = useMemo(
2124
() => createSelector(selectNodes, (nodes) => Boolean(nodes.find((n) => n.id === nodeId))),
2225
[nodeId]
@@ -40,7 +43,11 @@ const InvocationNodeWrapper = (props: NodeProps<Node<InvocationNodeData>>) => {
4043
return (
4144
<InvocationNodeContextProvider nodeId={nodeId}>
4245
<NodeWrapper nodeId={nodeId} selected={selected}>
43-
<InvocationNode nodeId={nodeId} isOpen={isOpen} />
46+
{bodyComponentKey === 'call_saved_workflows' ? (
47+
<CallSavedWorkflowsNode nodeId={nodeId} isOpen={isOpen} />
48+
) : (
49+
<InvocationNode nodeId={nodeId} isOpen={isOpen} />
50+
)}
4451
</NodeWrapper>
4552
</InvocationNodeContextProvider>
4653
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getInvocationNodeBodyComponentKey } from './getInvocationNodeBodyComponent';
4+
5+
describe('getInvocationNodeBodyComponentKey', () => {
6+
it('returns the specialized renderer for call_saved_workflows nodes', () => {
7+
expect(getInvocationNodeBodyComponentKey('call_saved_workflows')).toBe('call_saved_workflows');
8+
});
9+
10+
it('falls back to the default renderer for other invocation nodes', () => {
11+
expect(getInvocationNodeBodyComponentKey('add')).toBe('default');
12+
});
13+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type InvocationNodeBodyComponentKey = 'default' | 'call_saved_workflows';
2+
3+
export const getInvocationNodeBodyComponentKey = (type: string): InvocationNodeBodyComponentKey => {
4+
if (type === 'call_saved_workflows') {
5+
return 'call_saved_workflows';
6+
}
7+
8+
return 'default';
9+
};

invokeai/frontend/web/src/features/nodes/store/util/testUtils.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,49 @@ export const add: InvocationTemplate = {
7272
classification: 'stable',
7373
};
7474

75+
export const call_saved_workflows: InvocationTemplate = {
76+
title: 'Call Saved Workflows',
77+
type: 'call_saved_workflows',
78+
version: '1.0.0',
79+
tags: ['workflow', 'saved', 'library'],
80+
description: 'Displays and later executes against the saved workflow library.',
81+
outputType: 'integer_output',
82+
inputs: {
83+
workflow_id: {
84+
name: 'workflow_id',
85+
title: 'Workflow Id',
86+
required: false,
87+
description: 'The selected saved workflow ID, managed by the workflow editor UI.',
88+
fieldKind: 'input',
89+
input: 'any',
90+
ui_hidden: true,
91+
type: {
92+
name: 'StringField',
93+
cardinality: 'SINGLE',
94+
batch: false,
95+
},
96+
default: '',
97+
},
98+
},
99+
outputs: {
100+
value: {
101+
fieldKind: 'output',
102+
name: 'value',
103+
title: 'Value',
104+
description: 'The output integer',
105+
type: {
106+
name: 'IntegerField',
107+
cardinality: 'SINGLE',
108+
batch: false,
109+
},
110+
ui_hidden: false,
111+
},
112+
},
113+
useCache: false,
114+
nodePack: 'invokeai',
115+
classification: 'beta',
116+
};
117+
75118
export const sub: InvocationTemplate = {
76119
title: 'Subtract Integers',
77120
type: 'sub',
@@ -530,6 +573,7 @@ const iterate: InvocationTemplate = {
530573

531574
export const templates: Templates = {
532575
add,
576+
call_saved_workflows,
533577
sub,
534578
collect,
535579
iterate,
@@ -547,6 +591,63 @@ export const schema = {
547591
},
548592
components: {
549593
schemas: {
594+
CallSavedWorkflowsInvocation: {
595+
properties: {
596+
id: {
597+
type: 'string',
598+
title: 'Id',
599+
description: 'The id of this instance of an invocation. Must be unique among all instances of invocations.',
600+
field_kind: 'node_attribute',
601+
},
602+
is_intermediate: {
603+
type: 'boolean',
604+
title: 'Is Intermediate',
605+
description: 'Whether or not this is an intermediate invocation.',
606+
default: false,
607+
field_kind: 'node_attribute',
608+
ui_type: 'IsIntermediate',
609+
},
610+
use_cache: {
611+
type: 'boolean',
612+
title: 'Use Cache',
613+
description: 'Whether or not to use the cache',
614+
default: false,
615+
field_kind: 'node_attribute',
616+
},
617+
workflow_id: {
618+
type: 'string',
619+
title: 'Workflow Id',
620+
description: 'The selected saved workflow ID, managed by the workflow editor UI.',
621+
default: '',
622+
field_kind: 'input',
623+
input: 'any',
624+
orig_default: '',
625+
orig_required: false,
626+
ui_hidden: true,
627+
},
628+
type: {
629+
type: 'string',
630+
enum: ['call_saved_workflows'],
631+
const: 'call_saved_workflows',
632+
title: 'type',
633+
default: 'call_saved_workflows',
634+
field_kind: 'node_attribute',
635+
},
636+
},
637+
type: 'object',
638+
required: ['type', 'id'],
639+
title: 'Call Saved Workflows',
640+
description: 'Displays and later executes against the saved workflow library.',
641+
category: 'workflow',
642+
classification: 'beta',
643+
node_pack: 'invokeai',
644+
tags: ['workflow', 'saved', 'library'],
645+
version: '1.0.0',
646+
output: {
647+
$ref: '#/components/schemas/IntegerOutput',
648+
},
649+
class: 'invocation',
650+
},
550651
AddInvocation: {
551652
properties: {
552653
id: {

invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { omit, pick } from 'es-toolkit/compat';
2-
import { schema, templates } from 'features/nodes/store/util/testUtils';
2+
import { call_saved_workflows, schema, templates } from 'features/nodes/store/util/testUtils';
33
import { parseSchema } from 'features/nodes/util/schema/parseSchema';
44
import { describe, expect, it } from 'vitest';
55

@@ -18,4 +18,8 @@ describe('parseSchema', () => {
1818
const parsed = parseSchema(schema, ['add']);
1919
expect(stripUndefinedDeep(parsed)).toEqual(stripUndefinedDeep(pick(templates, 'add')));
2020
});
21+
it('should parse the call_saved_workflows node template', () => {
22+
const parsed = parseSchema(schema);
23+
expect(stripUndefinedDeep(parsed.call_saved_workflows)).toEqual(stripUndefinedDeep(call_saved_workflows));
24+
});
2125
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from unittest.mock import Mock
2+
3+
from invokeai.app.services.shared.invocation_context import InvocationContext
4+
5+
6+
def test_call_saved_workflows_invocation_contract():
7+
from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
8+
from invokeai.app.invocations.primitives import IntegerOutput
9+
10+
invocation = CallSavedWorkflowsInvocation(id="test-node")
11+
12+
assert invocation.get_type() == "call_saved_workflows"
13+
assert invocation.workflow_id == ""
14+
15+
output = invocation.invoke(Mock(InvocationContext))
16+
17+
assert isinstance(output, IntegerOutput)
18+
assert output.value == 0
19+
20+
21+
def test_call_saved_workflows_invocation_schema_hides_editor_managed_fields():
22+
from invokeai.app.invocations.call_saved_workflows import CallSavedWorkflowsInvocation
23+
24+
schema = CallSavedWorkflowsInvocation.model_json_schema()
25+
workflow_id = schema["properties"]["workflow_id"]
26+
27+
assert workflow_id["default"] == ""
28+
assert workflow_id["ui_hidden"] is True
29+
assert workflow_id["input"] == "any"

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
2424
from invokeai.app.services.invoker import Invoker
2525
from invokeai.app.services.users.users_default import UserService
26+
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
2627
from invokeai.backend.util.logging import InvokeAILogger
2728
from tests.backend.model_manager.model_manager_fixtures import * # noqa: F403
2829
from tests.fixtures.sqlite_database import create_mock_sqlite_database # noqa: F401
@@ -57,7 +58,7 @@ def mock_services() -> InvocationServices:
5758
session_processor=None, # type: ignore
5859
session_queue=None, # type: ignore
5960
urls=None, # type: ignore
60-
workflow_records=None, # type: ignore
61+
workflow_records=SqliteWorkflowRecordsStorage(db=db),
6162
tensors=None, # type: ignore
6263
conditioning=None, # type: ignore
6364
style_preset_records=None, # type: ignore

0 commit comments

Comments
 (0)