Consistency in handling logic

This commit is contained in:
Kent Keirsey
2025-06-27 14:57:08 -04:00
parent cc161925c4
commit 74a1203a90
7 changed files with 652 additions and 137 deletions

View File

@@ -6,6 +6,7 @@ import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/component
import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton';
import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton';
import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton';
import { StagingAreaToolbarInfoButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton';
import { StagingAreaToolbarMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenu';
import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton';
import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton';
@@ -42,6 +43,7 @@ export const StagingAreaToolbar = memo(() => {
<StagingAreaToolbarAcceptButton />
<StagingAreaToolbarToggleShowResultsButton />
<StagingAreaToolbarSaveSelectedToGalleryButton />
<StagingAreaToolbarInfoButton isDisabled={!shouldShowStagedImage} />
<StagingAreaToolbarMenu />
<StagingAreaToolbarDiscardSelectedButton isDisabled={!shouldShowStagedImage} />
<StagingAreaToolbarDiscardAllButton isDisabled={!shouldShowStagedImage} />

View File

@@ -1,7 +1,11 @@
import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger, Text, VStack } from '@invoke-ai/ui-library';
import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger, Text, VStack, Divider, Grid } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
import { memo, useCallback } from 'react';
import { MetadataItem } from 'features/metadata/components/MetadataItem';
import { MetadataLoRAs } from 'features/metadata/components/MetadataLoRAs';
import { useMetadataExtraction } from 'features/metadata/hooks/useMetadataExtraction';
import { handlers } from 'features/metadata/util/handlers';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiInfoBold } from 'react-icons/pi';
@@ -10,20 +14,8 @@ export const StagingAreaToolbarInfoButton = memo(({ isDisabled }: { isDisabled?:
const selectedItem = useStore(ctx.$selectedItem);
const { t } = useTranslation();
const formatTimestamp = useCallback((timestamp: string | null | undefined) => {
if (!timestamp) {
return 'N/A';
}
return new Date(timestamp).toLocaleString();
}, []);
const formatDuration = useCallback((start: string | null | undefined, end: string | null | undefined) => {
if (!start || !end) {
return 'N/A';
}
const duration = new Date(end).getTime() - new Date(start).getTime();
return `${(duration / 1000).toFixed(2)}s`;
}, []);
// Extract metadata using the unified hook
const metadata = useMetadataExtraction(selectedItem);
if (!selectedItem) {
return (
@@ -48,101 +40,126 @@ export const StagingAreaToolbarInfoButton = memo(({ isDisabled }: { isDisabled?:
isDisabled={isDisabled}
/>
</PopoverTrigger>
<PopoverContent maxW="300px">
<PopoverBody>
<VStack align="start" spacing={2} fontSize="sm">
<Text fontWeight="bold">Generation Info</Text>
<VStack align="start" spacing={1} w="full">
<Text>
<Text as="span" fontWeight="semibold">
Status:
</Text>{' '}
{selectedItem.status}
</Text>
<Text>
<Text as="span" fontWeight="semibold">
Item ID:
</Text>{' '}
{selectedItem.item_id}
</Text>
<Text>
<Text as="span" fontWeight="semibold">
Priority:
</Text>{' '}
{selectedItem.priority}
</Text>
{selectedItem.origin && (
<Text>
<Text as="span" fontWeight="semibold">
Origin:
</Text>{' '}
{selectedItem.origin}
</Text>
)}
{selectedItem.destination && (
<Text>
<Text as="span" fontWeight="semibold">
Destination:
</Text>{' '}
{selectedItem.destination}
</Text>
)}
<Text>
<Text as="span" fontWeight="semibold">
Created:
</Text>{' '}
{formatTimestamp(selectedItem.created_at)}
</Text>
{selectedItem.started_at && (
<Text>
<Text as="span" fontWeight="semibold">
Started:
</Text>{' '}
{formatTimestamp(selectedItem.started_at)}
</Text>
)}
{selectedItem.completed_at && (
<Text>
<Text as="span" fontWeight="semibold">
Completed:
</Text>{' '}
{formatTimestamp(selectedItem.completed_at)}
</Text>
)}
{selectedItem.started_at && selectedItem.completed_at && (
<Text>
<Text as="span" fontWeight="semibold">
Duration:
</Text>{' '}
{formatDuration(selectedItem.started_at, selectedItem.completed_at)}
</Text>
)}
{selectedItem.credits && (
<Text>
<Text as="span" fontWeight="semibold">
Credits:
</Text>{' '}
{selectedItem.credits}
</Text>
)}
{selectedItem.error_message && (
<Text color="error.300">
<Text as="span" fontWeight="semibold">
Error:
</Text>{' '}
{selectedItem.error_message}
</Text>
<PopoverContent maxW="500px" bg="base.900" borderColor="base.700">
<PopoverBody p={4}>
<VStack align="start" spacing={4} fontSize="sm">
{/* Prompts Section */}
<VStack align="start" spacing={3} w="full">
<Text fontWeight="semibold" fontSize="md" color="base.100">Prompts</Text>
{metadata !== null && (
<>
<MetadataItem
metadata={metadata}
handlers={handlers.positivePrompt}
displayMode="card"
showCopy={true}
/>
<MetadataItem
metadata={metadata}
handlers={handlers.negativePrompt}
displayMode="card"
showCopy={true}
/>
</>
)}
</VStack>
<Divider borderColor="base.700" />
{/* Models and LoRAs Section - Left Column */}
<Grid templateColumns="1fr 1fr" gap={6} w="full">
<VStack align="start" spacing={4} w="full">
{/* Model Section */}
<VStack align="start" spacing={3} w="full">
<Text fontWeight="semibold" fontSize="md" color="base.100">Model</Text>
{metadata !== null && (
<VStack align="start" spacing={2} w="full">
<MetadataItem
metadata={metadata}
handlers={handlers.model}
displayMode="badge"
colorScheme="invokeBlue"
showCopy={true}
/>
<MetadataItem
metadata={metadata}
handlers={handlers.vae}
displayMode="badge"
colorScheme="base"
showCopy={true}
/>
</VStack>
)}
</VStack>
{/* LoRA Section */}
{metadata !== null && (
<MetadataLoRAs
metadata={metadata}
displayMode="badge"
showCopy={true}
/>
)}
</VStack>
{/* Other Settings Section - Right Column */}
<VStack align="start" spacing={3} w="full">
<Text fontWeight="semibold" fontSize="md" color="base.100">Other Settings</Text>
{metadata !== null && (
<VStack align="start" spacing={3} w="full">
<MetadataItem
metadata={metadata}
handlers={handlers.seed}
displayMode="simple"
showCopy={true}
/>
<MetadataItem
metadata={metadata}
handlers={handlers.steps}
displayMode="simple"
showCopy={true}
/>
<MetadataItem
metadata={metadata}
handlers={handlers.cfgScale}
displayMode="simple"
showCopy={true}
/>
<MetadataItem
metadata={metadata}
handlers={handlers.scheduler}
displayMode="simple"
showCopy={true}
/>
</VStack>
)}
</VStack>
</Grid>
{/* Error Section */}
{selectedItem.error_message && (
<>
<Divider borderColor="base.700" />
<VStack align="start" spacing={2} w="full">
<Text fontWeight="semibold" fontSize="md" color="error.300">Error</Text>
<Text
fontSize="sm"
color="error.200"
bg="error.900"
p={3}
borderRadius="lg"
w="full"
border="1px solid"
borderColor="error.700"
>
{selectedItem.error_message}
</Text>
</VStack>
</>
)}
</VStack>
</PopoverBody>
</PopoverContent>

View File

@@ -9,10 +9,26 @@ type MetadataItemProps<T> = {
metadata: unknown;
handlers: MetadataHandlers<T>;
direction?: 'row' | 'column';
/** Display mode for the metadata item */
displayMode?: 'default' | 'badge' | 'simple' | 'card';
/** Color scheme for badge display mode */
colorScheme?: string;
/** Whether to show copy functionality */
showCopy?: boolean;
/** Whether to show recall functionality */
showRecall?: boolean;
};
const _MetadataItem = typedMemo(<T,>({ metadata, handlers, direction = 'row' }: MetadataItemProps<T>) => {
const { label, isDisabled, value, renderedValue, onRecall } = useMetadataItem(metadata, handlers);
const _MetadataItem = typedMemo(<T,>({
metadata,
handlers,
direction = 'row',
displayMode = 'default',
colorScheme = 'invokeBlue',
showCopy = false,
showRecall = true
}: MetadataItemProps<T>) => {
const { label, isDisabled, value, renderedValue, onRecall, valueOrNull } = useMetadataItem(metadata, handlers);
if (value === MetadataParseFailedToken) {
return null;
@@ -22,13 +38,24 @@ const _MetadataItem = typedMemo(<T,>({ metadata, handlers, direction = 'row' }:
return null;
}
// For display modes other than default, we need the raw value for copy functionality
if (displayMode !== 'default') {
if (!valueOrNull) {
return null;
}
}
return (
<MetadataItemView
label={label}
onRecall={onRecall}
onRecall={showRecall ? onRecall : undefined}
isDisabled={isDisabled}
renderedValue={renderedValue}
direction={direction}
displayMode={displayMode}
colorScheme={colorScheme}
showCopy={showCopy}
valueOrNull={valueOrNull}
/>
);
});

View File

@@ -1,28 +1,244 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { Flex, Text, VStack, HStack, Badge, IconButton, Tooltip } from '@invoke-ai/ui-library';
import { RecallButton } from 'features/metadata/components/RecallButton';
import { memo } from 'react';
import { useClipboard } from 'common/hooks/useClipboard';
import { memo, useCallback } from 'react';
import { PiCopyBold } from 'react-icons/pi';
type MetadataItemViewProps = {
onRecall: () => void;
onRecall?: () => void;
label: string;
renderedValue: React.ReactNode;
isDisabled: boolean;
direction?: 'row' | 'column';
/** Display mode for the metadata item */
displayMode?: 'default' | 'badge' | 'simple' | 'card';
/** Color scheme for badge display mode */
colorScheme?: string;
/** Whether to show copy functionality */
showCopy?: boolean;
/** Raw value for copy functionality */
valueOrNull?: unknown;
};
export const MetadataItemView = memo(
({ label, onRecall, isDisabled, renderedValue, direction = 'row' }: MetadataItemViewProps) => {
return (
<Flex gap={2}>
{onRecall && <RecallButton label={label} onClick={onRecall} isDisabled={isDisabled} />}
<Flex direction={direction} fontSize="sm">
<Text fontWeight="semibold" whiteSpace="pre-wrap" pr={2}>
{label}:
</Text>
{renderedValue}
({
label,
onRecall,
isDisabled,
renderedValue,
direction = 'row',
displayMode = 'default',
colorScheme = 'invokeBlue',
showCopy = false,
valueOrNull
}: MetadataItemViewProps) => {
const clipboard = useClipboard();
const handleCopy = useCallback(() => {
if (valueOrNull != null) {
clipboard.writeText(String(valueOrNull));
}
}, [clipboard, valueOrNull]);
// Default display mode (original behavior)
if (displayMode === 'default') {
return (
<Flex gap={2}>
{onRecall && <RecallButton label={label} onClick={onRecall} isDisabled={isDisabled} />}
<Flex direction={direction} fontSize="sm">
<Text fontWeight="semibold" whiteSpace="pre-wrap" pr={2}>
{label}:
</Text>
{renderedValue}
</Flex>
</Flex>
</Flex>
);
);
}
// Card display mode (for prompts)
if (displayMode === 'card') {
return (
<VStack align="start" spacing={1} w="full">
<Text fontSize="xs" fontWeight="medium" color="base.300" textTransform="uppercase" letterSpacing="wide">
{label}
</Text>
<VStack
position="relative"
w="full"
_hover={{
'& .hover-actions': {
opacity: 1,
}
}}
>
<Text
fontSize="sm"
bg="base.800"
p={3}
borderRadius="lg"
w="full"
wordBreak="break-word"
border="1px solid"
borderColor="base.700"
color="base.100"
lineHeight="tall"
>
{renderedValue}
</Text>
<HStack
className="hover-actions"
position="absolute"
top={2}
right={2}
opacity={0}
transition="opacity 0.2s"
spacing={1}
>
{showCopy && (
<Tooltip label="Copy to clipboard">
<IconButton
size="xs"
icon={<PiCopyBold />}
onClick={handleCopy}
colorScheme="base"
variant="ghost"
aria-label={`Copy ${label} to clipboard`}
/>
</Tooltip>
)}
{onRecall && (
<RecallButton
label={label}
onClick={onRecall}
isDisabled={isDisabled}
/>
)}
</HStack>
</VStack>
</VStack>
);
}
// Simple display mode (for seed, steps, etc.)
if (displayMode === 'simple') {
return (
<VStack align="start" spacing={1} w="full">
<Text fontSize="xs" fontWeight="medium" color="base.300" textTransform="uppercase" letterSpacing="wide">
{label}
</Text>
<VStack
position="relative"
w="full"
_hover={{
'& .hover-actions': {
opacity: 1,
}
}}
>
<Text fontSize="sm" color="base.100" fontFamily="mono" bg="base.800" px={3} py={2} borderRadius="md" w="full" textAlign="center">
{renderedValue}
</Text>
<HStack
className="hover-actions"
position="absolute"
top={1}
right={1}
opacity={0}
transition="opacity 0.2s"
spacing={1}
>
{showCopy && (
<Tooltip label="Copy to clipboard">
<IconButton
size="xs"
icon={<PiCopyBold />}
onClick={handleCopy}
colorScheme="base"
variant="ghost"
aria-label={`Copy ${label} to clipboard`}
/>
</Tooltip>
)}
{onRecall && (
<RecallButton
label={label}
onClick={onRecall}
isDisabled={isDisabled}
/>
)}
</HStack>
</VStack>
</VStack>
);
}
// Badge display mode (for models, etc.)
if (displayMode === 'badge') {
return (
<VStack align="start" spacing={1} w="full">
<Text fontSize="xs" fontWeight="medium" color="base.300" textTransform="uppercase" letterSpacing="wide">
{label}
</Text>
<VStack
position="relative"
w="fit-content"
_hover={{
'& .hover-actions': {
opacity: 1,
}
}}
>
<Badge
colorScheme={colorScheme}
variant="subtle"
fontSize="sm"
px={3}
py={2}
borderRadius="md"
>
{renderedValue}
</Badge>
<HStack
className="hover-actions"
position="absolute"
top={-2}
right={-2}
opacity={0}
transition="opacity 0.2s"
spacing={1}
bg="base.900"
borderRadius="md"
p={1}
border="1px solid"
borderColor="base.700"
shadow="lg"
>
{showCopy && (
<Tooltip label="Copy to clipboard">
<IconButton
size="xs"
icon={<PiCopyBold />}
onClick={handleCopy}
colorScheme="base"
variant="ghost"
aria-label={`Copy ${label} to clipboard`}
/>
</Tooltip>
)}
{onRecall && (
<RecallButton
label={label}
onClick={onRecall}
isDisabled={isDisabled}
/>
)}
</HStack>
</VStack>
</VStack>
);
}
return null;
}
);

View File

@@ -1,15 +1,31 @@
import type { LoRA } from 'features/controlLayers/store/types';
import { MetadataItemView } from 'features/metadata/components/MetadataItemView';
import { RecallButton } from 'features/metadata/components/RecallButton';
import type { MetadataHandlers } from 'features/metadata/types';
import { handlers } from 'features/metadata/util/handlers';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { VStack, Text, Badge, HStack, IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useClipboard } from 'common/hooks/useClipboard';
import { PiCopyBold } from 'react-icons/pi';
type Props = {
metadata: unknown;
/** Display mode for LoRA items */
displayMode?: 'default' | 'badge';
/** Whether to show copy functionality */
showCopy?: boolean;
/** Whether to show recall functionality */
showRecall?: boolean;
};
export const MetadataLoRAs = ({ metadata }: Props) => {
export const MetadataLoRAs = ({
metadata,
displayMode = 'default',
showCopy = false,
showRecall = true
}: Props) => {
const [loras, setLoRAs] = useState<LoRA[]>([]);
const clipboard = useClipboard();
useEffect(() => {
const parse = async () => {
@@ -25,32 +41,70 @@ export const MetadataLoRAs = ({ metadata }: Props) => {
const label = useMemo(() => handlers.loras.getLabel(), []);
return (
<>
{loras.map((lora) => (
<MetadataViewLoRA key={lora.model.key} label={label} lora={lora} handlers={handlers.loras} />
))}
</>
);
// Default display mode (original behavior)
if (displayMode === 'default') {
return (
<>
{loras.map((lora) => (
<MetadataViewLoRA
key={lora.model.key}
label={label}
lora={lora}
handlers={handlers.loras}
showRecall={showRecall}
/>
))}
</>
);
}
// Badge display mode (for staging area)
if (displayMode === 'badge') {
if (!loras || loras.length === 0) {
return null;
}
return (
<VStack align="start" spacing={3} w="full">
<Text fontWeight="semibold" fontSize="md" color="base.100">LoRAs</Text>
<VStack align="start" spacing={2} w="full">
{loras.map((lora: LoRA, index: number) => (
<BadgeLoRA
key={lora.id || index}
lora={lora}
index={index}
handlers={handlers.loras}
showCopy={showCopy}
showRecall={showRecall}
/>
))}
</VStack>
</VStack>
);
}
return null;
};
const MetadataViewLoRA = ({
label,
lora,
handlers,
showRecall = true,
}: {
label: string;
lora: LoRA;
handlers: MetadataHandlers<LoRA[], LoRA>;
showRecall?: boolean;
}) => {
const onRecall = useCallback(() => {
if (!handlers.recallItem) {
if (!handlers.recallItem || !showRecall) {
return;
}
handlers.recallItem(lora, true).catch(() => {
// no-op, the toast will show the error
});
}, [handlers, lora]);
}, [handlers, lora, showRecall]);
const [renderedValue, setRenderedValue] = useState<React.ReactNode>(null);
useEffect(() => {
@@ -66,5 +120,124 @@ const MetadataViewLoRA = ({
_renderValue();
}, [handlers, lora]);
return <MetadataItemView label={label} isDisabled={false} onRecall={onRecall} renderedValue={renderedValue} />;
return (
<MetadataItemView
label={label}
isDisabled={false}
onRecall={showRecall ? onRecall : undefined}
renderedValue={renderedValue}
/>
);
};
const BadgeLoRA = ({
lora,
index,
handlers,
showCopy = false,
showRecall = true,
}: {
lora: LoRA;
index: number;
handlers: MetadataHandlers<LoRA[], LoRA>;
showCopy?: boolean;
showRecall?: boolean;
}) => {
const [renderedValue, setRenderedValue] = useState<React.ReactNode>(null);
const clipboard = useClipboard();
useEffect(() => {
const _renderValue = async () => {
if (!handlers.renderItemValue) {
setRenderedValue(`${lora.model.key} - ${lora.weight}`);
return;
}
try {
const rendered = await handlers.renderItemValue(lora);
setRenderedValue(rendered);
} catch {
setRenderedValue(`${lora.model.key} - ${lora.weight}`);
}
};
_renderValue();
}, [handlers, lora]);
const handleCopy = useCallback(() => {
clipboard.writeText(`${lora.model.key} - ${lora.weight}`);
}, [clipboard, lora]);
const onRecall = useCallback(() => {
if (!handlers.recallItem || !showRecall) {
return;
}
handlers.recallItem(lora, true).catch(() => {
// no-op, the toast will show the error
});
}, [handlers, lora, showRecall]);
return (
<VStack align="start" spacing={1} w="full">
<Text fontSize="xs" fontWeight="medium" color="base.300" textTransform="uppercase" letterSpacing="wide">
LoRA {index + 1}
</Text>
<VStack
position="relative"
w="full"
_hover={{
'& .hover-actions': {
opacity: 1,
}
}}
>
<Badge
colorScheme="purple"
variant="subtle"
fontSize="sm"
px={3}
py={2}
borderRadius="md"
w="full"
textAlign="center"
>
{renderedValue}
</Badge>
<HStack
className="hover-actions"
position="absolute"
top={-2}
right={-2}
opacity={0}
transition="opacity 0.2s"
spacing={1}
bg="base.900"
borderRadius="md"
p={1}
border="1px solid"
borderColor="base.700"
shadow="lg"
>
{showCopy && (
<Tooltip label="Copy to clipboard">
<IconButton
size="xs"
icon={<PiCopyBold />}
onClick={handleCopy}
colorScheme="base"
variant="ghost"
aria-label={`Copy LoRA ${index + 1} to clipboard`}
/>
</Tooltip>
)}
{showRecall && handlers.recallItem && (
<RecallButton
label={handlers.getLabel()}
onClick={onRecall}
isDisabled={false}
/>
)}
</HStack>
</VStack>
</VStack>
);
};

View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { extractMetadata } from 'features/metadata/util/metadataExtraction';
/**
* Hook for extracting metadata from different data structures
* @param data The data object that might contain metadata
* @returns The extracted metadata or null if not found
*/
export const useMetadataExtraction = (data: unknown): unknown => {
return useMemo(() => {
return extractMetadata(data);
}, [data]);
};

View File

@@ -0,0 +1,67 @@
/**
* Utility functions for extracting metadata from different sources
*/
/**
* Extracts metadata from a session graph
* @param session The session object containing the graph
* @returns The extracted metadata or null if not found
*/
export const extractMetadataFromSession = (session: { graph?: { nodes?: Record<string, unknown> } } | null): unknown => {
if (!session?.graph?.nodes) {
return null;
}
// Find the metadata node (core_metadata with unique suffix)
const nodeKeys = Object.keys(session.graph.nodes);
const metadataNodeKey = nodeKeys.find(key => key.startsWith('core_metadata:'));
if (!metadataNodeKey) {
return null;
}
return session.graph.nodes[metadataNodeKey];
};
/**
* Extracts metadata from an image DTO
* @param image The image DTO object
* @returns The extracted metadata or null if not found
*/
export const extractMetadataFromImage = (image: { metadata?: unknown } | null): unknown => {
return image?.metadata || null;
};
/**
* Generic metadata extraction that works with different data structures
* @param data The data object that might contain metadata
* @returns The extracted metadata or null if not found
*/
export const extractMetadata = (data: unknown): unknown => {
if (!data || typeof data !== 'object') {
return null;
}
// Try to extract from session graph
if ('session' in data && data.session && typeof data.session === 'object') {
const sessionMetadata = extractMetadataFromSession(data.session as any);
if (sessionMetadata) {
return sessionMetadata;
}
}
// Try to extract from image DTO
if ('metadata' in data) {
const imageMetadata = extractMetadataFromImage(data as any);
if (imageMetadata) {
return imageMetadata;
}
}
// If the data itself looks like metadata, return it
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
return data;
}
return null;
};