fix(services): implement stable sorting for model and recipe caches

Add file_path as a tie-breaker for all sort modes in ModelCache, BaseModelService, LoraService, and RecipeCache to ensure deterministic ordering when primary keys are identical. Resolves issue #859.
This commit is contained in:
Will Miao
2026-03-17 14:20:23 +08:00
parent 9e81c33f8a
commit 70c150bd80
4 changed files with 35 additions and 12 deletions

View File

@@ -208,7 +208,11 @@ class BaseModelService(ABC):
reverse = sort_params.order == "desc"
annotated.sort(
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
key=lambda x: (
x.get("usage_count", 0),
x.get("model_name", "").lower(),
x.get("file_path", "").lower()
),
reverse=reverse,
)
return annotated

View File

@@ -516,12 +516,18 @@ class LoraService(BaseModelService):
if sort_by == "model_name":
available_loras = sorted(
available_loras,
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
key=lambda x: (
(x.get("model_name") or x.get("file_name", "")).lower(),
x.get("file_path", "").lower()
)
)
else: # Default to filename
available_loras = sorted(
available_loras,
key=lambda x: x.get("file_name", "").lower()
key=lambda x: (
x.get("file_name", "").lower(),
x.get("file_path", "").lower()
)
)
# Return minimal data needed for cycling

View File

@@ -221,33 +221,45 @@ class ModelCache:
start_time = time.perf_counter()
reverse = (order == 'desc')
if sort_key == 'name':
# Natural sort by configured display name, case-insensitive
# Natural sort by configured display name, case-insensitive, with file_path as tie-breaker
result = natsorted(
data,
key=lambda x: self._get_display_name(x).lower(),
key=lambda x: (
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
elif sort_key == 'date':
# Sort by modified timestamp (use .get() with default to handle missing fields)
# Sort by modified timestamp, fallback to name and path for stability
result = sorted(
data,
key=lambda x: x.get('modified', 0.0),
key=lambda x: (
x.get('modified', 0.0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
elif sort_key == 'size':
# Sort by file size (use .get() with default to handle missing fields)
# Sort by file size, fallback to name and path for stability
result = sorted(
data,
key=lambda x: x.get('size', 0),
key=lambda x: (
x.get('size', 0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
elif sort_key == 'usage':
# Sort by usage count, fallback to 0, then name for stability
# Sort by usage count, fallback to 0, then name and path for stability
return sorted(
data,
key=lambda x: (
x.get('usage_count', 0),
self._get_display_name(x).lower()
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)

View File

@@ -135,7 +135,8 @@ class RecipeCache:
"""Sort cached views. Caller must hold ``_lock``."""
self.sorted_by_name = natsorted(
self.raw_data, key=lambda x: x.get("title", "").lower()
self.raw_data,
key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()),
)
if not name_only:
self.sorted_by_date = sorted(