diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 6b11762c9ec..e773abdbc2b 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,7 +1,7 @@ import io import json import traceback -from typing import ClassVar, Optional +from typing import ClassVar, Literal, Optional from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import FileResponse @@ -38,6 +38,19 @@ IMAGE_MAX_AGE = 31536000 +class ImageSearchBody(BaseModel): + file_name_term: Optional[str] = Field(default=None) + metadata_term: Optional[str] = Field(default=None) + width_min: Optional[int] = Field(default=None) + width_max: Optional[int] = Field(default=None) + width_exact: Optional[int] = Field(default=None) + height_min: Optional[int] = Field(default=None) + height_max: Optional[int] = Field(default=None) + height_exact: Optional[int] = Field(default=None) + board_ids: Optional[list[str]] = Field(default=None) + starred_mode: Literal["include", "exclude", "only"] = Field(default="include") + + class ResizeToDimensions(BaseModel): width: int = Field(..., gt=0) height: int = Field(..., gt=0) @@ -389,6 +402,18 @@ async def list_image_dtos( order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), + file_name_term: Optional[str] = Query(default=None, description="File name search term"), + metadata_term: Optional[str] = Query(default=None, description="Metadata search term"), + width_min: Optional[int] = Query(default=None, description="Minimum image width"), + width_max: Optional[int] = Query(default=None, description="Maximum image width"), + width_exact: Optional[int] = Query(default=None, description="Exact image width"), + height_min: Optional[int] = Query(default=None, description="Minimum image height"), + height_max: Optional[int] = Query(default=None, description="Maximum image height"), + height_exact: Optional[int] = Query(default=None, description="Exact image height"), + board_ids: Optional[list[str]] = Query(default=None, description="Boards to include, supports 'none'"), + starred_mode: Literal["include", "exclude", "only"] = Query( + default="include", description="How to handle starred images" + ), ) -> OffsetPaginatedResults[ImageDTO]: """Gets a list of image DTOs for the current user""" @@ -402,6 +427,16 @@ async def list_image_dtos( is_intermediate, board_id, search_term, + file_name_term, + metadata_term, + width_min, + width_max, + width_exact, + height_min, + height_max, + height_exact, + board_ids, + starred_mode, current_user.user_id, ) @@ -591,6 +626,18 @@ async def get_image_names( order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), + file_name_term: Optional[str] = Query(default=None, description="File name search term"), + metadata_term: Optional[str] = Query(default=None, description="Metadata search term"), + width_min: Optional[int] = Query(default=None, description="Minimum image width"), + width_max: Optional[int] = Query(default=None, description="Maximum image width"), + width_exact: Optional[int] = Query(default=None, description="Exact image width"), + height_min: Optional[int] = Query(default=None, description="Minimum image height"), + height_max: Optional[int] = Query(default=None, description="Maximum image height"), + height_exact: Optional[int] = Query(default=None, description="Exact image height"), + board_ids: Optional[list[str]] = Query(default=None, description="Boards to include, supports 'none'"), + starred_mode: Literal["include", "exclude", "only"] = Query( + default="include", description="How to handle starred images" + ), ) -> ImageNamesResult: """Gets ordered list of image names with metadata for optimistic updates""" @@ -603,6 +650,16 @@ async def get_image_names( is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, + file_name_term=file_name_term, + metadata_term=metadata_term, + width_min=width_min, + width_max=width_max, + width_exact=width_exact, + height_min=height_min, + height_max=height_max, + height_exact=height_exact, + board_ids=board_ids, + starred_mode=starred_mode, user_id=current_user.user_id, is_admin=current_user.is_admin, ) @@ -611,6 +668,66 @@ async def get_image_names( raise HTTPException(status_code=500, detail="Failed to get image names") +@images_router.post("/search", operation_id="search_images", response_model=OffsetPaginatedResults[ImageDTO]) +async def search_images( + body: ImageSearchBody, + image_origin: Optional[ResourceOrigin] = Query(default=None), + categories: Optional[list[ImageCategory]] = Query(default=None), + is_intermediate: Optional[bool] = Query(default=None), + offset: int = Query(default=0), + limit: int = Query(default=100), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending), + starred_first: bool = Query(default=True), +) -> OffsetPaginatedResults[ImageDTO]: + return ApiDependencies.invoker.services.images.get_many( + offset=offset, + limit=limit, + starred_first=starred_first, + order_dir=order_dir, + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + file_name_term=body.file_name_term, + metadata_term=body.metadata_term, + width_min=body.width_min, + width_max=body.width_max, + width_exact=body.width_exact, + height_min=body.height_min, + height_max=body.height_max, + height_exact=body.height_exact, + board_ids=body.board_ids, + starred_mode=body.starred_mode, + ) + + +@images_router.post("/search/names", operation_id="search_image_names", response_model=ImageNamesResult) +async def search_image_names( + body: ImageSearchBody, + image_origin: Optional[ResourceOrigin] = Query(default=None), + categories: Optional[list[ImageCategory]] = Query(default=None), + is_intermediate: Optional[bool] = Query(default=None), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending), + starred_first: bool = Query(default=True), +) -> ImageNamesResult: + return ApiDependencies.invoker.services.images.get_image_names( + starred_first=starred_first, + order_dir=order_dir, + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + file_name_term=body.file_name_term, + metadata_term=body.metadata_term, + width_min=body.width_min, + width_max=body.width_max, + width_exact=body.width_exact, + height_min=body.height_min, + height_max=body.height_max, + height_exact=body.height_exact, + board_ids=body.board_ids, + starred_mode=body.starred_mode, + ) + + @images_router.post( "/images_by_names", operation_id="get_images_by_names", diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 16405c52708..6fe68870fd8 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -50,6 +50,16 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> OffsetPaginatedResults[ImageRecord]: @@ -112,6 +122,16 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> ImageNamesResult: diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index c6c237fc1e7..486e17320ad 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -123,6 +123,125 @@ def update( except sqlite3.Error as e: raise ImageRecordSaveException from e + def _append_search_conditions( + self, + query_conditions: str, + query_params: list[Union[int, str, bool]], + *, + board_id: Optional[str], + search_term: Optional[str], + file_name_term: Optional[str], + metadata_term: Optional[str], + width_min: Optional[int], + width_max: Optional[int], + width_exact: Optional[int], + height_min: Optional[int], + height_max: Optional[int], + height_exact: Optional[int], + board_ids: Optional[list[str]], + starred_mode: Optional[str], + user_id: Optional[str] = None, + is_admin: bool = False, + ) -> tuple[str, list[Union[int, str, bool]]]: + if board_ids: + normalized_board_ids = list(dict.fromkeys(board_ids)) + include_none = "none" in normalized_board_ids + explicit_board_ids = [b for b in normalized_board_ids if b != "none"] + board_subconditions: list[str] = [] + if explicit_board_ids: + placeholders = ",".join("?" * len(explicit_board_ids)) + board_subconditions.append(f"board_images.board_id IN ({placeholders})") + query_params.extend(explicit_board_ids) + if include_none: + board_subconditions.append("board_images.board_id IS NULL") + if board_subconditions: + query_conditions += f"""--sql + AND ({" OR ".join(board_subconditions)}) + """ + elif board_id == "none": + query_conditions += """--sql + AND board_images.board_id IS NULL + """ + # For uncategorized images, filter by user_id to ensure per-user isolation + # Admin users can see all uncategorized images from all users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) + elif board_id is not None: + query_conditions += """--sql + AND board_images.board_id = ? + """ + query_params.append(board_id) + + if search_term: + like_term = f"%{search_term.lower()}%" + query_conditions += """--sql + AND ( + images.metadata LIKE ? + OR images.created_at LIKE ? + OR images.image_name LIKE ? + ) + """ + query_params.extend([like_term, like_term, like_term]) + + if file_name_term: + query_conditions += """--sql + AND images.image_name LIKE ? + """ + query_params.append(f"%{file_name_term.lower()}%") + + if metadata_term: + query_conditions += """--sql + AND images.metadata LIKE ? + """ + query_params.append(f"%{metadata_term.lower()}%") + + if starred_mode == "only": + query_conditions += """--sql + AND images.starred = TRUE + """ + elif starred_mode == "exclude": + query_conditions += """--sql + AND images.starred = FALSE + """ + if width_exact is not None: + query_conditions += """--sql + AND images.width = ? + """ + query_params.append(width_exact) + else: + if width_min is not None: + query_conditions += """--sql + AND images.width >= ? + """ + query_params.append(width_min) + if width_max is not None: + query_conditions += """--sql + AND images.width <= ? + """ + query_params.append(width_max) + + if height_exact is not None: + query_conditions += """--sql + AND images.height = ? + """ + query_params.append(height_exact) + else: + if height_min is not None: + query_conditions += """--sql + AND images.height >= ? + """ + query_params.append(height_min) + if height_max is not None: + query_conditions += """--sql + AND images.height <= ? + """ + query_params.append(height_max) + + return query_conditions, query_params + def get_many( self, offset: int = 0, @@ -134,6 +253,16 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> OffsetPaginatedResults[ImageRecord]: @@ -183,34 +312,24 @@ def get_many( query_params.append(is_intermediate) - # board_id of "none" is reserved for images without a board - if board_id == "none": - query_conditions += """--sql - AND board_images.board_id IS NULL - """ - # For uncategorized images, filter by user_id to ensure per-user isolation - # Admin users can see all uncategorized images from all users - if user_id is not None and not is_admin: - query_conditions += """--sql - AND images.user_id = ? - """ - query_params.append(user_id) - elif board_id is not None: - query_conditions += """--sql - AND board_images.board_id = ? - """ - query_params.append(board_id) - - # Search term condition - if search_term: - query_conditions += """--sql - AND ( - images.metadata LIKE ? - OR images.created_at LIKE ? - ) - """ - query_params.append(f"%{search_term.lower()}%") - query_params.append(f"%{search_term.lower()}%") + query_conditions, query_params = self._append_search_conditions( + query_conditions, + query_params, + board_id=board_id, + search_term=search_term, + file_name_term=file_name_term, + metadata_term=metadata_term, + width_min=width_min, + width_max=width_max, + width_exact=width_exact, + height_min=height_min, + height_max=height_max, + height_exact=height_exact, + board_ids=board_ids, + starred_mode=starred_mode, + user_id=user_id, + is_admin=is_admin, + ) if starred_first: query_pagination = f"""--sql @@ -398,6 +517,16 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> ImageNamesResult: @@ -427,32 +556,24 @@ def get_image_names( """ query_params.append(is_intermediate) - if board_id == "none": - query_conditions += """--sql - AND board_images.board_id IS NULL - """ - # For uncategorized images, filter by user_id to ensure per-user isolation - # Admin users can see all uncategorized images from all users - if user_id is not None and not is_admin: - query_conditions += """--sql - AND images.user_id = ? - """ - query_params.append(user_id) - elif board_id is not None: - query_conditions += """--sql - AND board_images.board_id = ? - """ - query_params.append(board_id) - - if search_term: - query_conditions += """--sql - AND ( - images.metadata LIKE ? - OR images.created_at LIKE ? - ) - """ - query_params.append(f"%{search_term.lower()}%") - query_params.append(f"%{search_term.lower()}%") + query_conditions, query_params = self._append_search_conditions( + query_conditions, + query_params, + board_id=board_id, + search_term=search_term, + file_name_term=file_name_term, + metadata_term=metadata_term, + width_min=width_min, + width_max=width_max, + width_exact=width_exact, + height_min=height_min, + height_max=height_max, + height_exact=height_exact, + board_ids=board_ids, + starred_mode=starred_mode, + user_id=user_id, + is_admin=is_admin, + ) # Get starred count if starred_first is enabled starred_count = 0 diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index d11d75b3c1d..0d1e8e02a85 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -126,6 +126,16 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> OffsetPaginatedResults[ImageDTO]: @@ -162,6 +172,16 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> ImageNamesResult: diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index e82bd7f4de1..1ae07d543ee 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -217,6 +217,16 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> OffsetPaginatedResults[ImageDTO]: @@ -231,6 +241,16 @@ def get_many( is_intermediate, board_id, search_term, + file_name_term, + metadata_term, + width_min, + width_max, + width_exact, + height_min, + height_max, + height_exact, + board_ids, + starred_mode, user_id, is_admin, ) @@ -326,6 +346,16 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + file_name_term: Optional[str] = None, + metadata_term: Optional[str] = None, + width_min: Optional[int] = None, + width_max: Optional[int] = None, + width_exact: Optional[int] = None, + height_min: Optional[int] = None, + height_max: Optional[int] = None, + height_exact: Optional[int] = None, + board_ids: Optional[list[str]] = None, + starred_mode: Optional[str] = None, user_id: Optional[str] = None, is_admin: bool = False, ) -> ImageNamesResult: @@ -338,6 +368,16 @@ def get_image_names( is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, + file_name_term=file_name_term, + metadata_term=metadata_term, + width_min=width_min, + width_max=width_max, + width_exact=width_exact, + height_min=height_min, + height_max=height_max, + height_exact=height_exact, + board_ids=board_ids, + starred_mode=starred_mode, user_id=user_id, is_admin=is_admin, ) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2db971d06a6..0d25ff21c13 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -874,6 +874,10 @@ "starImage": { "title": "Star/Unstar Image", "desc": "Star or unstar the selected image." + }, + "openSearch": { + "title": "Open Search Dialog", + "desc": "Open the image search dialog." } } }, diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx index 7c57aa08497..1df6ea57961 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryPanel.tsx @@ -1,30 +1,24 @@ -import { Box, Button, ButtonGroup, Collapse, Divider, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; +import { Button, ButtonGroup, Divider, Flex, Spacer } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useDisclosure } from 'common/hooks/useBoolean'; -import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useGallerySearchTerm'; import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; import { galleryViewChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel'; import { selectShouldUsePagedGalleryView } from 'features/ui/store/uiSelectors'; -import type { CSSProperties } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi'; +import { PiCaretDownBold, PiCaretUpBold } from 'react-icons/pi'; import { useBoardName } from 'services/api/hooks/useBoardName'; import { GalleryImageGrid } from './GalleryImageGrid'; import { GalleryImageGridPaged } from './GalleryImageGridPaged'; import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover'; import { GalleryUploadButton } from './GalleryUploadButton'; -import { GallerySearch } from './ImageGrid/GallerySearch'; - -const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '100%' }; +import { ImageSearchModal } from './ImageSearchModal'; const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); -const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); export const GalleryPanel = memo(() => { const { t } = useTranslation(); @@ -33,10 +27,7 @@ export const GalleryPanel = memo(() => { const galleryPanel = useGalleryPanel(tab); const isCollapsed = useStore(galleryPanel.$isCollapsed); const galleryView = useAppSelector(selectGalleryView); - const initialSearchTerm = useAppSelector(selectSearchTerm); const shouldUsePagedGalleryView = useAppSelector(selectShouldUsePagedGalleryView); - const searchDisclosure = useDisclosure(!!initialSearchTerm); - const [searchTerm, onChangeSearchTerm, onResetSearchTerm] = useGallerySearchTerm(); const handleClickImages = useCallback(() => { dispatch(galleryViewChanged('images')); }, [dispatch]); @@ -45,14 +36,6 @@ export const GalleryPanel = memo(() => { dispatch(galleryViewChanged('assets')); }, [dispatch]); - const handleClickSearch = useCallback(() => { - onResetSearchTerm(); - if (!searchDisclosure.isOpen && galleryPanel.$isCollapsed.get()) { - galleryPanel.expand(); - } - searchDisclosure.toggle(); - }, [galleryPanel, onResetSearchTerm, searchDisclosure]); - const selectedBoardId = useAppSelector(selectSelectedBoardId); const boardName = useBoardName(selectedBoardId); @@ -91,26 +74,9 @@ export const GalleryPanel = memo(() => { - } - /> + - - - - - {shouldUsePagedGalleryView ? : } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx deleted file mode 100644 index 3c329c90e82..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library'; -import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names'; -import type { ChangeEvent, KeyboardEvent } from 'react'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiXBold } from 'react-icons/pi'; - -type Props = { - searchTerm: string; - onChangeSearchTerm: (value: string) => void; - onResetSearchTerm: () => void; -}; - -export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { - const { t } = useTranslation(); - const { isFetching } = useGalleryImageNames(); - - const handleChangeInput = useCallback( - (e: ChangeEvent) => { - onChangeSearchTerm(e.target.value); - }, - [onChangeSearchTerm] - ); - - const handleKeydown = useCallback( - (e: KeyboardEvent) => { - // exit search mode on escape - if (e.key === 'Escape') { - onResetSearchTerm(); - } - }, - [onResetSearchTerm] - ); - - return ( - - - {isFetching && ( - - - - )} - {!isFetching && searchTerm.length && ( - - } - /> - - )} - - ); -}); - -GallerySearch.displayName = 'GallerySearch'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts deleted file mode 100644 index 043e630c2ad..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectSearchTerm } from 'features/gallery/store/gallerySelectors'; -import { searchTermChanged } from 'features/gallery/store/gallerySlice'; -import { useCallback } from 'react'; - -export const useGallerySearchTerm = () => { - // Highlander! - // useAssertSingleton('gallery-search-state'); - - const dispatch = useAppDispatch(); - const searchTerm = useAppSelector(selectSearchTerm); - - const onChange = useCallback( - (val: string) => { - dispatch(searchTermChanged(val)); - }, - [dispatch] - ); - - const onReset = useCallback(() => { - dispatch(searchTermChanged('')); - }, [dispatch]); - - return [searchTerm, onChange, onReset] as const; -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageSearchModal.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageSearchModal.tsx new file mode 100644 index 00000000000..fc12421ac70 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageSearchModal.tsx @@ -0,0 +1,590 @@ +import type { ComboboxOption } from '@invoke-ai/ui-library'; +import { + Box, + Checkbox, + Combobox, + Divider, + Flex, + FormControl, + FormLabel, + Grid, + IconButton, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Spinner, + Text, +} from '@invoke-ai/ui-library'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import type { SingleValue } from 'chakra-react-select'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { ChangeEvent, UIEvent } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { PiMagnifyingGlassBold } from 'react-icons/pi'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import type { ImageSearchArgs, StarredMode } from 'services/api/endpoints/imageSearch'; +import { useSearchImagesQuery } from 'services/api/endpoints/imageSearch'; +import type { ImageDTO } from 'services/api/types'; +import { assert } from 'tsafe'; +import { useDebounce } from 'use-debounce'; + +import { GalleryImage } from './ImageGrid/GalleryImage'; + +const PAGE_SIZE = 60; +const LOAD_MORE_THRESHOLD_PX = 300; +const SEARCH_DEBOUNCE_MS = 300; + +type DimensionMode = 'lte' | 'eq' | 'gte' | 'between'; + +type EffectiveSearchFormState = Omit; + +type SearchFormState = EffectiveSearchFormState & { + width_mode: DimensionMode; + height_mode: DimensionMode; +}; + +type AnchorSnapshot = { + imageName: string; + topInContainer: number; +}; + +const defaultFormState: SearchFormState = { + file_name_enabled: true, + file_name_term: '', + metadata_enabled: true, + metadata_term: '', + width_enabled: false, + width_mode: 'between', + width_min: '', + width_max: '', + width_exact: '', + height_enabled: false, + height_mode: 'between', + height_min: '', + height_max: '', + height_exact: '', + board_ids: [], + starred_mode: 'include', +}; + +const SearchResultItem = memo(({ imageDTO }: { imageDTO: ImageDTO }) => ); +SearchResultItem.displayName = 'SearchResultItem'; + +export const ImageSearchModal = memo(() => { + const [isOpen, setIsOpen] = useState(false); + const [formState, setFormState] = useState(defaultFormState); + const [page, setPage] = useState(0); + const [allItems, setAllItems] = useState([]); + const [total, setTotal] = useState(0); + + const resultsRef = useRef(null); + + const { data: boards } = useListAllBoardsQuery({ include_archived: true }); + + const openModal = useCallback(() => setIsOpen(true), []); + const closeModal = useCallback(() => setIsOpen(false), []); + + useRegisteredHotkeys({ + category: 'gallery', + id: 'openSearch', + callback: (e) => { + e.preventDefault(); + openModal(); + }, + options: { enabled: true, preventDefault: true }, + dependencies: [openModal], + }); + + const dimensionModeOptions = useMemo( + () => [ + { value: 'lte', label: '≤' }, + { value: 'eq', label: '=' }, + { value: 'gte', label: '≥' }, + { value: 'between', label: 'Between' }, + ], + [] + ); + + const starredModeOptions = useMemo( + () => [ + { value: 'include', label: 'Include Starred' }, + { value: 'exclude', label: 'Exclude Starred' }, + { value: 'only', label: 'Only Starred' }, + ], + [] + ); + + const widthModeValue = useMemo( + () => dimensionModeOptions.find((opt) => opt.value === formState.width_mode), + [dimensionModeOptions, formState.width_mode] + ); + + const heightModeValue = useMemo( + () => dimensionModeOptions.find((opt) => opt.value === formState.height_mode), + [dimensionModeOptions, formState.height_mode] + ); + + const starredModeValue = useMemo( + () => starredModeOptions.find((opt) => opt.value === formState.starred_mode), + [formState.starred_mode, starredModeOptions] + ); + + const onStarredModeChange = useCallback((v: SingleValue) => { + assert(v?.value === 'include' || v?.value === 'exclude' || v?.value === 'only'); + setFormState((p) => ({ ...p, starred_mode: v.value as StarredMode })); + }, []); + + const getAnchorSnapshot = useCallback((): AnchorSnapshot | null => { + const container = resultsRef.current; + if (!container) { + return null; + } + const containerRect = container.getBoundingClientRect(); + const itemEls = container.querySelectorAll('[data-image-name]'); + for (const el of itemEls) { + const rect = el.getBoundingClientRect(); + if (rect.bottom > containerRect.top) { + const imageName = el.dataset.imageName; + if (!imageName) { + continue; + } + return { + imageName, + topInContainer: rect.top - containerRect.top, + }; + } + } + return null; + }, []); + + const restoreAnchorSnapshot = useCallback((anchor: AnchorSnapshot | null) => { + if (!anchor) { + return; + } + const container = resultsRef.current; + if (!container) { + return; + } + window.requestAnimationFrame(() => { + const target = container.querySelector(`[data-image-name="${anchor.imageName}"]`); + if (!target) { + return; + } + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const currentTop = targetRect.top - containerRect.top; + container.scrollTop += currentTop - anchor.topInContainer; + }); + }, []); + + const onFileNameEnabledChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, file_name_enabled: e.target.checked })); + }, []); + const onFileNameTermChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, file_name_term: e.target.value })); + }, []); + const onMetadataEnabledChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, metadata_enabled: e.target.checked })); + }, []); + const onMetadataTermChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, metadata_term: e.target.value })); + }, []); + const onWidthEnabledChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, width_enabled: e.target.checked })); + }, []); + const onHeightEnabledChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, height_enabled: e.target.checked })); + }, []); + + const onWidthModeChange = useCallback((v: SingleValue) => { + assert(v?.value === 'lte' || v?.value === 'eq' || v?.value === 'gte' || v?.value === 'between'); + setFormState((p) => ({ ...p, width_mode: v.value as DimensionMode })); + }, []); + + const onHeightModeChange = useCallback((v: SingleValue) => { + assert(v?.value === 'lte' || v?.value === 'eq' || v?.value === 'gte' || v?.value === 'between'); + setFormState((p) => ({ ...p, height_mode: v.value as DimensionMode })); + }, []); + + const onWidthMinChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, width_min: e.target.value })); + }, []); + const onWidthMaxChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, width_max: e.target.value })); + }, []); + const onWidthExactChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, width_exact: e.target.value })); + }, []); + const onHeightMinChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, height_min: e.target.value })); + }, []); + const onHeightMaxChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, height_max: e.target.value })); + }, []); + const onHeightExactChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ ...p, height_exact: e.target.value })); + }, []); + + const onAllBoardsChange = useCallback(() => { + setFormState((p) => ({ ...p, board_ids: [] })); + }, []); + + const onUncategorizedChange = useCallback((e: ChangeEvent) => { + setFormState((p) => ({ + ...p, + board_ids: e.target.checked ? [...new Set([...p.board_ids, 'none'])] : p.board_ids.filter((id) => id !== 'none'), + })); + }, []); + + const onBoardToggle = useCallback((e: ChangeEvent) => { + const boardId = e.target.value; + setFormState((p) => ({ + ...p, + board_ids: e.target.checked + ? [...new Set([...p.board_ids, boardId])] + : p.board_ids.filter((id) => id !== boardId), + })); + }, []); + + const effectiveFileNameTerm = formState.file_name_enabled ? formState.file_name_term : ''; + const effectiveMetadataTerm = formState.metadata_enabled ? formState.metadata_term : ''; + const effectiveWidthMin = + formState.width_enabled && (formState.width_mode === 'gte' || formState.width_mode === 'between') + ? formState.width_min + : ''; + const effectiveWidthMax = + formState.width_enabled && (formState.width_mode === 'lte' || formState.width_mode === 'between') + ? formState.width_max + : ''; + const effectiveWidthExact = formState.width_enabled && formState.width_mode === 'eq' ? formState.width_exact : ''; + const effectiveHeightMin = + formState.height_enabled && (formState.height_mode === 'gte' || formState.height_mode === 'between') + ? formState.height_min + : ''; + const effectiveHeightMax = + formState.height_enabled && (formState.height_mode === 'lte' || formState.height_mode === 'between') + ? formState.height_max + : ''; + const effectiveHeightExact = formState.height_enabled && formState.height_mode === 'eq' ? formState.height_exact : ''; + + const effectiveFormState = useMemo(() => { + const boardIds = formState.board_ids.length > 0 ? formState.board_ids : EMPTY_ARRAY; + + return { + board_ids: boardIds, + starred_mode: formState.starred_mode, + file_name_enabled: formState.file_name_enabled, + file_name_term: effectiveFileNameTerm, + metadata_enabled: formState.metadata_enabled, + metadata_term: effectiveMetadataTerm, + width_enabled: formState.width_enabled, + width_min: effectiveWidthMin, + width_max: effectiveWidthMax, + width_exact: effectiveWidthExact, + height_enabled: formState.height_enabled, + height_min: effectiveHeightMin, + height_max: effectiveHeightMax, + height_exact: effectiveHeightExact, + } satisfies EffectiveSearchFormState; + }, [ + effectiveFileNameTerm, + effectiveHeightExact, + effectiveHeightMax, + effectiveHeightMin, + effectiveMetadataTerm, + effectiveWidthExact, + effectiveWidthMax, + effectiveWidthMin, + formState.board_ids, + formState.file_name_enabled, + formState.height_enabled, + formState.metadata_enabled, + formState.starred_mode, + formState.width_enabled, + ]); + + const [debouncedEffectiveFormState] = useDebounce(effectiveFormState, SEARCH_DEBOUNCE_MS); + + const queryArgs = useMemo( + () => ({ + ...debouncedEffectiveFormState, + offset: page * PAGE_SIZE, + limit: PAGE_SIZE, + }), + [debouncedEffectiveFormState, page] + ); + + const headQueryArgs = useMemo( + () => ({ + ...debouncedEffectiveFormState, + offset: 0, + limit: PAGE_SIZE, + }), + [debouncedEffectiveFormState] + ); + + const { data, isFetching } = useSearchImagesQuery(queryArgs, { skip: !isOpen }); + const { data: headData } = useSearchImagesQuery(headQueryArgs, { skip: !isOpen || page === 0 }); + + useEffect(() => { + setPage(0); + setAllItems([]); + }, [debouncedEffectiveFormState]); + + useEffect(() => { + if (!data) { + return; + } + + setTotal(data.total); + setAllItems((prev) => { + if (page === 0) { + return data.items; + } + const seen = new Set(prev.map((i) => i.image_name)); + const next = [...prev]; + for (const item of data.items) { + if (!seen.has(item.image_name)) { + next.push(item); + } + } + return next; + }); + }, [data, page]); + + useEffect(() => { + if (!headData || page === 0) { + return; + } + const anchor = getAnchorSnapshot(); + setTotal(headData.total); + setAllItems((prev) => { + const pinned = [...headData.items]; + const pinnedNames = new Set(pinned.map((i) => i.image_name)); + for (const item of prev) { + if (!pinnedNames.has(item.image_name)) { + pinned.push(item); + } + } + return pinned; + }); + restoreAnchorSnapshot(anchor); + }, [getAnchorSnapshot, headData, page, restoreAnchorSnapshot]); + + const hasMore = allItems.length < total; + + const onResultsScroll = useCallback( + (e: UIEvent) => { + const target = e.currentTarget; + + if (isFetching || !hasMore) { + return; + } + + const pixelsFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + if (pixelsFromBottom < LOAD_MORE_THRESHOLD_PX) { + setPage((p) => p + 1); + } + }, + [hasMore, isFetching] + ); + + return ( + <> + } + onClick={openModal} + /> + + + + Search + + + + + + File Name + + + + + + Metadata + + + + + + Width + + + + + {formState.width_mode === 'between' ? ( + <> + + + + ) : null} + {formState.width_mode === 'gte' ? ( + + ) : null} + {formState.width_mode === 'lte' ? ( + + ) : null} + {formState.width_mode === 'eq' ? ( + + ) : null} + + + + Height + + + + + {formState.height_mode === 'between' ? ( + <> + + + + ) : null} + {formState.height_mode === 'gte' ? ( + + ) : null} + {formState.height_mode === 'lte' ? ( + + ) : null} + {formState.height_mode === 'eq' ? ( + + ) : null} + + + Starred + + + + Boards + + + All Boards + + + Uncategorized + + {boards?.map((b) => ( + + {b.board_name} + + ))} + + + + + + + + {isFetching && allItems.length === 0 ? 'Searching…' : `${allItems.length} / ${total} images`} + + + {allItems.map((imageDTO) => ( + + + + ))} + + {isFetching && allItems.length > 0 ? ( + + + + ) : null} + + + + + + + ); +}); + +ImageSearchModal.displayName = 'ImageSearchModal'; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index aad849fdb59..72cd253d1c9 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -53,7 +53,6 @@ export const selectAutoAssignBoardOnClick = createSelector( (gallery) => gallery.autoAssignBoardOnClick ); export const selectBoardSearchText = createSelector(selectGallerySlice, (gallery) => gallery.boardSearchText); -export const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); export const selectBoardsListOrderBy = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderBy); export const selectBoardsListOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderDir); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 9d4d2bfd75d..47115d8c01c 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -172,7 +172,6 @@ export const { orderDirChanged, starredFirstChanged, shouldShowArchivedBoardsChanged, - searchTermChanged, boardsListOrderByChanged, boardsListOrderDirChanged, } = slice.actions; diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index c6a0bd6705b..eaa346e2c81 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -184,6 +184,7 @@ export const useHotkeyData = (): HotkeysData => { addHotkey('gallery', 'galleryNavLeftAlt', ['alt+left']); addHotkey('gallery', 'deleteSelection', ['delete', 'backspace']); addHotkey('gallery', 'starImage', ['.']); + addHotkey('gallery', 'openSearch', ['ctrl+f', 'alt+f']); return data; }, [customHotkeys, t]); diff --git a/invokeai/frontend/web/src/services/api/endpoints/imageSearch.ts b/invokeai/frontend/web/src/services/api/endpoints/imageSearch.ts new file mode 100644 index 00000000000..b3de387a4d9 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/imageSearch.ts @@ -0,0 +1,71 @@ +import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; +import type { ListImagesResponse } from 'services/api/types'; +import stableHash from 'stable-hash'; + +import { api, buildV1Url } from '..'; + +export type StarredMode = 'include' | 'exclude' | 'only'; + +export type ImageSearchArgs = { + file_name_enabled: boolean; + file_name_term: string; + metadata_enabled: boolean; + metadata_term: string; + width_enabled: boolean; + width_min: string; + width_max: string; + width_exact: string; + height_enabled: boolean; + height_min: string; + height_max: string; + height_exact: string; + board_ids: string[]; + starred_mode: StarredMode; + offset: number; + limit: number; +}; + +const toInt = (v: string): number | undefined => { + const trimmed = v.trim(); + if (!trimmed.length) { + return undefined; + } + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const imageSearchApi = api.injectEndpoints({ + endpoints: (build) => ({ + searchImages: build.query({ + query: (arg) => ({ + url: buildV1Url('images/search'), + method: 'POST', + params: { + categories: IMAGE_CATEGORIES, + is_intermediate: false, + offset: arg.offset, + limit: arg.limit, + }, + body: { + file_name_term: arg.file_name_enabled ? arg.file_name_term : undefined, + metadata_term: arg.metadata_enabled ? arg.metadata_term : undefined, + width_min: arg.width_enabled ? toInt(arg.width_min) : undefined, + width_max: arg.width_enabled ? toInt(arg.width_max) : undefined, + width_exact: arg.width_enabled ? toInt(arg.width_exact) : undefined, + height_min: arg.height_enabled ? toInt(arg.height_min) : undefined, + height_max: arg.height_enabled ? toInt(arg.height_max) : undefined, + height_exact: arg.height_enabled ? toInt(arg.height_exact) : undefined, + board_ids: arg.board_ids.length ? arg.board_ids : undefined, + starred_mode: arg.starred_mode, + }, + }), + providesTags: (result, error, arg) => [ + { type: 'ImageSearchList', id: stableHash(arg) }, + { type: 'ImageSearchList', id: 'LIST' }, + 'FetchOnReconnect', + ], + }), + }), +}); + +export const { useSearchImagesQuery } = imageSearchApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 5b75d724e22..5c70bb33016 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -19,6 +19,8 @@ const tagTypes = [ 'HFTokenStatus', 'Image', 'ImageNameList', + 'ImageSearchNameList', + 'ImageSearchList', 'ImageList', 'ImageMetadata', 'ImageWorkflow', diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b605413787b..89b80dcdcc4 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1105,6 +1105,40 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/images/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Search Images */ + post: operations["search_images"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/images/search/names": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Search Image Names */ + post: operations["search_image_names"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/images/images_by_names": { parameters: { query?: never; @@ -12893,6 +12927,33 @@ export type components = { */ type: "img_scale"; }; + /** ImageSearchBody */ + ImageSearchBody: { + /** File Name Term */ + file_name_term?: string | null; + /** Metadata Term */ + metadata_term?: string | null; + /** Width Min */ + width_min?: number | null; + /** Width Max */ + width_max?: number | null; + /** Width Exact */ + width_exact?: number | null; + /** Height Min */ + height_min?: number | null; + /** Height Max */ + height_max?: number | null; + /** Height Exact */ + height_exact?: number | null; + /** Board Ids */ + board_ids?: string[] | null; + /** + * Starred Mode + * @default include + * @enum {string} + */ + starred_mode?: "include" | "exclude" | "only"; + }; /** * Image to Latents - SD1.5, SDXL * @description Encodes an image into latents. @@ -30082,6 +30143,26 @@ export interface operations { starred_first?: boolean; /** @description The term to search for */ search_term?: string | null; + /** @description File name search term */ + file_name_term?: string | null; + /** @description Metadata search term */ + metadata_term?: string | null; + /** @description Minimum image width */ + width_min?: number | null; + /** @description Maximum image width */ + width_max?: number | null; + /** @description Exact image width */ + width_exact?: number | null; + /** @description Minimum image height */ + height_min?: number | null; + /** @description Maximum image height */ + height_max?: number | null; + /** @description Exact image height */ + height_exact?: number | null; + /** @description Boards to include, supports 'none' */ + board_ids?: string[] | null; + /** @description How to handle starred images */ + starred_mode?: "include" | "exclude" | "only"; }; header?: never; path?: never; @@ -30703,6 +30784,26 @@ export interface operations { starred_first?: boolean; /** @description The term to search for */ search_term?: string | null; + /** @description File name search term */ + file_name_term?: string | null; + /** @description Metadata search term */ + metadata_term?: string | null; + /** @description Minimum image width */ + width_min?: number | null; + /** @description Maximum image width */ + width_max?: number | null; + /** @description Exact image width */ + width_exact?: number | null; + /** @description Minimum image height */ + height_min?: number | null; + /** @description Maximum image height */ + height_max?: number | null; + /** @description Exact image height */ + height_exact?: number | null; + /** @description Boards to include, supports 'none' */ + board_ids?: string[] | null; + /** @description How to handle starred images */ + starred_mode?: "include" | "exclude" | "only"; }; header?: never; path?: never; @@ -30730,6 +30831,86 @@ export interface operations { }; }; }; + search_images: { + parameters: { + query?: { + image_origin?: components["schemas"]["ResourceOrigin"] | null; + categories?: components["schemas"]["ImageCategory"][] | null; + is_intermediate?: boolean | null; + offset?: number; + limit?: number; + order_dir?: components["schemas"]["SQLiteDirection"]; + starred_first?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImageSearchBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_image_names: { + parameters: { + query?: { + image_origin?: components["schemas"]["ResourceOrigin"] | null; + categories?: components["schemas"]["ImageCategory"][] | null; + is_intermediate?: boolean | null; + order_dir?: components["schemas"]["SQLiteDirection"]; + starred_first?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImageSearchBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageNamesResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_images_by_names: { parameters: { query?: never; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index e2ee74dcad1..8765287562d 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -153,6 +153,9 @@ export const buildOnInvocationComplete = ( // No need to invalidate tags since we're doing optimistic updates // Board totals are already updated above via upsertQueryEntries + // Custom image search queries cannot be updated optimistically, invalidate to re-run in background. + dispatch(imagesApi.util.invalidateTags([{ type: 'ImageSearchNameList', id: 'LIST' }])); + dispatch(imagesApi.util.invalidateTags([{ type: 'ImageSearchList', id: 'LIST' }])); const autoSwitch = selectAutoSwitch(getState()); diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index c0da3ec51ca..00c42cf389c 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -117,3 +117,36 @@ def test_get_bulk_download_image_image_deleted_after_response( client.get("/api/v1/images/download/test.zip") assert not (tmp_path / "test.zip").exists() + + +def test_search_image_names_endpoint(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", MockApiDependencies(mock_invoker)) + + captured: dict[str, Any] = {} + + def mock_get_image_names(**kwargs): + captured.update(kwargs) + return {"image_names": ["test.png"], "starred_count": 0, "total_count": 1} + + monkeypatch.setattr(mock_invoker.services.images, "get_image_names", mock_get_image_names) + + response = client.post( + "/api/v1/images/search/names", + json={ + "file_name_term": "test", + "metadata_term": "prompt", + "width_min": 512, + "height_exact": 768, + "board_ids": ["none"], + "starred_mode": "only", + }, + ) + + assert response.status_code == 200 + assert response.json()["image_names"] == ["test.png"] + assert captured["file_name_term"] == "test" + assert captured["metadata_term"] == "prompt" + assert captured["width_min"] == 512 + assert captured["height_exact"] == 768 + assert captured["board_ids"] == ["none"] + assert captured["starred_mode"] == "only"