diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index e6b88fd5427..230b5c68862 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -4,7 +4,7 @@ import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; -import { isPlainObject } from 'es-toolkit'; +import { isPlainObject, uniq } from 'es-toolkit'; import { clamp } from 'es-toolkit/compat'; import type { AspectRatioID, ParamsState, RgbaColor } from 'features/controlLayers/store/types'; import { @@ -19,6 +19,7 @@ import { isFluxKontextAspectRatioID, isGemini2_5AspectRatioID, isImagenAspectRatioID, + MAX_POSITIVE_PROMPT_HISTORY, zParamsState, } from 'features/controlLayers/store/types'; import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; @@ -192,6 +193,27 @@ const slice = createSlice({ positivePromptChanged: (state, action: PayloadAction) => { state.positivePrompt = action.payload; }, + positivePromptAddedToHistory: (state, action: PayloadAction) => { + const prompt = action.payload.trim(); + if (prompt.length === 0) { + return; + } + // Remove if already exists + state.positivePromptHistory = uniq(state.positivePromptHistory); + + // Add to front + state.positivePromptHistory.unshift(prompt); + + if (state.positivePromptHistory.length > MAX_POSITIVE_PROMPT_HISTORY) { + state.positivePromptHistory = state.positivePromptHistory.slice(0, MAX_POSITIVE_PROMPT_HISTORY); + } + }, + promptRemovedFromHistory: (state, action: PayloadAction) => { + state.positivePromptHistory = state.positivePromptHistory.filter((p) => p !== action.payload); + }, + promptHistoryCleared: (state) => { + state.positivePromptHistory = []; + }, negativePromptChanged: (state, action: PayloadAction) => { state.negativePrompt = action.payload; }, @@ -462,6 +484,9 @@ export const { setClipSkip, shouldUseCpuNoiseChanged, positivePromptChanged, + positivePromptAddedToHistory, + promptRemovedFromHistory, + promptHistoryCleared, negativePromptChanged, refinerModelChanged, setRefinerSteps, @@ -500,6 +525,12 @@ export const paramsSliceConfig: SliceConfig = { state.dimensions.height = state.dimensions.rect.height; } + if (state._version === 1) { + // v1 -> v2, add positive prompt history + state._version = 2; + state.positivePromptHistory = []; + } + return zParamsState.parse(state); }, }, @@ -600,6 +631,7 @@ export const selectShouldUseCPUNoise = createParamsSelector((params) => params.s export const selectUpscaleScheduler = createParamsSelector((params) => params.upscaleScheduler); export const selectUpscaleCfgScale = createParamsSelector((params) => params.upscaleCfgScale); +export const selectPositivePromptHistory = createParamsSelector((params) => params.positivePromptHistory); export const selectRefinerCFGScale = createParamsSelector((params) => params.refinerCFGScale); export const selectRefinerModel = createParamsSelector((params) => params.refinerModel); export const selectIsRefinerModelSelected = createParamsSelector((params) => Boolean(params.refinerModel)); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 757686f0940..1969fb77b64 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -612,8 +612,13 @@ const zDimensionsState = z.object({ aspectRatio: zAspectRatioConfig, }); +export const MAX_POSITIVE_PROMPT_HISTORY = 100; +const zPositivePromptHistory = z + .array(zParameterPositivePrompt) + .transform((arr) => arr.slice(0, MAX_POSITIVE_PROMPT_HISTORY)); + export const zParamsState = z.object({ - _version: z.literal(1), + _version: z.literal(2), maskBlur: z.number(), maskBlurMethod: zParameterMaskBlurMethod, canvasCoherenceMode: zParameterCanvasCoherenceMode, @@ -644,6 +649,7 @@ export const zParamsState = z.object({ clipSkip: z.number(), shouldUseCpuNoise: z.boolean(), positivePrompt: zParameterPositivePrompt, + positivePromptHistory: zPositivePromptHistory, negativePrompt: zParameterNegativePrompt, refinerModel: zParameterSDXLRefinerModel.nullable(), refinerSteps: z.number(), @@ -661,7 +667,7 @@ export const zParamsState = z.object({ }); export type ParamsState = z.infer; export const getInitialParamsState = (): ParamsState => ({ - _version: 1, + _version: 2, maskBlur: 16, maskBlurMethod: 'box', canvasCoherenceMode: 'Gaussian Blur', @@ -692,6 +698,7 @@ export const getInitialParamsState = (): ParamsState => ({ clipSkip: 0, shouldUseCpuNoise: true, positivePrompt: '', + positivePromptHistory: [], negativePrompt: null, refinerModel: null, refinerSteps: 20, diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index 3f6c88690a4..ac8626eeb7c 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -32,6 +32,8 @@ import type { HotkeyCallback } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; +import { PositivePromptHistoryIconButton } from './PositivePromptHistory'; + const persistOptions: Parameters[2] = { trackWidth: false, trackHeight: true, @@ -118,6 +120,7 @@ export const ParamPositivePrompt = memo(() => { + {activeTab !== 'video' && modelSupportsNegativePrompt && } {isPromptExpansionEnabled && } diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/PositivePromptHistory.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/PositivePromptHistory.tsx new file mode 100644 index 00000000000..be5774da5db --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/Core/PositivePromptHistory.tsx @@ -0,0 +1,160 @@ +import { + Button, + Divider, + Flex, + IconButton, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Portal, + Text, + useShiftModifier, +} from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { + positivePromptChanged, + promptHistoryCleared, + promptRemovedFromHistory, + selectPositivePromptHistory, +} from 'features/controlLayers/store/paramsSlice'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { PiArrowArcLeftBold, PiClockCounterClockwise, PiTrashBold, PiTrashSimpleBold } from 'react-icons/pi'; + +export const PositivePromptHistoryIconButton = memo(() => { + return ( + + + } + tooltip="Prompt History" + /> + + + + + + + + + + ); +}); + +PositivePromptHistoryIconButton.displayName = 'PositivePromptHistoryIconButton'; + +const PromptHistoryContent = memo(() => { + const dispatch = useAppDispatch(); + const positivePromptHistory = useAppSelector(selectPositivePromptHistory); + const [searchTerm, setSearchTerm] = useState(''); + + const onClickClearHistory = useCallback(() => { + dispatch(promptHistoryCleared()); + }, [dispatch]); + + const filteredPrompts = useMemo(() => { + const trimmedSearchTerm = searchTerm.trim(); + if (!trimmedSearchTerm) { + return positivePromptHistory; + } + return positivePromptHistory.filter((prompt) => prompt.toLowerCase().includes(trimmedSearchTerm.toLowerCase())); + }, [positivePromptHistory, searchTerm]); + + const onChangeSearchTerm = useCallback((e: ChangeEvent) => { + setSearchTerm(e.target.value); + }, []); + + return ( + + + + Prompt History + + + + + + {positivePromptHistory.length === 0 && ( + + No prompt history recorded. + + )} + {positivePromptHistory.length !== 0 && filteredPrompts.length === 0 && ( + + No matching prompts in history.{' '} + + )} + {filteredPrompts.length > 0 && ( + + + {filteredPrompts.map((prompt, index) => ( + + ))} + + + )} + + ); +}); +PromptHistoryContent.displayName = 'PromptHistoryContent'; + +const PromptItem = memo(({ prompt }: { prompt: string }) => { + const dispatch = useAppDispatch(); + const shiftKey = useShiftModifier(); + + const onClickUse = useCallback(() => { + dispatch(positivePromptChanged(prompt)); + }, [dispatch, prompt]); + + const onClickDelete = useCallback(() => { + dispatch(promptRemovedFromHistory(prompt)); + }, [dispatch, prompt]); + + return ( + + {!shiftKey && ( + } + onClick={onClickUse} + /> + )} + {shiftKey && ( + } + onClick={onClickDelete} + colorScheme="error" + /> + )} + {prompt} + + ); +}); +PromptItem.displayName = 'PromptItem'; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts index 5e2dcf4fdb3..9d5a589f056 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts @@ -7,6 +7,7 @@ import { extractMessageFromAssertionError } from 'common/util/extractMessageFrom import { withResult, withResultAsync } from 'common/util/result'; import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { positivePromptAddedToHistory, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph'; import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph'; @@ -130,6 +131,9 @@ const enqueueCanvas = async (store: AppStore, canvasManager: CanvasManager, prep const enqueueResult = await req.unwrap(); + // Push to prompt history on successful enqueue + dispatch(positivePromptAddedToHistory(selectPositivePrompt(state))); + return { batchConfig, enqueueResult }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts index 07584530230..1529a87cff9 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts @@ -5,6 +5,7 @@ import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResult, withResultAsync } from 'common/util/result'; +import { positivePromptAddedToHistory, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph'; import { buildCogView4Graph } from 'features/nodes/util/graph/generation/buildCogView4Graph'; @@ -124,6 +125,9 @@ const enqueueGenerate = async (store: AppStore, prepend: boolean) => { const enqueueResult = await req.unwrap(); + // Push to prompt history on successful enqueue + dispatch(positivePromptAddedToHistory(selectPositivePrompt(state))); + return { batchConfig, enqueueResult }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts index c92e57d7bc3..01f278d98db 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts @@ -2,6 +2,7 @@ import { createAction } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; +import { positivePromptAddedToHistory, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildMultidiffusionUpscaleGraph } from 'features/nodes/util/graph/buildMultidiffusionUpscaleGraph'; import { useCallback } from 'react'; @@ -43,6 +44,9 @@ const enqueueUpscaling = async (store: AppStore, prepend: boolean) => { ); const enqueueResult = await req.unwrap(); + // Push to prompt history on successful enqueue + dispatch(positivePromptAddedToHistory(selectPositivePrompt(state))); + return { batchConfig, enqueueResult }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts index d8d0a1ecf79..183026c3632 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -5,6 +5,7 @@ import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResult, withResultAsync } from 'common/util/result'; +import { positivePromptAddedToHistory, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildRunwayVideoGraph } from 'features/nodes/util/graph/generation/buildRunwayVideoGraph'; import { buildVeo3VideoGraph } from 'features/nodes/util/graph/generation/buildVeo3VideoGraph'; @@ -108,6 +109,9 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { const enqueueResult = await req.unwrap(); + // Push to prompt history on successful enqueue + dispatch(positivePromptAddedToHistory(selectPositivePrompt(state))); + return { batchConfig, enqueueResult }; };