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"