mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-13 22:14:59 -05:00
feat(ui): add conditionally-enabled workflow publishing ui
This is a squash of a lot of scattered commits that became very difficult to clean up and make individually. Sorry. Besides the new UI, there are a number of notable changes: - Publishing logic is disabled in OSS by default. To enable it, provided a `disabledFeatures` prop _without_ "publishWorkflow". - Enqueuing a workflow is no longer handled in a redux listener. It was hard to track the state of the enqueue logic in the listener. It is now in a hook. I did not migrate the canvas and upscaling tabs - their enqueue logic is still in the listener. - When queueing a validation run, the new `useEnqueueWorkflows()` hook will update the payload with the required data for the run. - Some logic is added to the socket event listeners to handle workflow publish runs completing. - The workflow library side nav has a new "published" view. It is hidden when the "publishWorkflow" feature is disabled. - I've added `Safe` and `OrThrow` versions of some workflows hooks. These hooks typically retrieve some data from redux. For example, a node. The `Safe` hooks return the node or null if it cannot be found, while the `OrThrow` hooks return the node or raise if it cannot be found. The `OrThrow` hooks should be used within one of the gate components. These components use the `Safe` hooks and render a fallback if e.g. the node isn't found. This change is required for some of the publish flow UI. - Add support for locking the workflow editor. When locked, you can pan and zoom but that's it. Currently, it is only locked during publish flow and if a published workflow is opened.
This commit is contained in:
@@ -6,8 +6,10 @@ import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsS
|
||||
import { selectIterations } from 'features/controlLayers/store/paramsSlice';
|
||||
import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
|
||||
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
|
||||
import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { NodesState } from 'features/nodes/store/types';
|
||||
import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice';
|
||||
import type { BatchSizeResult } from 'features/nodes/util/node/resolveBatchValue';
|
||||
import { getBatchSize } from 'features/nodes/util/node/resolveBatchValue';
|
||||
import type { Reason } from 'features/queue/store/readiness';
|
||||
@@ -175,6 +177,8 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
|
||||
const { t } = useTranslation();
|
||||
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
|
||||
const [_, enqueueMutation] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions);
|
||||
const isInPublishFlow = useStore($isInPublishFlow);
|
||||
const isPublished = useAppSelector(selectWorkflowIsPublished);
|
||||
|
||||
const text = useMemo(() => {
|
||||
if (enqueueMutation.isLoading) {
|
||||
@@ -183,6 +187,12 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
|
||||
if (isLoadingDynamicPrompts) {
|
||||
return t('dynamicPrompts.loading');
|
||||
}
|
||||
if (isInPublishFlow) {
|
||||
return t('workflows.builder.publishInProgress');
|
||||
}
|
||||
if (isPublished) {
|
||||
return t('workflows.builder.publishedWorkflowIsLocked');
|
||||
}
|
||||
if (isReady) {
|
||||
if (prepend) {
|
||||
return t('queue.queueFront');
|
||||
@@ -190,7 +200,7 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo
|
||||
return t('queue.queueBack');
|
||||
}
|
||||
return t('queue.notReady');
|
||||
}, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isReady, prepend, t]);
|
||||
}, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isInPublishFlow, isPublished, isReady, t, prepend]);
|
||||
|
||||
return <Text fontWeight="semibold">{text}</Text>;
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export const InvokeButton = memo(() => {
|
||||
<QueueIterationsNumberInput />
|
||||
<InvokeButtonTooltip prepend={shift}>
|
||||
<Button
|
||||
onClick={shift ? queue.queueFront : queue.queueBack}
|
||||
onClick={shift ? queue.enqueueFront : queue.enqueueBack}
|
||||
isLoading={queue.isLoading || isLoadingDynamicPrompts}
|
||||
loadingText={invoke}
|
||||
isDisabled={queue.isDisabled}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChakraProps, CollapseProps } from '@invoke-ai/ui-library';
|
||||
import { ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai/ui-library';
|
||||
import { Badge, ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai/ui-library';
|
||||
import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge';
|
||||
import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText';
|
||||
import { useOriginText } from 'features/queue/components/QueueList/useOriginText';
|
||||
@@ -64,6 +64,7 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
|
||||
|
||||
const isCanceled = useMemo(() => ['canceled', 'completed', 'failed'].includes(item.status), [item.status]);
|
||||
const isFailed = useMemo(() => ['canceled', 'failed'].includes(item.status), [item.status]);
|
||||
const isValidationRun = useMemo(() => item.is_api_validation_run === true, [item.is_api_validation_run]);
|
||||
const originText = useOriginText(item.origin);
|
||||
const destinationText = useDestinationText(item.destination);
|
||||
|
||||
@@ -118,6 +119,9 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => {
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems="center" w={COLUMN_WIDTHS.validationRun} flexShrink={0}>
|
||||
{!isValidationRun && <Badge>{t('workflows.builder.publishingValidationRun')}</Badge>}
|
||||
</Flex>
|
||||
<Flex alignItems="center" w={COLUMN_WIDTHS.actions} pe={3}>
|
||||
<ButtonGroup size="xs" variant="ghost">
|
||||
{(!isFailed || !isRetryEnabled) && (
|
||||
|
||||
@@ -7,5 +7,6 @@ export const COLUMN_WIDTHS = {
|
||||
destination: '6rem',
|
||||
batchId: '5rem',
|
||||
fieldValues: 'auto',
|
||||
validationRun: 'auto',
|
||||
actions: 'auto',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { useAppStore } from 'app/store/nanostores/store';
|
||||
import {
|
||||
$outputNodeId,
|
||||
getPublishInputs,
|
||||
selectFieldIdentifiersWithInvocationTypes,
|
||||
} from 'features/nodes/components/sidePanel/workflow/publish';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import { isBatchNode, isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph';
|
||||
import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue';
|
||||
import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { useCallback } from 'react';
|
||||
import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue';
|
||||
import type { Batch, EnqueueBatchArg } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const enqueueRequestedWorkflows = createAction('app/enqueueRequestedWorkflows');
|
||||
|
||||
export const useEnqueueWorkflows = () => {
|
||||
const { getState, dispatch } = useAppStore();
|
||||
const enqueue = useCallback(
|
||||
async (prepend: boolean, isApiValidationRun: boolean) => {
|
||||
dispatch(enqueueRequestedWorkflows());
|
||||
const state = getState();
|
||||
const nodesState = selectNodesSlice(state);
|
||||
const workflow = state.workflow;
|
||||
const templates = $templates.get();
|
||||
const graph = buildNodesGraph(state, templates);
|
||||
const builtWorkflow = buildWorkflowWithValidation({
|
||||
nodes: nodesState.nodes,
|
||||
edges: nodesState.edges,
|
||||
workflow,
|
||||
});
|
||||
|
||||
if (builtWorkflow) {
|
||||
// embedded workflows don't have an id
|
||||
delete builtWorkflow.id;
|
||||
}
|
||||
|
||||
const data: Batch['data'] = [];
|
||||
|
||||
const invocationNodes = nodesState.nodes.filter(isInvocationNode);
|
||||
const batchNodes = invocationNodes.filter(isBatchNode);
|
||||
|
||||
// Handle zipping batch nodes. First group the batch nodes by their batch_group_id
|
||||
const groupedBatchNodes = groupBy(batchNodes, (node) => node.data.inputs['batch_group_id']?.value);
|
||||
|
||||
// Then, we will create a batch data collection item for each group
|
||||
for (const [batchGroupId, batchNodes] of Object.entries(groupedBatchNodes)) {
|
||||
const zippedBatchDataCollectionItems: NonNullable<Batch['data']>[number] = [];
|
||||
|
||||
for (const node of batchNodes) {
|
||||
const value = await resolveBatchValue({ nodesState, node, dispatch });
|
||||
const sourceHandle = node.data.type === 'image_batch' ? 'image' : 'value';
|
||||
const edgesFromBatch = nodesState.edges.filter(
|
||||
(e) => e.source === node.id && e.sourceHandle === sourceHandle
|
||||
);
|
||||
if (batchGroupId !== 'None') {
|
||||
// If this batch node has a batch_group_id, we will zip the data collection items
|
||||
for (const edge of edgesFromBatch) {
|
||||
if (!edge.targetHandle) {
|
||||
break;
|
||||
}
|
||||
zippedBatchDataCollectionItems.push({
|
||||
node_path: edge.target,
|
||||
field_name: edge.targetHandle,
|
||||
items: value,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Otherwise add the data collection items to root of the batch so they are not zipped
|
||||
const productBatchDataCollectionItems: NonNullable<Batch['data']>[number] = [];
|
||||
for (const edge of edgesFromBatch) {
|
||||
if (!edge.targetHandle) {
|
||||
break;
|
||||
}
|
||||
productBatchDataCollectionItems.push({
|
||||
node_path: edge.target,
|
||||
field_name: edge.targetHandle,
|
||||
items: value,
|
||||
});
|
||||
}
|
||||
if (productBatchDataCollectionItems.length > 0) {
|
||||
data.push(productBatchDataCollectionItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, if this batch data collection item has any items, add it to the data array
|
||||
if (batchGroupId !== 'None' && zippedBatchDataCollectionItems.length > 0) {
|
||||
data.push(zippedBatchDataCollectionItems);
|
||||
}
|
||||
}
|
||||
|
||||
const batchConfig: EnqueueBatchArg = {
|
||||
batch: {
|
||||
graph,
|
||||
workflow: builtWorkflow,
|
||||
runs: state.params.iterations,
|
||||
origin: 'workflows',
|
||||
destination: 'gallery',
|
||||
data,
|
||||
},
|
||||
prepend,
|
||||
};
|
||||
|
||||
if (isApiValidationRun) {
|
||||
// Derive the input fields from the builder's selected node field elements
|
||||
const fieldIdentifiers = selectFieldIdentifiersWithInvocationTypes(state);
|
||||
const inputs = getPublishInputs(fieldIdentifiers, templates);
|
||||
const api_input_fields = inputs.publishable.map(({ nodeId, fieldName }) => {
|
||||
return {
|
||||
kind: 'input',
|
||||
node_id: nodeId,
|
||||
field_name: fieldName,
|
||||
} as const;
|
||||
});
|
||||
|
||||
// Derive the output fields from the builder's selected output node
|
||||
const outputNodeId = $outputNodeId.get();
|
||||
assert(outputNodeId !== null, 'Output node not selected');
|
||||
const outputNodeType = selectNodeData(selectNodesSlice(state), outputNodeId).type;
|
||||
const outputNodeTemplate = templates[outputNodeType];
|
||||
assert(outputNodeTemplate, `Template for node type ${outputNodeType} not found`);
|
||||
const outputFieldNames = Object.keys(outputNodeTemplate.outputs);
|
||||
const api_output_fields = outputFieldNames.map((fieldName) => {
|
||||
return {
|
||||
kind: 'output',
|
||||
node_id: outputNodeId,
|
||||
field_name: fieldName,
|
||||
} as const;
|
||||
});
|
||||
|
||||
batchConfig.is_api_validation_run = true;
|
||||
batchConfig.api_input_fields = api_input_fields;
|
||||
batchConfig.api_output_fields = api_output_fields;
|
||||
|
||||
// If the batch is an API validation run, we only want to run it once
|
||||
batchConfig.batch.runs = 1;
|
||||
}
|
||||
|
||||
const req = dispatch(
|
||||
queueApi.endpoints.enqueueBatch.initiate(batchConfig, { ...enqueueMutationFixedCacheKeyOptions, track: false })
|
||||
);
|
||||
|
||||
const enqueueResult = await req.unwrap();
|
||||
return { batchConfig, enqueueResult };
|
||||
},
|
||||
[dispatch, getState]
|
||||
);
|
||||
|
||||
return enqueue;
|
||||
};
|
||||
@@ -1,29 +1,66 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { enqueueRequested } from 'app/store/actions';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { enqueueRequestedCanvas } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
|
||||
import { enqueueRequestedUpscaling } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { withResultAsync } from 'common/util/result';
|
||||
import { parseify } from 'common/util/serialize';
|
||||
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
|
||||
import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows';
|
||||
import { $isReadyToEnqueue } from 'features/queue/store/readiness';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { useCallback } from 'react';
|
||||
import { serializeError } from 'serialize-error';
|
||||
import { enqueueMutationFixedCacheKeyOptions, useEnqueueBatchMutation } from 'services/api/endpoints/queue';
|
||||
|
||||
const log = logger('generation');
|
||||
|
||||
export const useInvoke = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const tabName = useAppSelector(selectActiveTab);
|
||||
const isReady = useStore($isReadyToEnqueue);
|
||||
const isLocked = useIsWorkflowEditorLocked();
|
||||
const enqueueWorkflows = useEnqueueWorkflows();
|
||||
|
||||
const [_, { isLoading }] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions);
|
||||
const queueBack = useCallback(() => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
dispatch(enqueueRequested({ tabName, prepend: false }));
|
||||
}, [dispatch, isReady, tabName]);
|
||||
const queueFront = useCallback(() => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
dispatch(enqueueRequested({ tabName, prepend: true }));
|
||||
}, [dispatch, isReady, tabName]);
|
||||
|
||||
return { queueBack, queueFront, isLoading, isDisabled: !isReady };
|
||||
const enqueue = useCallback(
|
||||
async (prepend: boolean, isApiValidationRun: boolean) => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabName === 'workflows') {
|
||||
const result = await withResultAsync(() => enqueueWorkflows(prepend, isApiValidationRun));
|
||||
if (result.isErr()) {
|
||||
log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch');
|
||||
} else {
|
||||
log.debug(parseify(result.value), 'Enqueued batch');
|
||||
}
|
||||
}
|
||||
|
||||
if (tabName === 'upscaling') {
|
||||
dispatch(enqueueRequestedUpscaling({ prepend }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabName === 'canvas') {
|
||||
dispatch(enqueueRequestedCanvas({ prepend }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Else we are not on a generation tab and should not queue
|
||||
},
|
||||
[dispatch, enqueueWorkflows, isReady, tabName]
|
||||
);
|
||||
|
||||
const enqueueBack = useCallback(() => {
|
||||
enqueue(false, false);
|
||||
}, [enqueue]);
|
||||
|
||||
const enqueueFront = useCallback(() => {
|
||||
enqueue(true, false);
|
||||
}, [enqueue]);
|
||||
|
||||
return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady || isLocked, enqueue };
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { DynamicPromptsState } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
|
||||
import { selectDynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
|
||||
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
|
||||
import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish';
|
||||
import { $templates } from 'features/nodes/store/nodesSlice';
|
||||
import { selectNodesSlice } from 'features/nodes/store/selectors';
|
||||
import type { NodesState, Templates } from 'features/nodes/store/types';
|
||||
@@ -84,7 +85,8 @@ const debouncedUpdateReasons = debounce(
|
||||
templates: Templates,
|
||||
upscale: UpscaleState,
|
||||
config: AppConfig,
|
||||
store: AppStore
|
||||
store: AppStore,
|
||||
isInPublishFlow: boolean
|
||||
) => {
|
||||
if (tab === 'canvas') {
|
||||
const model = selectMainModelConfig(store.getState());
|
||||
@@ -108,6 +110,7 @@ const debouncedUpdateReasons = debounce(
|
||||
workflowSettingsState: workflowSettings,
|
||||
isConnected,
|
||||
templates,
|
||||
isInPublishFlow,
|
||||
});
|
||||
$reasonsWhyCannotEnqueue.set(reasons);
|
||||
} else if (tab === 'upscaling') {
|
||||
@@ -144,6 +147,7 @@ export const useReadinessWatcher = () => {
|
||||
const canvasIsRasterizing = useStore(canvasManager?.stateApi.$isRasterizing ?? $true);
|
||||
const canvasIsSelectingObject = useStore(canvasManager?.stateApi.$isSegmenting ?? $true);
|
||||
const canvasIsCompositing = useStore(canvasManager?.compositor.$isBusy ?? $true);
|
||||
const isInPublishFlow = useStore($isInPublishFlow);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdateReasons(
|
||||
@@ -162,7 +166,8 @@ export const useReadinessWatcher = () => {
|
||||
templates,
|
||||
upscale,
|
||||
config,
|
||||
store
|
||||
store,
|
||||
isInPublishFlow
|
||||
);
|
||||
}, [
|
||||
store,
|
||||
@@ -181,6 +186,7 @@ export const useReadinessWatcher = () => {
|
||||
templates,
|
||||
upscale,
|
||||
workflowSettings,
|
||||
isInPublishFlow,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -192,15 +198,16 @@ const getReasonsWhyCannotEnqueueWorkflowsTab = async (arg: {
|
||||
workflowSettingsState: WorkflowSettingsState;
|
||||
isConnected: boolean;
|
||||
templates: Templates;
|
||||
isInPublishFlow: boolean;
|
||||
}): Promise<Reason[]> => {
|
||||
const { dispatch, nodesState, workflowSettingsState, isConnected, templates } = arg;
|
||||
const { dispatch, nodesState, workflowSettingsState, isConnected, templates, isInPublishFlow } = arg;
|
||||
const reasons: Reason[] = [];
|
||||
|
||||
if (!isConnected) {
|
||||
reasons.push(disconnectedReason(i18n.t));
|
||||
}
|
||||
|
||||
if (workflowSettingsState.shouldValidateGraph) {
|
||||
if (workflowSettingsState.shouldValidateGraph || isInPublishFlow) {
|
||||
const { nodes, edges } = nodesState;
|
||||
const invocationNodes = nodes.filter(isInvocationNode);
|
||||
const batchNodes = invocationNodes.filter(isBatchNode);
|
||||
|
||||
Reference in New Issue
Block a user