diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 1f71e39b9ea..61e85cbe2b9 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2340,7 +2340,8 @@
"next": "Next",
"saveToGallery": "Save To Gallery",
"showResultsOn": "Showing Results",
- "showResultsOff": "Hiding Results"
+ "showResultsOff": "Hiding Results",
+ "info": "Info"
}
},
"upscaling": {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx
index 3fa5e3e0cab..7c9d5f8721b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx
@@ -3,6 +3,7 @@ import { SimpleStagingAreaToolbarMenu } from 'features/controlLayers/components/
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 { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton';
import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton';
import { memo } from 'react';
@@ -16,6 +17,7 @@ export const SimpleStagingAreaToolbar = memo(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx
index 6b57e5cf93d..9fa3f5c9399 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx
@@ -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(() => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton.tsx
new file mode 100644
index 00000000000..2d8b598ee35
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarInfoButton.tsx
@@ -0,0 +1,172 @@
+import {
+ Divider,
+ Grid,
+ IconButton,
+ Popover,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Text,
+ VStack,
+} from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context';
+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';
+
+export const StagingAreaToolbarInfoButton = memo(({ isDisabled }: { isDisabled?: boolean }) => {
+ const ctx = useCanvasSessionContext();
+ const selectedItem = useStore(ctx.$selectedItem);
+ const { t } = useTranslation();
+
+ // Extract metadata using the unified hook
+ const metadata = useMetadataExtraction(selectedItem);
+
+ if (!selectedItem) {
+ return (
+ }
+ colorScheme="invokeBlue"
+ isDisabled={true}
+ />
+ );
+ }
+
+ return (
+
+
+ }
+ colorScheme="invokeBlue"
+ isDisabled={isDisabled}
+ />
+
+
+
+
+ {/* Prompts Section */}
+
+
+ Prompts
+
+
+ {metadata !== null && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ {/* Models and LoRAs Section - Left Column */}
+
+
+ {/* Model Section */}
+
+
+ Model
+
+
+ {metadata !== null && (
+
+
+
+
+ )}
+
+
+ {/* LoRA Section */}
+ {metadata !== null && }
+
+
+ {/* Other Settings Section - Right Column */}
+
+
+ Other Settings
+
+
+ {metadata !== null && (
+
+
+
+
+
+
+ )}
+
+
+
+ {/* Error Section */}
+ {selectedItem.error_message && (
+ <>
+
+
+
+ Error
+
+
+ {selectedItem.error_message}
+
+
+ >
+ )}
+
+
+
+
+ );
+});
+
+StagingAreaToolbarInfoButton.displayName = 'StagingAreaToolbarInfoButton';
diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx
index cfa3ee3df34..7ee70596d8c 100644
--- a/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx
+++ b/invokeai/frontend/web/src/features/metadata/components/MetadataItem.tsx
@@ -9,29 +9,58 @@ type MetadataItemProps = {
metadata: unknown;
handlers: MetadataHandlers;
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(({ metadata, handlers, direction = 'row' }: MetadataItemProps) => {
- const { label, isDisabled, value, renderedValue, onRecall } = useMetadataItem(metadata, handlers);
+const _MetadataItem = typedMemo(
+ ({
+ metadata,
+ handlers,
+ direction = 'row',
+ displayMode = 'default',
+ colorScheme = 'invokeBlue',
+ showCopy = false,
+ showRecall = true,
+ }: MetadataItemProps) => {
+ const { label, isDisabled, value, renderedValue, onRecall, valueOrNull } = useMetadataItem(metadata, handlers);
- if (value === MetadataParseFailedToken) {
- return null;
- }
+ if (value === MetadataParseFailedToken) {
+ return null;
+ }
- if (handlers.getIsVisible && !isSymbol(value) && !handlers.getIsVisible(value)) {
- return null;
- }
+ if (handlers.getIsVisible && !isSymbol(value) && !handlers.getIsVisible(value)) {
+ return null;
+ }
- return (
-
- );
-});
+ // For display modes other than default, we need the raw value for copy functionality
+ if (displayMode !== 'default') {
+ if (!valueOrNull) {
+ return null;
+ }
+ }
+
+ return (
+
+ );
+ }
+);
export const MetadataItem = typedMemo(_MetadataItem);
diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx
index 233e427798d..9aa1a8a6060 100644
--- a/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx
+++ b/invokeai/frontend/web/src/features/metadata/components/MetadataItemView.tsx
@@ -1,28 +1,229 @@
-import { Flex, Text } from '@invoke-ai/ui-library';
+import { Badge, Flex, HStack, IconButton, Text, Tooltip, VStack } from '@invoke-ai/ui-library';
+import { useClipboard } from 'common/hooks/useClipboard';
import { RecallButton } from 'features/metadata/components/RecallButton';
-import { memo } from 'react';
+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 (
-
- {onRecall && }
-
-
- {label}:
-
- {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 (
+
+ {onRecall && }
+
+
+ {label}:
+
+ {renderedValue}
+
-
- );
+ );
+ }
+
+ // Card display mode (for prompts)
+ if (displayMode === 'card') {
+ return (
+
+
+ {label}
+
+
+
+ {renderedValue}
+
+
+ {showCopy && (
+
+ }
+ onClick={handleCopy}
+ colorScheme="base"
+ variant="ghost"
+ aria-label={`Copy ${label} to clipboard`}
+ />
+
+ )}
+ {onRecall && }
+
+
+
+ );
+ }
+
+ // Simple display mode (for seed, steps, etc.)
+ if (displayMode === 'simple') {
+ return (
+
+
+ {label}
+
+
+
+ {renderedValue}
+
+
+ {showCopy && (
+
+ }
+ onClick={handleCopy}
+ colorScheme="base"
+ variant="ghost"
+ aria-label={`Copy ${label} to clipboard`}
+ />
+
+ )}
+ {onRecall && }
+
+
+
+ );
+ }
+
+ // Badge display mode (for models, etc.)
+ if (displayMode === 'badge') {
+ return (
+
+
+ {label}
+
+
+
+ {renderedValue}
+
+
+ {showCopy && (
+
+ }
+ onClick={handleCopy}
+ colorScheme="base"
+ variant="ghost"
+ aria-label={`Copy ${label} to clipboard`}
+ />
+
+ )}
+ {onRecall && }
+
+
+
+ );
+ }
+
+ return null;
}
);
diff --git a/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx b/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx
index 1b184979898..feced56ad8e 100644
--- a/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx
+++ b/invokeai/frontend/web/src/features/metadata/components/MetadataLoRAs.tsx
@@ -1,14 +1,24 @@
+import { Badge, HStack, IconButton, Text, Tooltip, VStack } from '@invoke-ai/ui-library';
+import { useClipboard } from 'common/hooks/useClipboard';
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 { 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([]);
useEffect(() => {
@@ -25,32 +35,72 @@ export const MetadataLoRAs = ({ metadata }: Props) => {
const label = useMemo(() => handlers.loras.getLabel(), []);
- return (
- <>
- {loras.map((lora) => (
-
- ))}
- >
- );
+ // Default display mode (original behavior)
+ if (displayMode === 'default') {
+ return (
+ <>
+ {loras.map((lora) => (
+
+ ))}
+ >
+ );
+ }
+
+ // Badge display mode (for staging area)
+ if (displayMode === 'badge') {
+ if (!loras || loras.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ LoRAs
+
+
+ {loras.map((lora: LoRA, index: number) => (
+
+ ))}
+
+
+ );
+ }
+
+ return null;
};
const MetadataViewLoRA = ({
label,
lora,
handlers,
+ showRecall = true,
}: {
label: string;
lora: LoRA;
handlers: MetadataHandlers;
+ 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(null);
useEffect(() => {
@@ -66,5 +116,120 @@ const MetadataViewLoRA = ({
_renderValue();
}, [handlers, lora]);
- return ;
+ return (
+
+ );
+};
+
+const BadgeLoRA = ({
+ lora,
+ index,
+ handlers,
+ showCopy = false,
+ showRecall = true,
+}: {
+ lora: LoRA;
+ index: number;
+ handlers: MetadataHandlers;
+ showCopy?: boolean;
+ showRecall?: boolean;
+}) => {
+ const [renderedValue, setRenderedValue] = useState(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 (
+
+
+ LoRA {index + 1}
+
+
+
+ {renderedValue}
+
+
+ {showCopy && (
+
+ }
+ onClick={handleCopy}
+ colorScheme="base"
+ variant="ghost"
+ aria-label={`Copy LoRA ${index + 1} to clipboard`}
+ />
+
+ )}
+ {showRecall && handlers.recallItem && (
+
+ )}
+
+
+
+ );
};
diff --git a/invokeai/frontend/web/src/features/metadata/hooks/useMetadataExtraction.ts b/invokeai/frontend/web/src/features/metadata/hooks/useMetadataExtraction.ts
new file mode 100644
index 00000000000..9bf3e208827
--- /dev/null
+++ b/invokeai/frontend/web/src/features/metadata/hooks/useMetadataExtraction.ts
@@ -0,0 +1,13 @@
+import { extractMetadata } from 'features/metadata/util/metadataExtraction';
+import { useMemo } from 'react';
+
+/**
+ * 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]);
+};
diff --git a/invokeai/frontend/web/src/features/metadata/util/metadataExtraction.ts b/invokeai/frontend/web/src/features/metadata/util/metadataExtraction.ts
new file mode 100644
index 00000000000..9b46c5added
--- /dev/null
+++ b/invokeai/frontend/web/src/features/metadata/util/metadataExtraction.ts
@@ -0,0 +1,89 @@
+/**
+ * Utility functions for extracting metadata from different sources
+ */
+
+/**
+ * Type guard to check if an object has a session property
+ */
+const hasSession = (data: unknown): data is { session: { graph?: { nodes?: Record } } } => {
+ return (
+ data !== null &&
+ typeof data === 'object' &&
+ 'session' in data &&
+ typeof (data as Record).session === 'object' &&
+ (data as Record).session !== null
+ );
+};
+
+/**
+ * Type guard to check if an object has a metadata property
+ */
+const hasMetadata = (data: unknown): data is { metadata: unknown } => {
+ return data !== null && typeof data === 'object' && 'metadata' in data;
+};
+
+/**
+ * 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 } } | 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 (hasSession(data)) {
+ const sessionMetadata = extractMetadataFromSession(data.session);
+ if (sessionMetadata) {
+ return sessionMetadata;
+ }
+ }
+
+ // Try to extract from image DTO
+ if (hasMetadata(data)) {
+ const imageMetadata = extractMetadataFromImage(data);
+ 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;
+};