diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 45a762ba..67b03bb6 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -1295,6 +1295,27 @@ class BaseModelService(ABC): path_for_sorting, ) + @staticmethod + def _relative_path_folder_group_sort_key( + relative_path: str, include_terms: List[str] + ) -> tuple: + """Group paths by folder, then sort by relevance within each group. + + Folders are ordered alphabetically (case-insensitive) by their full + folder path, with root-level files (empty folder) first. Within a + folder, paths keep the relevance ordering of + ``_relative_path_sort_key``. This keeps same-folder entries together + in the autocomplete dropdown instead of interleaving them by filename. + """ + path_for_sorting = BaseModelService._remove_model_extension( + relative_path.lower() + ) + folder = path_for_sorting.rpartition(os.sep)[0] + + return (folder,) + BaseModelService._relative_path_sort_key( + relative_path, include_terms + ) + async def search_relative_paths( self, search_term: str, @@ -1404,9 +1425,13 @@ class BaseModelService(ABC): ): matching_paths.append(relative_path) - # Sort by relevance (prefix and earliest hits first, then by length and alphabetically) + # Group by folder (root first, then alphabetically) and sort by + # relevance (prefix and earliest hits, then length and alphabetically) + # within each folder group. matching_paths.sort( - key=lambda relative: self._relative_path_sort_key(relative, include_terms) + key=lambda relative: self._relative_path_folder_group_sort_key( + relative, include_terms + ) ) # Apply offset and limit diff --git a/tests/services/test_relative_path_search.py b/tests/services/test_relative_path_search.py index e093a3af..71b99b5f 100644 --- a/tests/services/test_relative_path_search.py +++ b/tests/services/test_relative_path_search.py @@ -49,9 +49,83 @@ async def test_search_relative_paths_supports_multiple_tokens(): matching = await service.search_relative_paths("flux detail") + # Folder grouping takes precedence over cross-folder relevance: + # the "detail" folder sorts before "flux" alphabetically. assert matching == [ - f"flux{os.sep}detail-model.safetensors", f"detail{os.sep}flux-trained.safetensors", + f"flux{os.sep}detail-model.safetensors", + ] + + +@pytest.mark.asyncio +async def test_search_relative_paths_groups_by_folder_alphabetically(): + """Same-folder entries cluster together; folders sort alphabetically.""" + scanner = FakeScanner( + [ + {"file_path": "/models/zeta/model-z1.safetensors"}, + {"file_path": "/models/alpha/model-a1.safetensors"}, + {"file_path": "/models/zeta/model-z2.safetensors"}, + {"file_path": "/models/alpha/model-a2.safetensors"}, + {"file_path": "/models/model-root.safetensors"}, + ], + ["/models"], + ) + service = DummyService("stub", scanner, BaseModelMetadata) + + matching = await service.search_relative_paths("model") + + assert matching == [ + # Root-level files (empty folder) come first + "model-root.safetensors", + f"alpha{os.sep}model-a1.safetensors", + f"alpha{os.sep}model-a2.safetensors", + f"zeta{os.sep}model-z1.safetensors", + f"zeta{os.sep}model-z2.safetensors", + ] + + +@pytest.mark.asyncio +async def test_search_relative_paths_relevance_within_folder_group(): + """Within a folder group, the relevance ordering still applies.""" + scanner = FakeScanner( + [ + {"file_path": "/models/flux/x-detail-model.safetensors"}, + {"file_path": "/models/flux/detail-model.safetensors"}, + {"file_path": "/models/flux/a-very-long-detail-model-name.safetensors"}, + ], + ["/models"], + ) + service = DummyService("stub", scanner, BaseModelMetadata) + + matching = await service.search_relative_paths("flux detail") + + assert matching == [ + # Prefix hit on the full path wins + f"flux{os.sep}detail-model.safetensors", + # Then earliest match position, then shorter path + f"flux{os.sep}x-detail-model.safetensors", + f"flux{os.sep}a-very-long-detail-model-name.safetensors", + ] + + +@pytest.mark.asyncio +async def test_search_relative_paths_nested_folders_sort_naturally(): + scanner = FakeScanner( + [ + {"file_path": "/models/styles/anime/model-b.safetensors"}, + {"file_path": "/models/styles/model-a.safetensors"}, + {"file_path": "/models/other/model-c.safetensors"}, + ], + ["/models"], + ) + service = DummyService("stub", scanner, BaseModelMetadata) + + matching = await service.search_relative_paths("model") + + assert matching == [ + f"other{os.sep}model-c.safetensors", + f"styles{os.sep}model-a.safetensors", + f"styles{os.sep}anime{os.sep}model-b.safetensors", ] diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 61681ae0..4d6d40f8 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -1398,7 +1398,8 @@ class AutoComplete { } // Merge and deduplicate results while preserving order from backend - // Backend returns results sorted by relevance, so we maintain that order + // Backend returns results grouped by folder and sorted by relevance + // within each group, so we maintain that order const seen = new Set(); const mergedItems = [];