feat(ui): split out workflow redux state

The `nodes` slice is a rather complicated slice. Removing `workflow` makes it a bit more reasonable.

Also helps to flatten state out a bit.
This commit is contained in:
psychedelicious 2023-12-02 23:06:34 +11:00
parent 92bc04dc87
commit 0f32d260b7
16 changed files with 174 additions and 117 deletions

View File

@ -1646,5 +1646,8 @@
"clearWorkflowSearchFilter": "Clear Workflow Search Filter",
"workflowName": "Workflow Name",
"workflowEditorReset": "Workflow Editor Reset"
},
"app": {
"storeNotInitialized": "Store is not initialized"
}
}

View File

@ -11,12 +11,18 @@ export const addEnqueueRequestedNodes = () => {
enqueueRequested.match(action) && action.payload.tabName === 'nodes',
effect: async (action, { getState, dispatch }) => {
const state = getState();
const { nodes, edges } = state.nodes;
const workflow = state.workflow;
const graph = buildNodesGraph(state.nodes);
const workflow = buildWorkflow(state.nodes);
const builtWorkflow = buildWorkflow({
nodes,
edges,
workflow,
});
const batchConfig: BatchConfig = {
batch: {
graph,
workflow,
workflow: builtWorkflow,
runs: state.generation.iterations,
},
prepend: action.payload.prepend,

View File

@ -11,13 +11,11 @@ import {
TypesafeDroppableData,
} from 'features/dnd/types';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
fieldImageValueChanged,
workflowExposedFieldAdded,
} from 'features/nodes/store/nodesSlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '../';
import { workflowExposedFieldAdded } from 'features/nodes/store/workflowSlice';
export const dndDropped = createAction<{
overData: TypesafeDroppableData;

View File

@ -1,5 +1,6 @@
import { Store } from '@reduxjs/toolkit';
import { createStore } from 'app/store/store';
import { atom } from 'nanostores';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const $store = atom<Store<any> | undefined>();
export const $store = atom<
Readonly<ReturnType<typeof createStore>> | undefined
>();

View File

@ -14,6 +14,7 @@ import galleryReducer from 'features/gallery/store/gallerySlice';
import loraReducer from 'features/lora/store/loraSlice';
import modelmanagerReducer from 'features/modelManager/store/modelManagerSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import workflowReducer from 'features/nodes/store/workflowSlice';
import generationReducer from 'features/parameters/store/generationSlice';
import postprocessingReducer from 'features/parameters/store/postprocessingSlice';
import queueReducer from 'features/queue/store/queueSlice';
@ -22,6 +23,7 @@ import configReducer from 'features/system/store/configSlice';
import systemReducer from 'features/system/store/systemSlice';
import hotkeysReducer from 'features/ui/store/hotkeysSlice';
import uiReducer from 'features/ui/store/uiSlice';
import { createStore as createIDBKeyValStore, get, set } from 'idb-keyval';
import dynamicMiddlewares from 'redux-dynamic-middlewares';
import { Driver, rememberEnhancer, rememberReducer } from 'redux-remember';
import { api } from 'services/api';
@ -32,7 +34,6 @@ import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
import { listenerMiddleware } from './middleware/listenerMiddleware';
import { createStore as createIDBKeyValStore, get, set } from 'idb-keyval';
const allReducers = {
canvas: canvasReducer,
@ -52,6 +53,7 @@ const allReducers = {
modelmanager: modelmanagerReducer,
sdxl: sdxlReducer,
queue: queueReducer,
workflow: workflowReducer,
[api.reducerPath]: api.reducer,
};
@ -65,6 +67,7 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'generation',
'sdxl',
'nodes',
'workflow',
'postprocessing',
'system',
'ui',

View File

@ -13,7 +13,7 @@ import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitl
import {
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
} from 'features/nodes/store/nodesSlice';
} from 'features/nodes/store/workflowSlice';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa';
import { MENU_LIST_MOTION_PROPS } from 'theme/components/menu';
@ -41,9 +41,9 @@ const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
() =>
createSelector(
stateSelector,
({ nodes }) => {
({ workflow }) => {
const isExposed = Boolean(
nodes.workflow.exposedFields.find(
workflow.exposedFields.find(
(f) => f.nodeId === nodeId && f.fieldName === fieldName
)
);

View File

@ -10,7 +10,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { workflowExposedFieldRemoved } from 'features/nodes/store/nodesSlice';
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { memo, useCallback } from 'react';
import { FaInfoCircle, FaTrash } from 'react-icons/fa';

View File

@ -5,6 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIInput from 'common/components/IAIInput';
import IAITextarea from 'common/components/IAITextarea';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import {
workflowAuthorChanged,
workflowContactChanged,
@ -13,16 +14,15 @@ import {
workflowNotesChanged,
workflowTagsChanged,
workflowVersionChanged,
} from 'features/nodes/store/nodesSlice';
} from 'features/nodes/store/workflowSlice';
import { ChangeEvent, memo, useCallback } from 'react';
import ScrollableContent from 'features/nodes/components/sidePanel/ScrollableContent';
import { useTranslation } from 'react-i18next';
const selector = createSelector(
stateSelector,
({ nodes }) => {
({ workflow }) => {
const { author, name, description, tags, version, contact, notes } =
nodes.workflow;
workflow;
return {
name,

View File

@ -11,9 +11,9 @@ import { useTranslation } from 'react-i18next';
const selector = createSelector(
stateSelector,
({ nodes }) => {
({ workflow: workflows }) => {
return {
fields: nodes.workflow.exposedFields,
fields: workflows.exposedFields,
};
},
defaultSelectorOptions

View File

@ -5,12 +5,16 @@ import { useMemo } from 'react';
import { useDebounce } from 'use-debounce';
export const useWorkflow = () => {
const nodes = useAppSelector((state: RootState) => state.nodes);
const [debouncedNodes] = useDebounce(nodes, 300);
const workflow = useMemo(
() => buildWorkflow(debouncedNodes),
[debouncedNodes]
const nodes_ = useAppSelector((state: RootState) => state.nodes.nodes);
const edges_ = useAppSelector((state: RootState) => state.nodes.edges);
const workflow_ = useAppSelector((state: RootState) => state.workflow);
const [nodes] = useDebounce(nodes_, 300);
const [edges] = useDebounce(edges_, 300);
const [workflow] = useDebounce(workflow_, 300);
const builtWorkflow = useMemo(
() => buildWorkflow({ nodes, edges, workflow }),
[nodes, edges, workflow]
);
return workflow;
return builtWorkflow;
};

View File

@ -29,7 +29,7 @@ import {
zNodeStatus,
} from 'features/nodes/types/invocation';
import { WorkflowV2 } from 'features/nodes/types/workflow';
import { cloneDeep, forEach, isEqual, uniqBy } from 'lodash-es';
import { cloneDeep, forEach } from 'lodash-es';
import {
addEdge,
applyEdgeChanges,
@ -70,20 +70,6 @@ const initialNodeExecutionState: Omit<NodeExecutionState, 'nodeId'> = {
outputs: [],
};
const INITIAL_WORKFLOW: WorkflowV2 = {
name: '',
author: '',
description: '',
version: '',
contact: '',
tags: '',
notes: '',
nodes: [],
edges: [],
exposedFields: [],
meta: { version: '2.0.0', category: 'user' },
};
export const initialNodesState: NodesState = {
nodes: [],
edges: [],
@ -103,7 +89,7 @@ export const initialNodesState: NodesState = {
nodeOpacity: 1,
selectedNodes: [],
selectedEdges: [],
workflow: INITIAL_WORKFLOW,
// workflow: INITIAL_WORKFLOW,
nodeExecutionStates: {},
viewport: { x: 0, y: 0, zoom: 1 },
mouseOverField: null,
@ -308,23 +294,6 @@ const nodesSlice = createSlice({
}
state.modifyingEdge = false;
},
workflowExposedFieldAdded: (
state,
action: PayloadAction<FieldIdentifier>
) => {
state.workflow.exposedFields = uniqBy(
state.workflow.exposedFields.concat(action.payload),
(field) => `${field.nodeId}-${field.fieldName}`
);
},
workflowExposedFieldRemoved: (
state,
action: PayloadAction<FieldIdentifier>
) => {
state.workflow.exposedFields = state.workflow.exposedFields.filter(
(field) => !isEqual(field, action.payload)
);
},
fieldLabelChanged: (
state,
action: PayloadAction<{
@ -508,9 +477,6 @@ const nodesSlice = createSlice({
},
nodesDeleted: (state, action: PayloadAction<AnyNode[]>) => {
action.payload.forEach((node) => {
state.workflow.exposedFields = state.workflow.exposedFields.filter(
(f) => f.nodeId !== node.id
);
if (!isInvocationNode(node)) {
return;
}
@ -673,7 +639,6 @@ const nodesSlice = createSlice({
nodeEditorReset: (state) => {
state.nodes = [];
state.edges = [];
state.workflow = cloneDeep(INITIAL_WORKFLOW);
},
shouldValidateGraphChanged: (state, action: PayloadAction<boolean>) => {
state.shouldValidateGraph = action.payload;
@ -690,34 +655,8 @@ const nodesSlice = createSlice({
nodeOpacityChanged: (state, action: PayloadAction<number>) => {
state.nodeOpacity = action.payload;
},
workflowNameChanged: (state, action: PayloadAction<string>) => {
state.workflow.name = action.payload;
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.workflow.description = action.payload;
},
workflowTagsChanged: (state, action: PayloadAction<string>) => {
state.workflow.tags = action.payload;
},
workflowAuthorChanged: (state, action: PayloadAction<string>) => {
state.workflow.author = action.payload;
},
workflowNotesChanged: (state, action: PayloadAction<string>) => {
state.workflow.notes = action.payload;
},
workflowVersionChanged: (state, action: PayloadAction<string>) => {
state.workflow.version = action.payload;
},
workflowContactChanged: (state, action: PayloadAction<string>) => {
state.workflow.contact = action.payload;
},
workflowIDChanged: (state, action: PayloadAction<string>) => {
state.workflow.id = action.payload;
},
workflowLoaded: (state, action: PayloadAction<WorkflowV2>) => {
const { nodes, edges, ...workflow } = action.payload;
state.workflow = workflow;
const { nodes, edges } = action.payload;
state.nodes = applyNodeChanges(
nodes.map((node) => ({
item: { ...node, ...SHARED_NODE_PROPERTIES },
@ -740,9 +679,6 @@ const nodesSlice = createSlice({
return acc;
}, {});
},
workflowReset: (state) => {
state.workflow = cloneDeep(INITIAL_WORKFLOW);
},
viewportChanged: (state, action: PayloadAction<Viewport>) => {
state.viewport = action.payload;
},
@ -996,17 +932,7 @@ export const {
shouldSnapToGridChanged,
shouldValidateGraphChanged,
viewportChanged,
workflowAuthorChanged,
workflowContactChanged,
workflowDescriptionChanged,
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
workflowLoaded,
workflowNameChanged,
workflowNotesChanged,
workflowTagsChanged,
workflowVersionChanged,
workflowIDChanged,
edgeAdded,
} = nodesSlice.actions;

View File

@ -29,7 +29,6 @@ export type NodesState = {
shouldColorEdges: boolean;
selectedNodes: string[];
selectedEdges: string[];
workflow: Omit<WorkflowV2, 'nodes' | 'edges'>;
nodeExecutionStates: Record<string, NodeExecutionState>;
viewport: Viewport;
isReady: boolean;
@ -41,3 +40,5 @@ export type NodesState = {
addNewNodePosition: XYPosition | null;
selectionMode: SelectionMode;
};
export type WorkflowsState = Omit<WorkflowV2, 'nodes' | 'edges'>;

View File

@ -0,0 +1,99 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice';
import { WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import { FieldIdentifier } from 'features/nodes/types/field';
import { WorkflowV2 } from 'features/nodes/types/workflow';
import { cloneDeep, isEqual, uniqBy } from 'lodash-es';
export const initialWorkflowState: WorkflowState = {
name: '',
author: '',
description: '',
version: '',
contact: '',
tags: '',
notes: '',
exposedFields: [],
meta: { version: '2.0.0', category: 'user' },
};
const workflowSlice = createSlice({
name: 'workflow',
initialState: initialWorkflowState,
reducers: {
workflowExposedFieldAdded: (
state,
action: PayloadAction<FieldIdentifier>
) => {
state.exposedFields = uniqBy(
state.exposedFields.concat(action.payload),
(field) => `${field.nodeId}-${field.fieldName}`
);
},
workflowExposedFieldRemoved: (
state,
action: PayloadAction<FieldIdentifier>
) => {
state.exposedFields = state.exposedFields.filter(
(field) => !isEqual(field, action.payload)
);
},
workflowNameChanged: (state, action: PayloadAction<string>) => {
state.name = action.payload;
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload;
},
workflowTagsChanged: (state, action: PayloadAction<string>) => {
state.tags = action.payload;
},
workflowAuthorChanged: (state, action: PayloadAction<string>) => {
state.author = action.payload;
},
workflowNotesChanged: (state, action: PayloadAction<string>) => {
state.notes = action.payload;
},
workflowVersionChanged: (state, action: PayloadAction<string>) => {
state.version = action.payload;
},
workflowContactChanged: (state, action: PayloadAction<string>) => {
state.contact = action.payload;
},
workflowIDChanged: (state, action: PayloadAction<string>) => {
state.id = action.payload;
},
workflowLoaded: (state, action: PayloadAction<WorkflowV2>) => {
const { nodes: _nodes, edges: _edges, ...workflow } = action.payload;
return cloneDeep(workflow);
},
workflowReset: () => cloneDeep(initialWorkflowState),
},
extraReducers: (builder) => {
builder.addCase(nodesDeleted, (state, action) => {
action.payload.forEach((node) => {
state.exposedFields = state.exposedFields.filter(
(f) => f.nodeId !== node.id
);
});
});
builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState));
},
});
export const {
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
workflowNameChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,
workflowNotesChanged,
workflowVersionChanged,
workflowContactChanged,
workflowIDChanged,
workflowLoaded,
workflowReset,
} = workflowSlice.actions;
export default workflowSlice.reducer;

View File

@ -1,27 +1,39 @@
import { logger } from 'app/logging/logger';
import { parseify } from 'common/util/serialize';
import { NodesState } from 'features/nodes/store/types';
import {
WorkflowV2,
zWorkflowEdge,
zWorkflowNode,
} from 'features/nodes/types/workflow';
import { fromZodError } from 'zod-validation-error';
import { parseify } from 'common/util/serialize';
import i18n from 'i18next';
import { cloneDeep } from 'lodash-es';
import { fromZodError } from 'zod-validation-error';
export const buildWorkflow = (nodesState: NodesState): WorkflowV2 => {
const workflow = cloneDeep(nodesState.workflow);
const nodes = cloneDeep(nodesState.nodes);
const edges = cloneDeep(nodesState.edges);
type BuildWorkflowArg = {
nodes: NodesState['nodes'];
edges: NodesState['edges'];
workflow: Omit<WorkflowV2, 'nodes' | 'edges'>;
};
type BuildWorkflowFunction = (arg: BuildWorkflowArg) => WorkflowV2;
export const buildWorkflow: BuildWorkflowFunction = ({
nodes,
edges,
workflow,
}) => {
const clonedWorkflow = cloneDeep(workflow);
const clonedNodes = cloneDeep(nodes);
const clonedEdges = cloneDeep(edges);
const newWorkflow: WorkflowV2 = {
...workflow,
...clonedWorkflow,
nodes: [],
edges: [],
};
nodes
clonedNodes
.filter((n) =>
['invocation', 'notes'].includes(n.type ?? '__UNKNOWN_NODE_TYPE__')
)
@ -37,7 +49,7 @@ export const buildWorkflow = (nodesState: NodesState): WorkflowV2 => {
newWorkflow.nodes.push(result.data);
});
edges.forEach((edge) => {
clonedEdges.forEach((edge) => {
const result = zWorkflowEdge.safeParse(edge);
if (!result.success) {
const { message } = fromZodError(result.error, {

View File

@ -1,5 +1,4 @@
import { $store } from 'app/store/nanostores/store';
import { RootState } from 'app/store/store';
import {
WorkflowMigrationError,
WorkflowVersionError,
@ -32,8 +31,12 @@ const zWorkflowMetaVersion = z.object({
* - Workflow schema version bumped to 2.0.0
*/
const migrateV1toV2 = (workflowToMigrate: WorkflowV1): WorkflowV2 => {
const invocationTemplates = ($store.get()?.getState() as RootState).nodes
.nodeTemplates;
const invocationTemplates = $store.get()?.getState().nodes.nodeTemplates;
if (!invocationTemplates) {
throw new Error(t('app.storeNotInitialized'));
}
workflowToMigrate.nodes.forEach((node) => {
if (node.type === 'invocation') {
// Migrate field types
@ -66,6 +69,7 @@ const migrateV1toV2 = (workflowToMigrate: WorkflowV1): WorkflowV2 => {
});
// Bump version
(workflowToMigrate as unknown as WorkflowV2).meta.version = '2.0.0';
// Add category - should always be 'user', 'default' workflows are only created by the backend
(workflowToMigrate as unknown as WorkflowV2).meta.category = 'user';
// Parsing strips out any extra properties not in the latest version
return zWorkflowV2.parse(workflowToMigrate);

View File

@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';
import { FaClone } from 'react-icons/fa';
const SaveWorkflowAsButton = () => {
const currentName = useAppSelector((state) => state.nodes.workflow.name);
const currentName = useAppSelector((state) => state.workflow.name);
const { t } = useTranslation();
const { saveWorkflowAs, isLoading } = useSaveWorkflowAs();
const [name, setName] = useState(currentName.trim());