feat(ui): useful tooltips on invoke button

This commit is contained in:
psychedelicious 2023-08-20 23:07:55 +10:00
parent 2dfcba8654
commit 990b6b5f6a
7 changed files with 193 additions and 238 deletions

View File

@ -8,8 +8,8 @@ import {
import { memo, ReactNode } from 'react'; import { memo, ReactNode } from 'react';
export interface IAIButtonProps extends ButtonProps { export interface IAIButtonProps extends ButtonProps {
tooltip?: string; tooltip?: TooltipProps['label'];
tooltipProps?: Omit<TooltipProps, 'children'>; tooltipProps?: Omit<TooltipProps, 'children' | 'label'>;
isChecked?: boolean; isChecked?: boolean;
children: ReactNode; children: ReactNode;
} }

View File

@ -9,8 +9,8 @@ import { memo } from 'react';
export type IAIIconButtonProps = IconButtonProps & { export type IAIIconButtonProps = IconButtonProps & {
role?: string; role?: string;
tooltip?: string; tooltip?: TooltipProps['label'];
tooltipProps?: Omit<TooltipProps, 'children'>; tooltipProps?: Omit<TooltipProps, 'children' | 'label'>;
isChecked?: boolean; isChecked?: boolean;
}; };

View File

@ -2,71 +2,104 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
// import { validateSeedWeights } from 'common/util/seedWeightPairs'; import { isInvocationNode } from 'features/nodes/types/types';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { forEach } from 'lodash-es'; import { forEach, map } from 'lodash-es';
import { NON_REFINER_BASE_MODELS } from 'services/api/constants'; import { getConnectedEdges } from 'reactflow';
import { modelsApi } from '../../services/api/endpoints/models';
const readinessSelector = createSelector( const selector = createSelector(
[stateSelector, activeTabNameSelector], [stateSelector, activeTabNameSelector],
(state, activeTabName) => { (state, activeTabName) => {
const { generation, system } = state; const { generation, system, nodes } = state;
const { initialImage } = generation; const { initialImage, model } = generation;
const { isProcessing, isConnected } = system; const { isProcessing, isConnected } = system;
let isReady = true; const reasons: string[] = [];
const reasonsWhyNotReady: string[] = [];
if (activeTabName === 'img2img' && !initialImage) {
isReady = false;
reasonsWhyNotReady.push('No initial image selected');
}
const { isSuccess: mainModelsSuccessfullyLoaded } =
modelsApi.endpoints.getMainModels.select(NON_REFINER_BASE_MODELS)(state);
if (!mainModelsSuccessfullyLoaded) {
isReady = false;
reasonsWhyNotReady.push('Models are not loaded');
}
// TODO: job queue
// Cannot generate if already processing an image // Cannot generate if already processing an image
if (isProcessing) { if (isProcessing) {
isReady = false; reasons.push('System busy');
reasonsWhyNotReady.push('System Busy');
} }
// Cannot generate if not connected // Cannot generate if not connected
if (!isConnected) { if (!isConnected) {
isReady = false; reasons.push('System disconnected');
reasonsWhyNotReady.push('System Disconnected');
} }
// // Cannot generate variations without valid seed weights if (activeTabName === 'img2img' && !initialImage) {
// if ( reasons.push('No initial image selected');
// shouldGenerateVariations && }
// (!(validateSeedWeights(seedWeights) || seedWeights === '') || seed === -1)
// ) {
// isReady = false;
// reasonsWhyNotReady.push('Seed-Weights badly formatted.');
// }
forEach(state.controlNet.controlNets, (controlNet, id) => { if (activeTabName === 'nodes' && nodes.shouldValidateGraph) {
if (!controlNet.model) { nodes.nodes.forEach((node) => {
isReady = false; if (!isInvocationNode(node)) {
reasonsWhyNotReady.push(`ControlNet ${id} has no model selected.`); return;
}
const nodeTemplate = nodes.nodeTemplates[node.data.type];
if (!nodeTemplate) {
// Node type not found
reasons.push('Missing node template');
return;
}
const connectedEdges = getConnectedEdges([node], nodes.edges);
forEach(node.data.inputs, (field) => {
const fieldTemplate = nodeTemplate.inputs[field.name];
const hasConnection = connectedEdges.some(
(edge) =>
edge.target === node.id && edge.targetHandle === field.name
);
if (!fieldTemplate) {
reasons.push('Missing field template');
return;
}
if (fieldTemplate.required && !field.value && !hasConnection) {
reasons.push(
`${node.data.label || nodeTemplate.title} -> ${
field.label || fieldTemplate.title
} missing input`
);
return;
}
});
});
} else {
if (!model) {
reasons.push('No model selected');
} }
});
// All good if (state.controlNet.isEnabled) {
return { isReady, reasonsWhyNotReady }; map(state.controlNet.controlNets).forEach((controlNet, i) => {
if (!controlNet.isEnabled) {
return;
}
if (!controlNet.model) {
reasons.push(`ControlNet ${i + 1} has no model selected.`);
}
if (
!controlNet.controlImage ||
(!controlNet.processedControlImage &&
controlNet.processorType !== 'none')
) {
reasons.push(`ControlNet ${i + 1} has no control image`);
}
});
}
}
return { isReady: !reasons.length, isProcessing, reasons };
}, },
defaultSelectorOptions defaultSelectorOptions
); );
export const useIsReadyToInvoke = () => { export const useIsReadyToInvoke = () => {
const { isReady } = useAppSelector(readinessSelector); const { isReady, isProcessing, reasons } = useAppSelector(selector);
return isReady; return { isReady, isProcessing, reasons };
}; };

View File

@ -2,10 +2,9 @@ import { Box } from '@chakra-ui/react';
import { userInvoked } from 'app/store/actions'; import { userInvoked } from 'app/store/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton, { IAIButtonProps } from 'common/components/IAIButton'; import IAIButton, { IAIButtonProps } from 'common/components/IAIButton';
import IAIIconButton, { import { IAIIconButtonProps } from 'common/components/IAIIconButton';
IAIIconButtonProps, import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke';
} from 'common/components/IAIIconButton'; import { InvokeButtonTooltipContent } from 'features/parameters/components/ProcessButtons/InvokeButton';
import { selectIsReadyNodes } from 'features/nodes/store/selectors';
import ProgressBar from 'features/system/components/ProgressBar'; import ProgressBar from 'features/system/components/ProgressBar';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
@ -14,15 +13,13 @@ import { useTranslation } from 'react-i18next';
import { FaPlay } from 'react-icons/fa'; import { FaPlay } from 'react-icons/fa';
interface InvokeButton interface InvokeButton
extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> { extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> {}
iconButton?: boolean;
}
const NodeInvokeButton = (props: InvokeButton) => { const NodeInvokeButton = (props: InvokeButton) => {
const { iconButton = false, ...rest } = props; const { ...rest } = props;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const activeTabName = useAppSelector(activeTabNameSelector); const activeTabName = useAppSelector(activeTabNameSelector);
const isReady = useAppSelector(selectIsReadyNodes); const { isReady, isProcessing } = useIsReadyToInvoke();
const handleInvoke = useCallback(() => { const handleInvoke = useCallback(() => {
dispatch(userInvoked('nodes')); dispatch(userInvoked('nodes'));
}, [dispatch]); }, [dispatch]);
@ -58,37 +55,24 @@ const NodeInvokeButton = (props: InvokeButton) => {
<ProgressBar /> <ProgressBar />
</Box> </Box>
)} )}
{iconButton ? ( <IAIButton
<IAIIconButton tooltip={<InvokeButtonTooltipContent />}
aria-label={t('parameters.invoke')} aria-label={t('parameters.invoke')}
type="submit" type="submit"
icon={<FaPlay />} isDisabled={!isReady}
isDisabled={!isReady} onClick={handleInvoke}
onClick={handleInvoke} flexGrow={1}
flexGrow={1} w="100%"
w="100%" colorScheme="accent"
tooltip={t('parameters.invoke')} id="invoke-button"
tooltipProps={{ placement: 'bottom' }} leftIcon={isProcessing ? undefined : <FaPlay />}
colorScheme="accent" fontWeight={700}
id="invoke-button" isLoading={isProcessing}
{...rest} loadingText={t('parameters.invoke')}
/> {...rest}
) : ( >
<IAIButton Invoke
aria-label={t('parameters.invoke')} </IAIButton>
type="submit"
isDisabled={!isReady}
onClick={handleInvoke}
flexGrow={1}
w="100%"
colorScheme="accent"
id="invoke-button"
fontWeight={700}
{...rest}
>
Invoke
</IAIButton>
)}
</Box> </Box>
</Box> </Box>
); );

View File

@ -1,8 +1,7 @@
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton'; import IAIButton from 'common/components/IAIButton';
import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice'; import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { FaPlus } from 'react-icons/fa';
import { Panel } from 'reactflow'; import { Panel } from 'reactflow';
const TopLeftPanel = () => { const TopLeftPanel = () => {
@ -14,12 +13,9 @@ const TopLeftPanel = () => {
return ( return (
<Panel position="top-left"> <Panel position="top-left">
<IAIIconButton <IAIButton aria-label="Add Node" onClick={handleOpenAddNodePopover}>
aria-label="Add Node" Add Node
tooltip="Add Node" </IAIButton>
onClick={handleOpenAddNodePopover}
icon={<FaPlus />}
/>
</Panel> </Panel>
); );
}; };

View File

@ -1,92 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
// import { validateSeedWeights } from 'common/util/seedWeightPairs';
import { every } from 'lodash-es';
import { getConnectedEdges } from 'reactflow';
import { isInvocationNode } from '../types/types';
import { NodesState } from './types';
export const selectIsReadyNodes = createSelector(
[stateSelector],
(state) => {
const { nodes, system } = state;
const { isProcessing, isConnected } = system;
if (isProcessing || !isConnected) {
// Cannot generate if already processing an image
return false;
}
if (!nodes.shouldValidateGraph) {
return true;
}
const isGraphReady = every(nodes.nodes, (node) => {
if (!isInvocationNode(node)) {
return true;
}
const nodeTemplate = nodes.nodeTemplates[node.data.type];
if (!nodeTemplate) {
// Node type not found
return false;
}
const connectedEdges = getConnectedEdges([node], nodes.edges);
const isNodeValid = every(node.data.inputs, (field) => {
const fieldTemplate = nodeTemplate.inputs[field.name];
const hasConnection = connectedEdges.some(
(edge) => edge.target === node.id && edge.targetHandle === field.name
);
if (!fieldTemplate) {
// Field type not found
return false;
}
if (fieldTemplate.required && !field.value && !hasConnection) {
// Required field is empty or does not have a connection
return false;
}
// ok
return true;
});
return isNodeValid;
});
return isGraphReady;
},
defaultSelectorOptions
);
export const getNodeAndTemplate = (nodeId: string, nodes: NodesState) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
return { node, nodeTemplate };
};
export const getInputFieldAndTemplate = (
nodeId: string,
fieldName: string,
nodes: NodesState
) => {
const node = nodes.nodes
.filter(isInvocationNode)
.find((node) => node.id === nodeId);
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
if (!node || !nodeTemplate) {
return;
}
const field = node.data.inputs[fieldName];
const fieldTemplate = nodeTemplate.inputs[fieldName];
return { field, fieldTemplate };
};

View File

@ -1,4 +1,12 @@
import { Box, ChakraProps, Tooltip } from '@chakra-ui/react'; import {
Box,
ChakraProps,
Divider,
Flex,
ListItem,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { userInvoked } from 'app/store/actions'; import { userInvoked } from 'app/store/actions';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
@ -13,7 +21,7 @@ import { clampSymmetrySteps } from 'features/parameters/store/generationSlice';
import ProgressBar from 'features/system/components/ProgressBar'; import ProgressBar from 'features/system/components/ProgressBar';
import { selectIsBusy } from 'features/system/store/systemSelectors'; import { selectIsBusy } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useCallback } from 'react'; import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FaPlay } from 'react-icons/fa'; import { FaPlay } from 'react-icons/fa';
@ -53,9 +61,8 @@ interface InvokeButton
export default function InvokeButton(props: InvokeButton) { export default function InvokeButton(props: InvokeButton) {
const { iconButton = false, ...rest } = props; const { iconButton = false, ...rest } = props;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isReady = useIsReadyToInvoke(); const { isReady, isProcessing } = useIsReadyToInvoke();
const { isBusy, autoAddBoardId, activeTabName } = useAppSelector(selector); const { activeTabName } = useAppSelector(selector);
const autoAddBoardName = useBoardName(autoAddBoardId);
const handleInvoke = useCallback(() => { const handleInvoke = useCallback(() => {
dispatch(clampSymmetrySteps()); dispatch(clampSymmetrySteps());
@ -94,53 +101,80 @@ export default function InvokeButton(props: InvokeButton) {
<ProgressBar /> <ProgressBar />
</Box> </Box>
)} )}
<Tooltip {iconButton ? (
placement="top" <IAIIconButton
hasArrow aria-label={t('parameters.invoke')}
openDelay={500} type="submit"
label={ icon={<FaPlay />}
autoAddBoardId ? `Auto-Adding to ${autoAddBoardName}` : undefined isDisabled={!isReady}
} onClick={handleInvoke}
> tooltip={<InvokeButtonTooltipContent />}
{iconButton ? ( colorScheme="accent"
<IAIIconButton isLoading={isProcessing}
aria-label={t('parameters.invoke')} id="invoke-button"
type="submit" {...rest}
icon={<FaPlay />} sx={{
isDisabled={!isReady || isBusy} w: 'full',
onClick={handleInvoke} flexGrow: 1,
tooltip={t('parameters.invoke')} ...(isProcessing ? IN_PROGRESS_STYLES : {}),
tooltipProps={{ placement: 'top' }} }}
colorScheme="accent" />
id="invoke-button" ) : (
{...rest} <IAIButton
sx={{ tooltip={<InvokeButtonTooltipContent />}
w: 'full', aria-label={t('parameters.invoke')}
flexGrow: 1, type="submit"
...(isBusy ? IN_PROGRESS_STYLES : {}), isDisabled={!isReady}
}} onClick={handleInvoke}
/> colorScheme="accent"
) : ( id="invoke-button"
<IAIButton leftIcon={isProcessing ? undefined : <FaPlay />}
aria-label={t('parameters.invoke')} isLoading={isProcessing}
type="submit" loadingText={t('parameters.invoke')}
isDisabled={!isReady || isBusy} {...rest}
onClick={handleInvoke} sx={{
colorScheme="accent" w: 'full',
id="invoke-button" flexGrow: 1,
{...rest} fontWeight: 700,
sx={{ ...(isProcessing ? IN_PROGRESS_STYLES : {}),
w: 'full', }}
flexGrow: 1, >
fontWeight: 700, Invoke
...(isBusy ? IN_PROGRESS_STYLES : {}), </IAIButton>
}} )}
>
Invoke
</IAIButton>
)}
</Tooltip>
</Box> </Box>
</Box> </Box>
); );
} }
export const InvokeButtonTooltipContent = memo(() => {
const { isReady, reasons } = useIsReadyToInvoke();
const { autoAddBoardId } = useAppSelector(selector);
const autoAddBoardName = useBoardName(autoAddBoardId);
return (
<Flex flexDir="column" gap={1}>
<Text fontWeight={600}>
{isReady ? 'Ready to Invoke' : 'Unable to Invoke'}
</Text>
{reasons.length > 0 && (
<UnorderedList>
{reasons.map((reason, i) => (
<ListItem key={`${reason}.${i}`}>
<Text fontWeight={400}>{reason}</Text>
</ListItem>
))}
</UnorderedList>
)}
<Divider opacity={0.2} />
<Text fontWeight={400} fontStyle="oblique 10deg">
Adding images to{' '}
<Text as="span" fontWeight={600}>
{autoAddBoardName || 'Uncategorized'}
</Text>
</Text>
</Flex>
);
});
InvokeButtonTooltipContent.displayName = 'InvokeButtonTooltipContent';