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.
This commit is contained in:
Will Miao
2026-09-02 22:01:44 +08:00
parent 00095a5398
commit 77109b3cf8
3 changed files with 104 additions and 4 deletions
+27 -2
View File
@@ -1295,6 +1295,27 @@ class BaseModelService(ABC):
path_for_sorting, 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( async def search_relative_paths(
self, self,
search_term: str, search_term: str,
@@ -1404,9 +1425,13 @@ class BaseModelService(ABC):
): ):
matching_paths.append(relative_path) 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( 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 # Apply offset and limit
+75 -1
View File
@@ -49,9 +49,83 @@ async def test_search_relative_paths_supports_multiple_tokens():
matching = await service.search_relative_paths("flux detail") 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 == [ assert matching == [
f"flux{os.sep}detail-model.safetensors",
f"detail{os.sep}flux-trained.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",
] ]
+2 -1
View File
@@ -1398,7 +1398,8 @@ class AutoComplete {
} }
// Merge and deduplicate results while preserving order from backend // 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 seen = new Set();
const mergedItems = []; const mergedItems = [];