diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 6c915b35a43..9331b94d4b3 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -10,6 +10,7 @@ QUEUE_ITEM_STATUS, Batch, BatchStatus, + CancelAllExceptCurrentResult, CancelByBatchIDsResult, CancelByDestinationResult, ClearResult, @@ -94,6 +95,18 @@ async def Pause( return ApiDependencies.invoker.services.session_processor.pause() +@session_queue_router.put( + "/{queue_id}/cancel_all_except_current", + operation_id="cancel_all_except_current", + responses={200: {"model": CancelAllExceptCurrentResult}}, +) +async def cancel_all_except_current( + queue_id: str = Path(description="The queue id to perform this operation on"), +) -> CancelAllExceptCurrentResult: + """Immediately cancels all queue items except in-processing items""" + return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id) + + @session_queue_router.put( "/{queue_id}/cancel_by_batch_ids", operation_id="cancel_by_batch_ids", diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 70f6c697fdf..b080d3fdb7b 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -5,6 +5,7 @@ QUEUE_ITEM_STATUS, Batch, BatchStatus, + CancelAllExceptCurrentResult, CancelByBatchIDsResult, CancelByDestinationResult, CancelByQueueIDResult, @@ -112,6 +113,11 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: """Cancels all queue items with matching queue ID""" pass + @abstractmethod + def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult: + """Cancels all queue items except in-progress items""" + pass + @abstractmethod def list_queue_items( self, diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d43688efad4..8b37c4f8c43 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -366,6 +366,12 @@ class CancelByQueueIDResult(CancelByBatchIDsResult): pass +class CancelAllExceptCurrentResult(CancelByBatchIDsResult): + """Result of canceling all except current""" + + pass + + class IsEmptyResult(BaseModel): """Result of checking if the session queue is empty""" diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 5c631b70494..0a646399fef 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -9,6 +9,7 @@ QUEUE_ITEM_STATUS, Batch, BatchStatus, + CancelAllExceptCurrentResult, CancelByBatchIDsResult, CancelByDestinationResult, CancelByQueueIDResult, @@ -510,6 +511,39 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: self.__lock.release() return CancelByQueueIDResult(canceled=count) + def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult: + try: + where = """--sql + WHERE + queue_id == ? + AND status == 'pending' + """ + self.__lock.acquire() + self.__cursor.execute( + f"""--sql + SELECT COUNT(*) + FROM session_queue + {where}; + """, + (queue_id,), + ) + count = self.__cursor.fetchone()[0] + self.__cursor.execute( + f"""--sql + UPDATE session_queue + SET status = 'canceled' + {where}; + """, + (queue_id,), + ) + self.__conn.commit() + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + return CancelAllExceptCurrentResult(canceled=count) + def get_queue_item(self, item_id: int) -> SessionQueueItem: try: self.__lock.acquire() diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 10197fef1ff..96bd9bce291 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -209,9 +209,13 @@ "pauseSucceeded": "Processor Paused", "pauseFailed": "Problem Pausing Processor", "cancel": "Cancel", + "cancelAllExceptCurrentQueueItemAlertDialog": "Canceling all queue items except the current one will stop pending items but allow the in-progress one to finish.", + "cancelAllExceptCurrentQueueItemAlertDialog2": "Are you sure you want to cancel all pending queue items?", + "cancelAllExceptCurrentTooltip": "Cancel All Except Current Item", "cancelTooltip": "Cancel Current Item", "cancelSucceeded": "Item Canceled", "cancelFailed": "Problem Canceling Item", + "confirm": "Confirm", "prune": "Prune", "pruneTooltip": "Prune {{item_count}} Completed Items", "pruneSucceeded": "Pruned {{item_count}} Completed Items from Queue", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 2902c344adb..7bef8d59a6d 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -23,6 +23,7 @@ import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModa import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal'; +import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog'; import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal'; @@ -97,6 +98,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { + diff --git a/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx new file mode 100644 index 00000000000..cdaaebcdf0e --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx @@ -0,0 +1,47 @@ +import { ConfirmationAlertDialog, Text } from '@invoke-ai/ui-library'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { buildUseBoolean } from 'common/hooks/useBoolean'; +import { useCancelAllExceptCurrentQueueItem } from 'features/queue/hooks/useCancelAllExceptCurrentQueueItem'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const [useCancelAllExceptCurrentQueueItemConfirmationAlertDialog] = buildUseBoolean(false); + +export const useCancelAllExceptCurrentQueueItemDialog = () => { + const dialog = useCancelAllExceptCurrentQueueItemConfirmationAlertDialog(); + const { cancelAllExceptCurrentQueueItem, isLoading, isDisabled, queueStatus } = useCancelAllExceptCurrentQueueItem(); + + return { + cancelAllExceptCurrentQueueItem, + isOpen: dialog.isTrue, + openDialog: dialog.setTrue, + closeDialog: dialog.setFalse, + isLoading, + queueStatus, + isDisabled, + }; +}; + +export const CancelAllExceptCurrentQueueItemConfirmationAlertDialog = memo(() => { + useAssertSingleton('CancelAllExceptCurrentQueueItemConfirmationAlertDialog'); + const { t } = useTranslation(); + const cancelAllExceptCurrentQueueItem = useCancelAllExceptCurrentQueueItemDialog(); + + return ( + + {t('queue.cancelAllExceptCurrentQueueItemAlertDialog')} +
+ {t('queue.cancelAllExceptCurrentQueueItemAlertDialog2')} +
+ ); +}); + +CancelAllExceptCurrentQueueItemConfirmationAlertDialog.displayName = + 'CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx index 27672cec839..2a9f89b0f33 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx @@ -1,6 +1,7 @@ import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { SessionMenuItems } from 'common/components/SessionMenuItems'; +import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { QueueCountBadge } from 'features/queue/components/QueueCountBadge'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; @@ -10,7 +11,15 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiListBold, PiPauseFill, PiPlayFill, PiQueueBold, PiTrashSimpleBold, PiXBold } from 'react-icons/pi'; +import { + PiListBold, + PiPauseFill, + PiPlayFill, + PiQueueBold, + PiTrashSimpleBold, + PiXBold, + PiXCircle, +} from 'react-icons/pi'; export const QueueActionsMenuButton = memo(() => { const ref = useRef(null); @@ -18,6 +27,7 @@ export const QueueActionsMenuButton = memo(() => { const { t } = useTranslation(); const isPauseEnabled = useFeatureStatus('pauseQueue'); const isResumeEnabled = useFeatureStatus('resumeQueue'); + const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); const cancelCurrent = useCancelCurrentQueueItem(); const clearQueue = useClearQueueDialog(); const { @@ -52,6 +62,15 @@ export const QueueActionsMenuButton = memo(() => { > {t('queue.cancelTooltip')} + } + onClick={cancelAllExceptCurrent.openDialog} + isLoading={cancelAllExceptCurrent.isLoading} + isDisabled={cancelAllExceptCurrent.isDisabled} + > + {t('queue.cancelAllExceptCurrentTooltip')} + } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts new file mode 100644 index 00000000000..3d388915457 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts @@ -0,0 +1,48 @@ +import { useStore } from '@nanostores/react'; +import { toast } from 'features/toast/toast'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCancelAllExceptCurrentMutation, useGetQueueStatusQuery } from 'services/api/endpoints/queue'; +import { $isConnected } from 'services/events/stores'; + +export const useCancelAllExceptCurrentQueueItem = () => { + const { t } = useTranslation(); + const { data: queueStatus } = useGetQueueStatusQuery(); + const isConnected = useStore($isConnected); + const [trigger, { isLoading }] = useCancelAllExceptCurrentMutation({ + fixedCacheKey: 'cancelAllExceptCurrent', + }); + + const cancelAllExceptCurrentQueueItem = useCallback(async () => { + if (!queueStatus?.queue.pending) { + return; + } + + try { + await trigger().unwrap(); + toast({ + id: 'QUEUE_CANCEL_SUCCEEDED', + title: t('queue.cancelSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_CANCEL_FAILED', + title: t('queue.cancelFailed'), + status: 'error', + }); + } + }, [queueStatus?.queue.pending, trigger, t]); + + const isDisabled = useMemo( + () => !isConnected || !queueStatus?.queue.pending, + [isConnected, queueStatus?.queue.pending] + ); + + return { + cancelAllExceptCurrentQueueItem, + isLoading, + queueStatus, + isDisabled, + }; +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index c2af0d8aa9e..05e5862ee74 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -348,6 +348,25 @@ export const queueApi = api.injectEndpoints({ return ['SessionQueueStatus', 'BatchStatus', { type: 'QueueCountsByDestination', id: destination }]; }, }), + cancelAllExceptCurrent: build.mutation< + paths['/api/v1/queue/{queue_id}/cancel_all_except_current']['put']['responses']['200']['content']['application/json'], + void + >({ + query: () => ({ + url: buildQueueUrl('cancel_all_except_current'), + method: 'PUT', + }), + onQueryStarted: async (arg, api) => { + const { dispatch, queryFulfilled } = api; + try { + await queryFulfilled; + resetListQueryData(dispatch); + } catch { + // no-op + } + }, + invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination'], + }), listQueueItems: build.query< EntityState & { has_more: boolean; @@ -390,6 +409,7 @@ export const queueApi = api.injectEndpoints({ }); export const { + useCancelAllExceptCurrentMutation, useCancelByBatchIdsMutation, useEnqueueBatchMutation, usePauseProcessorMutation, diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 7c0250167f8..1f9b90fff6e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1102,6 +1102,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/cancel_all_except_current": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Cancel All Except Current + * @description Immediately cancels all queue items except in-processing items + */ + put: operations["cancel_all_except_current"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/cancel_by_batch_ids": { parameters: { query?: never; @@ -3327,6 +3347,17 @@ export type components = { */ type: "calculate_image_tiles_output"; }; + /** + * CancelAllExceptCurrentResult + * @description Result of canceling all except current + */ + CancelAllExceptCurrentResult: { + /** + * Canceled + * @description Number of queue items canceled + */ + canceled: number; + }; /** * CancelByBatchIDsResult * @description Result of canceling by list of batch ids @@ -21174,6 +21205,38 @@ export interface operations { }; }; }; + cancel_all_except_current: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CancelAllExceptCurrentResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; cancel_by_batch_ids: { parameters: { query?: never;