From 77109b3cf8d5f5c9d4c549cad5447b13c12241b5 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 2 Sep 2026 22:01:44 +0800 Subject: [PATCH] feat(autocomplete): group relative-path results by folder (#1091) Autocomplete suggestions were ranked purely by relevance across the whole library, so same-named loras from different subfolders interleaved and were hard to tell apart. Results are now bucketed by folder (root first, then alphabetically, with nested paths sorting naturally) while keeping the existing relevance ordering within each folder group. --- py/services/base_model_service.py | 29 +++++++- tests/services/test_relative_path_search.py | 76 ++++++++++++++++++++- web/comfyui/autocomplete.js | 3 +- 3 files changed, 104 insertions(+), 4 deletions(-) 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 = [];