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" reverse = sort_params.order == "desc"
annotated.sort( 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, reverse=reverse,
) )
return annotated return annotated

View File

@@ -516,12 +516,18 @@ class LoraService(BaseModelService):
if sort_by == "model_name": if sort_by == "model_name":
available_loras = sorted( available_loras = sorted(
available_loras, 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 else: # Default to filename
available_loras = sorted( available_loras = sorted(
available_loras, 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 # Return minimal data needed for cycling

View File

@@ -221,33 +221,45 @@ class ModelCache:
start_time = time.perf_counter() start_time = time.perf_counter()
reverse = (order == 'desc') reverse = (order == 'desc')
if sort_key == 'name': 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( result = natsorted(
data, 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 reverse=reverse
) )
elif sort_key == 'date': 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( result = sorted(
data, 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 reverse=reverse
) )
elif sort_key == 'size': 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( result = sorted(
data, 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 reverse=reverse
) )
elif sort_key == 'usage': 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( return sorted(
data, data,
key=lambda x: ( key=lambda x: (
x.get('usage_count', 0), x.get('usage_count', 0),
self._get_display_name(x).lower() self._get_display_name(x).lower(),
x.get('file_path', '').lower()
), ),
reverse=reverse reverse=reverse
) )

View File

@@ -135,7 +135,8 @@ class RecipeCache:
"""Sort cached views. Caller must hold ``_lock``.""" """Sort cached views. Caller must hold ``_lock``."""
self.sorted_by_name = natsorted( 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: if not name_only:
self.sorted_by_date = sorted( self.sorted_by_date = sorted(