feat(ui): toast on queue item errors, improved error descriptions

Show error toasts on queue item error events instead of invocation error events. This allows errors that occurred outside node execution to be surfaced to the user.

The error description component is updated to show the new error message if available. Commercial handling is retained, but local now uses the same component to display the error message itself.
This commit is contained in:
psychedelicious 2024-05-24 19:03:35 +10:00
parent 50dd569411
commit f5a775ae4e
4 changed files with 86 additions and 75 deletions

View File

@ -3,44 +3,16 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation'; import { zNodeStatus } from 'features/nodes/types/invocation';
import { toast } from 'features/toast/toast';
import ToastWithSessionRefDescription from 'features/toast/ToastWithSessionRefDescription';
import { t } from 'i18next';
import { startCase } from 'lodash-es';
import { socketInvocationError } from 'services/events/actions'; import { socketInvocationError } from 'services/events/actions';
const log = logger('socketio'); const log = logger('socketio');
const getTitle = (errorType: string) => {
if (errorType === 'OutOfMemoryError') {
return t('toast.outOfMemoryError');
}
return t('toast.serverError');
};
const getDescription = (errorType: string, sessionId: string, isLocal?: boolean) => {
if (!isLocal) {
if (errorType === 'OutOfMemoryError') {
return ToastWithSessionRefDescription({
message: t('toast.outOfMemoryDescription'),
sessionId,
});
}
return ToastWithSessionRefDescription({
message: errorType,
sessionId,
});
}
return errorType;
};
export const addInvocationErrorEventListener = (startAppListening: AppStartListening) => { export const addInvocationErrorEventListener = (startAppListening: AppStartListening) => {
startAppListening({ startAppListening({
actionCreator: socketInvocationError, actionCreator: socketInvocationError,
effect: (action, { getState }) => { effect: (action) => {
log.error(action.payload, `Invocation error (${action.payload.data.node.type})`); log.error(action.payload, `Invocation error (${action.payload.data.node.type})`);
const { source_node_id, error_type, error_message, error_traceback, graph_execution_state_id } = const { source_node_id, error_type, error_message, error_traceback } = action.payload.data;
action.payload.data;
const nes = deepClone($nodeExecutionStates.get()[source_node_id]); const nes = deepClone($nodeExecutionStates.get()[source_node_id]);
if (nes) { if (nes) {
nes.status = zNodeStatus.enum.FAILED; nes.status = zNodeStatus.enum.FAILED;
@ -53,19 +25,6 @@ export const addInvocationErrorEventListener = (startAppListening: AppStartListe
}; };
upsertExecutionState(nes.nodeId, nes); upsertExecutionState(nes.nodeId, nes);
} }
const errorType = startCase(error_type);
const sessionId = graph_execution_state_id;
const { isLocal } = getState().config;
toast({
id: `INVOCATION_ERROR_${errorType}`,
title: getTitle(errorType),
status: 'error',
duration: null,
description: getDescription(errorType, sessionId, isLocal),
updateDescription: isLocal ? true : false,
});
}, },
}); });
}; };

View File

@ -3,6 +3,8 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { deepClone } from 'common/util/deepClone'; import { deepClone } from 'common/util/deepClone';
import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState'; import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation'; import { zNodeStatus } from 'features/nodes/types/invocation';
import ErrorToastDescription, { getTitleFromErrorType } from 'features/toast/ErrorToastDescription';
import { toast } from 'features/toast/toast';
import { forEach } from 'lodash-es'; import { forEach } from 'lodash-es';
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue'; import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { socketQueueItemStatusChanged } from 'services/events/actions'; import { socketQueueItemStatusChanged } from 'services/events/actions';
@ -12,7 +14,7 @@ const log = logger('socketio');
export const addSocketQueueItemStatusChangedEventListener = (startAppListening: AppStartListening) => { export const addSocketQueueItemStatusChangedEventListener = (startAppListening: AppStartListening) => {
startAppListening({ startAppListening({
actionCreator: socketQueueItemStatusChanged, actionCreator: socketQueueItemStatusChanged,
effect: async (action, { dispatch }) => { effect: async (action, { dispatch, getState }) => {
// we've got new status for the queue item, batch and queue // we've got new status for the queue item, batch and queue
const { queue_item, batch_status, queue_status } = action.payload.data; const { queue_item, batch_status, queue_status } = action.payload.data;
@ -54,7 +56,7 @@ export const addSocketQueueItemStatusChangedEventListener = (startAppListening:
]) ])
); );
if (['in_progress'].includes(action.payload.data.queue_item.status)) { if (queue_item.status === 'in_progress') {
forEach($nodeExecutionStates.get(), (nes) => { forEach($nodeExecutionStates.get(), (nes) => {
if (!nes) { if (!nes) {
return; return;
@ -67,6 +69,26 @@ export const addSocketQueueItemStatusChangedEventListener = (startAppListening:
clone.outputs = []; clone.outputs = [];
$nodeExecutionStates.setKey(clone.nodeId, clone); $nodeExecutionStates.setKey(clone.nodeId, clone);
}); });
} else if (queue_item.status === 'failed' && queue_item.error_type) {
const { error_type, error_message, session_id } = queue_item;
const isLocal = getState().config.isLocal ?? true;
const sessionId = session_id;
toast({
id: `INVOCATION_ERROR_${error_type}`,
title: getTitleFromErrorType(error_type),
status: 'error',
duration: null,
description: (
<ErrorToastDescription
errorType={error_type}
errorMessage={error_message}
sessionId={sessionId}
isLocal={false}
/>
),
updateDescription: isLocal ? true : false,
});
} }
}, },
}); });

View File

@ -0,0 +1,60 @@
import { Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { t } from 'i18next';
import { upperFirst } from 'lodash-es';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold } from 'react-icons/pi';
function onCopy(sessionId: string) {
navigator.clipboard.writeText(sessionId);
}
const ERROR_TYPE_TO_TITLE: Record<string, string> = {
OutOfMemoryError: 'toast.outOfMemoryError',
};
const COMMERCIAL_ERROR_TYPE_TO_DESC: Record<string, string> = {
OutOfMemoryError: 'toast.outOfMemoryErrorDesc',
};
export const getTitleFromErrorType = (errorType: string) => {
return t(ERROR_TYPE_TO_TITLE[errorType] ?? 'toast.serverError');
};
type Props = { errorType: string; errorMessage?: string | null; sessionId: string; isLocal: boolean };
export default function ErrorToastDescription({ errorType, errorMessage, sessionId, isLocal }: Props) {
const { t } = useTranslation();
const description = useMemo(() => {
// Special handling for commercial error types
const descriptionTKey = isLocal ? null : COMMERCIAL_ERROR_TYPE_TO_DESC[errorType];
if (descriptionTKey) {
return t(descriptionTKey);
}
if (errorMessage) {
return upperFirst(errorMessage);
}
}, [errorMessage, errorType, isLocal, t]);
return (
<Flex flexDir="column">
{description && <Text fontSize="md">{description}</Text>}
{!isLocal && (
<Flex gap="2" alignItems="center">
<Text fontSize="sm" fontStyle="italic">
{t('toast.sessionRef', { sessionId })}
</Text>
<IconButton
size="sm"
aria-label="Copy"
icon={<PiCopyBold />}
onClick={onCopy.bind(null, sessionId)}
variant="ghost"
sx={sx}
/>
</Flex>
)}
</Flex>
);
}
const sx = { svg: { fill: 'base.50' } };

View File

@ -1,30 +0,0 @@
import { Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { t } from 'i18next';
import { PiCopyBold } from 'react-icons/pi';
function onCopy(sessionId: string) {
navigator.clipboard.writeText(sessionId);
}
type Props = { message: string; sessionId: string };
export default function ToastWithSessionRefDescription({ message, sessionId }: Props) {
return (
<Flex flexDir="column">
<Text fontSize="md">{message}</Text>
<Flex gap="2" alignItems="center">
<Text fontSize="sm">{t('toast.sessionRef', { sessionId })}</Text>
<IconButton
size="sm"
aria-label="Copy"
icon={<PiCopyBold />}
onClick={onCopy.bind(null, sessionId)}
variant="ghost"
sx={sx}
/>
</Flex>
</Flex>
);
}
const sx = { svg: { fill: 'base.50' } };