feat(ui): dynamic prompts loading ux

- Prompt must have an open curly brace followed by a close curly brace to enable dynamic prompts processing
- If a the given prompt already had a dynamic prompt cached, do not re-process
- If processing is not needed, user may invoke immediately
- Invoke button shows loading state when dynamic prompts are processing, tooltip says generating
- Dynamic prompts preview icon in prompt box shows loading state when processing, tooltip says generating
This commit is contained in:
psychedelicious 2024-01-02 20:43:25 +11:00 committed by Kent Keirsey
parent 0b4eb888c5
commit 870cc5b733
9 changed files with 85 additions and 36 deletions

View File

@ -1218,7 +1218,8 @@
"perIterationDesc": "Use a different seed for each iteration", "perIterationDesc": "Use a different seed for each iteration",
"perPromptLabel": "Seed per Image", "perPromptLabel": "Seed per Image",
"perPromptDesc": "Use a different seed for each image" "perPromptDesc": "Use a different seed for each image"
} },
"loading": "Generating Dynamic Prompts..."
}, },
"sdxl": { "sdxl": {
"cfgScale": "CFG Scale", "cfgScale": "CFG Scale",

View File

@ -8,6 +8,7 @@ import {
parsingErrorChanged, parsingErrorChanged,
promptsChanged, promptsChanged,
} from 'features/dynamicPrompts/store/dynamicPromptsSlice'; } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { setPositivePrompt } from 'features/parameters/store/generationSlice'; import { setPositivePrompt } from 'features/parameters/store/generationSlice';
import { utilitiesApi } from 'services/api/endpoints/utilities'; import { utilitiesApi } from 'services/api/endpoints/utilities';
import { appSocketConnected } from 'services/events/actions'; import { appSocketConnected } from 'services/events/actions';
@ -29,20 +30,39 @@ export const addDynamicPromptsListener = () => {
action, action,
{ dispatch, getState, cancelActiveListeners, delay } { dispatch, getState, cancelActiveListeners, delay }
) => { ) => {
// debounce request
cancelActiveListeners(); cancelActiveListeners();
await delay(1000);
const state = getState(); const state = getState();
const { positivePrompt } = state.generation;
const { maxPrompts } = state.dynamicPrompts;
if (state.config.disabledFeatures.includes('dynamicPrompting')) { if (state.config.disabledFeatures.includes('dynamicPrompting')) {
return; return;
} }
const { positivePrompt } = state.generation; const cachedPrompts = utilitiesApi.endpoints.dynamicPrompts.select({
const { maxPrompts } = state.dynamicPrompts; prompt: positivePrompt,
max_prompts: maxPrompts,
})(getState()).data;
if (cachedPrompts) {
dispatch(promptsChanged(cachedPrompts.prompts));
return;
}
if (!getShouldProcessPrompt(state.generation.positivePrompt)) {
if (state.dynamicPrompts.isLoading) {
dispatch(isLoadingChanged(false));
}
dispatch(promptsChanged([state.generation.positivePrompt]));
return;
}
if (!state.dynamicPrompts.isLoading) {
dispatch(isLoadingChanged(true)); dispatch(isLoadingChanged(true));
}
// debounce request
await delay(1000);
try { try {
const req = dispatch( const req = dispatch(

View File

@ -1,22 +1,10 @@
import { SpinnerIcon } from '@chakra-ui/icons'; import { SpinnerIcon } from '@chakra-ui/icons';
import { import { forwardRef, MenuItem as ChakraMenuItem } from '@chakra-ui/react';
forwardRef,
keyframes,
MenuItem as ChakraMenuItem,
} from '@chakra-ui/react';
import { memo } from 'react'; import { memo } from 'react';
import { spinAnimation } from 'theme/animations';
import type { InvMenuItemProps } from './types'; import type { InvMenuItemProps } from './types';
const spin = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`;
export const InvMenuItem = memo( export const InvMenuItem = memo(
forwardRef<InvMenuItemProps, typeof ChakraMenuItem>( forwardRef<InvMenuItemProps, typeof ChakraMenuItem>(
(props: InvMenuItemProps, ref) => { (props: InvMenuItemProps, ref) => {
@ -30,13 +18,7 @@ export const InvMenuItem = memo(
return ( return (
<ChakraMenuItem <ChakraMenuItem
ref={ref} ref={ref}
icon={ icon={isLoading ? <SpinnerIcon animation={spinAnimation} /> : icon}
isLoading ? (
<SpinnerIcon animation={`${spin} 1s linear infinite`} />
) : (
icon
)
}
isDisabled={isLoading || isDisabled} isDisabled={isLoading || isDisabled}
data-destructive={isDestructive} data-destructive={isDestructive}
{...rest} {...rest}

View File

@ -3,6 +3,7 @@ import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { selectControlAdapterAll } from 'features/controlAdapters/store/controlAdaptersSlice'; import { selectControlAdapterAll } from 'features/controlAdapters/store/controlAdaptersSlice';
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types'; import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { isInvocationNode } from 'features/nodes/types/invocation'; import { isInvocationNode } from 'features/nodes/types/invocation';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import i18n from 'i18next'; import i18n from 'i18next';
@ -22,7 +23,7 @@ const selector = createMemoizedSelector(
}, },
activeTabName activeTabName
) => { ) => {
const { initialImage, model } = generation; const { initialImage, model, positivePrompt } = generation;
const { isConnected } = system; const { isConnected } = system;
@ -87,7 +88,7 @@ const selector = createMemoizedSelector(
}); });
} }
} else { } else {
if (dynamicPrompts.prompts.length === 0) { if (dynamicPrompts.prompts.length === 0 && getShouldProcessPrompt(positivePrompt)) {
reasons.push(i18n.t('parameters.invoke.noPrompts')); reasons.push(i18n.t('parameters.invoke.noPrompts'));
} }

View File

@ -1,15 +1,29 @@
import type { SystemStyleObject } from '@chakra-ui/styled-system';
import { useAppSelector } from 'app/store/storeHooks';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton'; import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { InvTooltip } from 'common/components/InvTooltip/InvTooltip'; import { InvTooltip } from 'common/components/InvTooltip/InvTooltip';
import { useDynamicPromptsModal } from 'features/dynamicPrompts/hooks/useDynamicPromptsModal'; import { useDynamicPromptsModal } from 'features/dynamicPrompts/hooks/useDynamicPromptsModal';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BsBracesAsterisk } from 'react-icons/bs'; import { BsBracesAsterisk } from 'react-icons/bs';
import { spinAnimation } from 'theme/animations';
const loadingStyles: SystemStyleObject = {
svg: { animation: spinAnimation },
};
export const ShowDynamicPromptsPreviewButton = memo(() => { export const ShowDynamicPromptsPreviewButton = memo(() => {
const { t } = useTranslation(); const { t } = useTranslation();
const isLoading = useAppSelector((state) => state.dynamicPrompts.isLoading);
const { isOpen, onOpen } = useDynamicPromptsModal(); const { isOpen, onOpen } = useDynamicPromptsModal();
return ( return (
<InvTooltip label={t('dynamicPrompts.showDynamicPrompts')}> <InvTooltip
label={
isLoading
? t('dynamicPrompts.loading')
: t('dynamicPrompts.showDynamicPrompts')
}
>
<InvIconButton <InvIconButton
size="sm" size="sm"
variant="promptOverlay" variant="promptOverlay"
@ -17,6 +31,7 @@ export const ShowDynamicPromptsPreviewButton = memo(() => {
aria-label={t('dynamicPrompts.showDynamicPrompts')} aria-label={t('dynamicPrompts.showDynamicPrompts')}
icon={<BsBracesAsterisk />} icon={<BsBracesAsterisk />}
onClick={onOpen} onClick={onOpen}
sx={isLoading ? loadingStyles : undefined}
/> />
</InvTooltip> </InvTooltip>
); );

View File

@ -0,0 +1,3 @@
const hasOpenCloseCurlyBracesRegex = /.*\{.*\}.*/;
export const getShouldProcessPrompt = (prompt: string): boolean =>
hasOpenCloseCurlyBracesRegex.test(prompt);

View File

@ -41,6 +41,9 @@ const selector = createMemoizedSelector([stateSelector], (state) => {
}); });
export const InvokeQueueBackButton = memo(() => { export const InvokeQueueBackButton = memo(() => {
const isLoadingDynamicPrompts = useAppSelector(
(state) => state.dynamicPrompts.isLoading
);
const { queueBack, isLoading, isDisabled } = useQueueBack(); const { queueBack, isLoading, isDisabled } = useQueueBack();
const { iterations, step, fineStep } = useAppSelector(selector); const { iterations, step, fineStep } = useAppSelector(selector);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -73,7 +76,8 @@ export const InvokeQueueBackButton = memo(() => {
</IAIInformationalPopover> </IAIInformationalPopover>
<InvButton <InvButton
onClick={queueBack} onClick={queueBack}
isLoading={isLoading} isLoading={isLoading || isLoadingDynamicPrompts}
loadingText={invoke}
isDisabled={isDisabled} isDisabled={isDisabled}
rightIcon={<IoSparkles />} rightIcon={<IoSparkles />}
tooltip={<QueueButtonTooltip />} tooltip={<QueueButtonTooltip />}
@ -83,8 +87,10 @@ export const InvokeQueueBackButton = memo(() => {
size="lg" size="lg"
w="calc(100% - 60px)" w="calc(100% - 60px)"
flexShrink={0} flexShrink={0}
justifyContent="space-between"
spinnerPlacement="end"
> >
{invoke} <span>{invoke}</span>
<Spacer /> <Spacer />
</InvButton> </InvButton>
</Flex> </Flex>

View File

@ -4,6 +4,7 @@ import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { InvText } from 'common/components/InvText/wrapper'; import { InvText } from 'common/components/InvText/wrapper';
import { useIsReadyToEnqueue } from 'common/hooks/useIsReadyToEnqueue'; import { useIsReadyToEnqueue } from 'common/hooks/useIsReadyToEnqueue';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useEnqueueBatchMutation } from 'services/api/endpoints/queue'; import { useEnqueueBatchMutation } from 'services/api/endpoints/queue';
@ -15,8 +16,10 @@ const tooltipSelector = createMemoizedSelector(
[stateSelector], [stateSelector],
({ gallery, dynamicPrompts, generation }) => { ({ gallery, dynamicPrompts, generation }) => {
const { autoAddBoardId } = gallery; const { autoAddBoardId } = gallery;
const promptsCount = dynamicPrompts.prompts.length; const { iterations, positivePrompt } = generation;
const { iterations } = generation; const promptsCount = getShouldProcessPrompt(positivePrompt)
? dynamicPrompts.prompts.length
: 1;
return { return {
autoAddBoardId, autoAddBoardId,
promptsCount, promptsCount,
@ -32,6 +35,9 @@ type Props = {
export const QueueButtonTooltip = memo(({ prepend = false }: Props) => { export const QueueButtonTooltip = memo(({ prepend = false }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isReady, reasons } = useIsReadyToEnqueue(); const { isReady, reasons } = useIsReadyToEnqueue();
const isLoadingDynamicPrompts = useAppSelector(
(state) => state.dynamicPrompts.isLoading
);
const { autoAddBoardId, promptsCount, iterations } = const { autoAddBoardId, promptsCount, iterations } =
useAppSelector(tooltipSelector); useAppSelector(tooltipSelector);
const autoAddBoardName = useBoardName(autoAddBoardId); const autoAddBoardName = useBoardName(autoAddBoardId);
@ -43,6 +49,9 @@ export const QueueButtonTooltip = memo(({ prepend = false }: Props) => {
if (isLoading) { if (isLoading) {
return t('queue.enqueueing'); return t('queue.enqueueing');
} }
if (isLoadingDynamicPrompts) {
return t('dynamicPrompts.loading');
}
if (isReady) { if (isReady) {
if (prepend) { if (prepend) {
return t('queue.queueFront'); return t('queue.queueFront');
@ -50,7 +59,7 @@ export const QueueButtonTooltip = memo(({ prepend = false }: Props) => {
return t('queue.queueBack'); return t('queue.queueBack');
} }
return t('queue.notReady'); return t('queue.notReady');
}, [isLoading, isReady, prepend, t]); }, [isLoading, isLoadingDynamicPrompts, isReady, prepend, t]);
return ( return (
<Flex flexDir="column" gap={1}> <Flex flexDir="column" gap={1}>

View File

@ -0,0 +1,12 @@
import { keyframes } from '@emotion/react';
export const spinKeyframes = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`;
export const spinAnimation = `${spinKeyframes} 0.45s linear infinite`;