feat(ui): add seeded random sort option to model pages (#1049)

This commit is contained in:
Will Miao
2026-08-03 15:02:49 +08:00
parent 28e93d12ff
commit ab4154c57d
22 changed files with 533 additions and 49 deletions

View File

@@ -714,7 +714,9 @@
"versionsCount": "Lokale Versionen", "versionsCount": "Lokale Versionen",
"versionsCountDesc": "Meiste Versionen zuerst", "versionsCountDesc": "Meiste Versionen zuerst",
"versionsCountAsc": "Wenigste Versionen zuerst", "versionsCountAsc": "Wenigste Versionen zuerst",
"versionIdDesc": "Neueste Version zuerst" "versionIdDesc": "Neueste Version zuerst",
"random": "Zufällig",
"randomAction": "Zufällig mischen"
}, },
"refresh": { "refresh": {
"title": "Modelliste aktualisieren", "title": "Modelliste aktualisieren",

View File

@@ -714,7 +714,9 @@
"versionsCount": "Local Versions", "versionsCount": "Local Versions",
"versionsCountDesc": "Most versions first", "versionsCountDesc": "Most versions first",
"versionsCountAsc": "Fewest versions first", "versionsCountAsc": "Fewest versions first",
"versionIdDesc": "Newest version first" "versionIdDesc": "Newest version first",
"random": "Random",
"randomAction": "Randomize (shuffle)"
}, },
"refresh": { "refresh": {
"title": "Refresh model list", "title": "Refresh model list",

View File

@@ -714,7 +714,9 @@
"versionsCount": "Versiones locales", "versionsCount": "Versiones locales",
"versionsCountDesc": "Más versiones primero", "versionsCountDesc": "Más versiones primero",
"versionsCountAsc": "Menos versiones primero", "versionsCountAsc": "Menos versiones primero",
"versionIdDesc": "Versión más nueva primero" "versionIdDesc": "Versión más nueva primero",
"random": "Aleatorio",
"randomAction": "Aleatorizar (barajar)"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de modelos", "title": "Actualizar lista de modelos",

View File

@@ -714,7 +714,9 @@
"versionsCount": "Versions locales", "versionsCount": "Versions locales",
"versionsCountDesc": "Plus de versions d'abord", "versionsCountDesc": "Plus de versions d'abord",
"versionsCountAsc": "Moins de versions d'abord", "versionsCountAsc": "Moins de versions d'abord",
"versionIdDesc": "Version la plus récente d'abord" "versionIdDesc": "Version la plus récente d'abord",
"random": "Aléatoire",
"randomAction": "Aléatoire (mélanger)"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des modèles", "title": "Actualiser la liste des modèles",

View File

@@ -714,7 +714,9 @@
"versionsCount": "גרסאות מקומיות", "versionsCount": "גרסאות מקומיות",
"versionsCountDesc": "הכי הרבה גרסאות ראשונות", "versionsCountDesc": "הכי הרבה גרסאות ראשונות",
"versionsCountAsc": "הכי מעט גרסאות ראשונות", "versionsCountAsc": "הכי מעט גרסאות ראשונות",
"versionIdDesc": "גרסה חדשה ביותר ראשונה" "versionIdDesc": "גרסה חדשה ביותר ראשונה",
"random": "אקראי",
"randomAction": "ערבוב אקראי"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מודלים", "title": "רענן רשימת מודלים",

View File

@@ -714,7 +714,9 @@
"versionsCount": "ローカルバージョン数", "versionsCount": "ローカルバージョン数",
"versionsCountDesc": "バージョン数の多い順", "versionsCountDesc": "バージョン数の多い順",
"versionsCountAsc": "バージョン数の少ない順", "versionsCountAsc": "バージョン数の少ない順",
"versionIdDesc": "最新バージョン順" "versionIdDesc": "最新バージョン順",
"random": "ランダム",
"randomAction": "シャッフル(ランダム)"
}, },
"refresh": { "refresh": {
"title": "モデルリストを更新", "title": "モデルリストを更新",

View File

@@ -714,7 +714,9 @@
"versionsCount": "로컬 버전 수", "versionsCount": "로컬 버전 수",
"versionsCountDesc": "버전 수 많은 순", "versionsCountDesc": "버전 수 많은 순",
"versionsCountAsc": "버전 수 적은 순", "versionsCountAsc": "버전 수 적은 순",
"versionIdDesc": "최신 버전순" "versionIdDesc": "최신 버전순",
"random": "랜덤",
"randomAction": "셔플 (무작위)"
}, },
"refresh": { "refresh": {
"title": "모델 목록 새로고침", "title": "모델 목록 새로고침",

View File

@@ -714,7 +714,9 @@
"versionsCount": "Локальные версии", "versionsCount": "Локальные версии",
"versionsCountDesc": "Сначала больше версий", "versionsCountDesc": "Сначала больше версий",
"versionsCountAsc": "Сначала меньше версий", "versionsCountAsc": "Сначала меньше версий",
"versionIdDesc": "Сначала новые версии" "versionIdDesc": "Сначала новые версии",
"random": "Случайно",
"randomAction": "Перемешать"
}, },
"refresh": { "refresh": {
"title": "Обновить список моделей", "title": "Обновить список моделей",

View File

@@ -714,7 +714,9 @@
"versionsCount": "本地版本数", "versionsCount": "本地版本数",
"versionsCountDesc": "版本数从多到少", "versionsCountDesc": "版本数从多到少",
"versionsCountAsc": "版本数从少到多", "versionsCountAsc": "版本数从少到多",
"versionIdDesc": "最新版本优先" "versionIdDesc": "最新版本优先",
"random": "随机",
"randomAction": "随机排序(洗牌)"
}, },
"refresh": { "refresh": {
"title": "刷新模型列表", "title": "刷新模型列表",

View File

@@ -714,7 +714,9 @@
"versionsCount": "本地版本數", "versionsCount": "本地版本數",
"versionsCountDesc": "版本數從多到少", "versionsCountDesc": "版本數從多到少",
"versionsCountAsc": "版本數從少到多", "versionsCountAsc": "版本數從少到多",
"versionIdDesc": "最新版本優先" "versionIdDesc": "最新版本優先",
"random": "隨機",
"randomAction": "隨機排序(洗牌)"
}, },
"refresh": { "refresh": {
"title": "重新整理模型列表", "title": "重新整理模型列表",

View File

@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import re import re
import random
from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING
import logging import logging
import os import os
@@ -390,6 +391,12 @@ class BaseModelService(ABC):
(item.get("model_name") or item.get("file_name") or "").lower(), (item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(), item.get("file_path", "").lower(),
) )
elif key_name == "random":
# Seeded random shuffle: same seed -> same order (stable pagination)
rng = random.Random(sort_params.seed or "random")
result = list(data)
rng.shuffle(result)
return result
elif key_name == "size": elif key_name == "size":
key_fn = lambda item: ( key_fn = lambda item: (
int(item.get("size", 0) or 0), int(item.get("size", 0) or 0),

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import time import time
import logging import logging
import random
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -38,8 +39,8 @@ class ModelCache:
def __post_init__(self): def __post_init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# Cache for last sort: (sort_key, order) -> sorted list # Cache for last sort: (sort_key, order, seed) -> sorted list
self._last_sort: Tuple[str, str] = (None, None) self._last_sort: Tuple[Optional[str], str, Optional[str]] = (None, "asc", None)
self._last_sorted_data: List[Dict] = [] self._last_sorted_data: List[Dict] = []
self._normalize_raw_data() self._normalize_raw_data()
self.name_display_mode = self._normalize_display_mode(self.name_display_mode) self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
@@ -203,9 +204,9 @@ class ModelCache:
async def resort(self): async def resort(self):
"""Resort cached data according to last sort mode if set""" """Resort cached data according to last sort mode if set"""
async with self._lock: async with self._lock:
if self._last_sort != (None, None): if self._last_sort[0] is not None:
sort_key, order = self._last_sort sort_key, order, seed = self._last_sort
sorted_data = self._sort_data(self.raw_data, sort_key, order) sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
self._last_sorted_data = sorted_data self._last_sorted_data = sorted_data
# Update folder list # Update folder list
# else: do nothing # else: do nothing
@@ -218,7 +219,7 @@ class ModelCache:
self.folders = sorted(list(all_folders), key=lambda x: x.lower()) self.folders = sorted(list(all_folders), key=lambda x: x.lower())
self.rebuild_version_index() self.rebuild_version_index()
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]: def _sort_data(self, data: List[Dict], sort_key: str, order: str, seed: Optional[str] = None) -> List[Dict]:
"""Sort data by sort_key and order""" """Sort data by sort_key and order"""
start_time = time.perf_counter() start_time = time.perf_counter()
reverse = (order == 'desc') reverse = (order == 'desc')
@@ -265,6 +266,13 @@ class ModelCache:
), ),
reverse=reverse reverse=reverse
) )
elif sort_key == 'random':
# Random shuffle seeded for stable pagination: the same seed
# always yields the same order, so successive page requests
# stay consistent while browsing.
rng = random.Random(seed or 'random')
result = list(data)
rng.shuffle(result)
elif sort_key == 'versions_count': elif sort_key == 'versions_count':
# Pre-dedup sort: fall back to name sort. # Pre-dedup sort: fall back to name sort.
# Actual re-sort by version_count happens in get_paginated_data after dedup. # Actual re-sort by version_count happens in get_paginated_data after dedup.
@@ -285,15 +293,16 @@ class ModelCache:
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration) logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
return result return result
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]: async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc', seed: Optional[str] = None) -> List[Dict]:
"""Get sorted data by sort_key and order, using cache if possible""" """Get sorted data by sort_key and order, using cache if possible"""
async with self._lock: async with self._lock:
if (sort_key, order) == self._last_sort: cache_key = (sort_key, order, seed)
if cache_key == self._last_sort:
return self._last_sorted_data return self._last_sorted_data
start_time = time.perf_counter() start_time = time.perf_counter()
sorted_data = self._sort_data(self.raw_data, sort_key, order) sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
self._last_sort = (sort_key, order) self._last_sort = cache_key
self._last_sorted_data = sorted_data self._last_sorted_data = sorted_data
duration = time.perf_counter() - start_time duration = time.perf_counter() - start_time
@@ -313,8 +322,8 @@ class ModelCache:
self.name_display_mode = normalized self.name_display_mode = normalized
if self._last_sort[0] == 'name': if self._last_sort[0] == 'name':
sort_key, order = self._last_sort sort_key, order, seed = self._last_sort
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order) self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool: async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
"""Update preview_url for a specific model in all cached data """Update preview_url for a specific model in all cached data

View File

@@ -85,6 +85,7 @@ class SortParams:
key: str key: str
order: str order: str
seed: Optional[str] = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -116,7 +117,7 @@ class ModelCacheRepository:
async def fetch_sorted(self, params: SortParams) -> List[Dict[str, Any]]: async def fetch_sorted(self, params: SortParams) -> List[Dict[str, Any]]:
"""Fetch cached data pre-sorted according to ``params``.""" """Fetch cached data pre-sorted according to ``params``."""
cache = await self.get_cache() cache = await self.get_cache()
return await cache.get_sorted_data(params.key, params.order) return await cache.get_sorted_data(params.key, params.order, params.seed)
@staticmethod @staticmethod
def parse_sort(sort_by: str) -> SortParams: def parse_sort(sort_by: str) -> SortParams:
@@ -132,10 +133,17 @@ class ModelCacheRepository:
sort_key = sort_by.strip().lower() or "name" sort_key = sort_by.strip().lower() or "name"
order = "asc" order = "asc"
if order not in ("asc", "desc"): seed = None
if sort_key == "random":
# Random sort: the portion after ':' is the shuffle seed.
# A stable seed keeps paginated requests consistent; order is
# meaningless for a random shuffle.
seed = order if order and order not in ("asc", "desc") else None
order = "asc"
elif order not in ("asc", "desc"):
order = "asc" order = "asc"
return SortParams(key=sort_key, order=order) return SortParams(key=sort_key, order=order, seed=seed)
class ModelFilterSet: class ModelFilterSet:

View File

@@ -1752,7 +1752,7 @@ class ModelScanner:
# ---- Conditional resort (only when sort-key fields changed) ---- # ---- Conditional resort (only when sort-key fields changed) ----
need_resort = False need_resort = False
_last = cache._last_sort _last = cache._last_sort
sort_key: Optional[str] = _last[0] if _last != (None, None) else None sort_key: Optional[str] = _last[0] if _last[0] is not None else None
if sort_key == "name": if sort_key == "name":
if ( if (
old_model_name != desired_entry.get("model_name", "") old_model_name != desired_entry.get("model_name", "")

View File

@@ -108,10 +108,20 @@ export class PageControls {
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
if (sortSelect) { if (sortSelect) {
initSortDropdown(sortSelect); initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy; this.applySortToSelect(this.pageState.sortBy);
sortSelect.addEventListener('change', async (e) => { sortSelect.addEventListener('change', async (e) => {
this.pageState.sortBy = e.target.value; let value = e.target.value;
this.saveSortPreference(e.target.value); if (value.startsWith('random')) {
// Every pick of Random reshuffles the list: generate a
// fresh seed so the backend keeps a stable order across
// paginated requests.
value = this._randomizeSortValue();
}
this.pageState.sortBy = value;
this.saveSortPreference(value);
// Reset the seeded Random option when switching away from
// Random, or re-apply the fresh seed when picking it again.
this.applySortToSelect(value);
await this.resetAndReload(); await this.resetAndReload();
}); });
} }
@@ -312,6 +322,44 @@ export class PageControls {
} }
} }
/**
* Apply a sort value to the native sort <select>, keeping the Random
* option's value in sync when the persisted value carries a seed
* (e.g. "random:abc123"). Must be used instead of assigning
* sortSelect.value directly whenever the value may be a seeded random
* sort, otherwise the native select has no matching option.
* @param {string} sortValue - Sort value like "name:asc" or "random:<seed>"
*/
applySortToSelect(sortValue) {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
}
sortSelect.value = sortValue;
}
/**
* Generate a fresh seeded random sort value ("random:<seed>") and keep
* the native <select> in sync so its value matches the persisted sort
* string and the dropdown shows the selected label.
* @returns {string} The new sort value, e.g. "random:abc123xyz"
*/
_randomizeSortValue() {
const seed = Math.random().toString(36).slice(2, 12);
const value = `random:${seed}`;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = value;
}
sortSelect.value = value;
}
return value;
}
/** /**
* Load sort preference from storage * Load sort preference from storage
*/ */
@@ -326,10 +374,7 @@ export class PageControls {
// Handle legacy format conversion // Handle legacy format conversion
const convertedSort = this.convertLegacySortFormat(savedSort); const convertedSort = this.convertLegacySortFormat(savedSort);
this.pageState.sortBy = convertedSort; this.pageState.sortBy = convertedSort;
const sortSelect = document.getElementById('sortSelect'); this.applySortToSelect(convertedSort);
if (sortSelect) {
sortSelect.value = convertedSort;
}
} }
} }
@@ -523,9 +568,9 @@ export class PageControls {
this.pageState.sortBy = restoredSort; this.pageState.sortBy = restoredSort;
this.saveSortPreference(restoredSort); this.saveSortPreference(restoredSort);
this._removeVlmSortOption(); this._removeVlmSortOption();
this.applySortToSelect(restoredSort);
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
if (sortSelect) { if (sortSelect) {
sortSelect.value = restoredSort;
sortSelect.disabled = false; sortSelect.disabled = false;
} }
} }
@@ -575,10 +620,7 @@ export class PageControls {
const savedGroupedSort = getStorageItem(groupedKey); const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) { if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort; this.pageState.sortBy = savedGroupedSort;
const sortSelect = document.getElementById('sortSelect'); this.applySortToSelect(savedGroupedSort);
if (sortSelect) {
sortSelect.value = savedGroupedSort;
}
} }
} else { } else {
// Leaving group mode: persist current sort for next time, restore non-group sort // Leaving group mode: persist current sort for next time, restore non-group sort
@@ -586,10 +628,7 @@ export class PageControls {
const savedNormalSort = getStorageItem(`${this.pageType}_sort`); const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
if (savedNormalSort) { if (savedNormalSort) {
this.pageState.sortBy = savedNormalSort; this.pageState.sortBy = savedNormalSort;
const sortSelect = document.getElementById('sortSelect'); this.applySortToSelect(savedNormalSort);
if (sortSelect) {
sortSelect.value = savedNormalSort;
}
} }
} }
} }
@@ -874,7 +913,7 @@ export class PageControls {
} }
if (sortSelect) { if (sortSelect) {
sortSelect.value = this.pageState.sortBy; this.applySortToSelect(this.pageState.sortBy);
} }
if (searchInput) { if (searchInput) {
searchInput.value = this.pageState.filters?.search || ''; searchInput.value = this.pageState.filters?.search || '';

View File

@@ -96,7 +96,16 @@ export function initSortDropdown(select) {
}; };
const choose = (value) => { const choose = (value) => {
if (select.value === value) return; if (select.value === value) {
// Re-picking the already-selected option is normally a no-op,
// matching native <select> behavior. The seeded Random sort is
// the exception: clicking it again should reshuffle, so let the
// change handler (PageControls) generate a fresh seed.
if (String(value).startsWith('random')) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
return;
}
select.value = value; select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true }));
}; };
@@ -277,9 +286,10 @@ export function initSortDropdown(select) {
} }
// Rebuild the menu when <option>s change (VLM adds/removes a temporary // Rebuild the menu when <option>s change (VLM adds/removes a temporary
// option at runtime). // option at runtime, and the seeded Random sort option gets a new value
// attribute each time it is picked).
const observer = new MutationObserver(() => buildMenu()); const observer = new MutationObserver(() => buildMenu());
observer.observe(select, { childList: true }); observer.observe(select, { childList: true, subtree: true, attributes: true, attributeFilter: ['value'] });
buildMenu(); buildMenu();
group.dataset.sortReady = '1'; group.dataset.sortReady = '1';

View File

@@ -48,6 +48,11 @@
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option> <option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %} {% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}"> <optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option> <option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>

View File

@@ -0,0 +1,221 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const resetAndReloadMock = vi.fn();
const getModelApiClientMock = vi.fn();
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
openCivitaiByMetadata: vi.fn(),
updatePanelPositions: vi.fn(),
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
downloadManager: { showDownloadModal: vi.fn() },
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
isInitialized: false,
},
}));
vi.mock('../../../static/js/components/alphabet/index.js', () => ({
createAlphabetBar: vi.fn(() => ({ destroy: vi.fn() })),
}));
vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({
performModelUpdateCheck: vi.fn(async () => ({ status: 'success', displayName: 'LoRA', records: [] })),
}));
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
resetAndReloadMock.mockResolvedValue(undefined);
getModelApiClientMock.mockReturnValue({});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, base_models: [] }),
});
});
afterEach(() => {
delete window.bulkManager;
delete window.modelDuplicatesManager;
delete global.fetch;
});
function renderControlsDom(pageKey) {
document.body.dataset.page = pageKey;
document.body.innerHTML = `
<div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<button id="excludedViewBackBtn">Back</button>
</div>
<div class="actions">
<div class="action-buttons">
<div class="control-group">
<select id="sortSelect">
<option value="name:asc">Name Asc</option>
<option value="name:desc">Name Desc</option>
<option value="random">Randomize (shuffle)</option>
</select>
</div>
<div class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"></button>
<button class="dropdown-toggle"></button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild"></div>
</div>
</div>
<div class="control-group">
<button data-action="fetch"></button>
</div>
<div class="control-group">
<button data-action="download"></button>
</div>
<div class="control-group">
<button data-action="bulk"></button>
</div>
<div class="control-group">
<button data-action="find-duplicates"></button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" class="favorite-filter"></button>
</div>
<div class="control-group dropdown-group update-filter-group">
<button id="updateFilterBtn" class="dropdown-main update-filter" aria-busy="false">
<span>Updates</span>
</button>
<button id="updateFilterMenuToggle" class="dropdown-toggle"></button>
<div class="dropdown-menu">
<div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates">
<span>Check updates</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<span class="customFilterText" title=""></span>
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
<div id="breadcrumbContainer"></div>
<div id="duplicatesBanner" style="display: none;"></div>
<div class="alphabet-bar-container"></div>
`;
}
async function createControls() {
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
return { stateModule, controls: new LorasControls() };
}
describe('Random sort option', () => {
it('generates a seeded sort value when Random is picked', async () => {
renderControlsDom('loras');
const { controls } = await createControls();
const sortSelect = document.getElementById('sortSelect');
const randomOpt = sortSelect.querySelector('option[value="random"]');
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(controls.pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(localStorage.getItem('lora_manager_loras_sort')).toBe(controls.pageState.sortBy);
expect(randomOpt.value).toBe(controls.pageState.sortBy);
expect(sortSelect.value).toBe(controls.pageState.sortBy);
expect(resetAndReloadMock).toHaveBeenCalled();
});
it('reshuffles with a fresh seed every time Random is picked again', async () => {
renderControlsDom('loras');
const { controls } = await createControls();
const sortSelect = document.getElementById('sortSelect');
const randomOpt = sortSelect.querySelector('option[value="random"]');
// First pick
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
const firstSeed = controls.pageState.sortBy;
// Second pick: the option now carries the seeded value, like a menu click
sortSelect.value = randomOpt.value;
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(controls.pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(controls.pageState.sortBy).not.toBe(firstSeed);
});
it('restores a persisted seeded random sort on load', async () => {
renderControlsDom('loras');
const savedSort = 'random:persistedseed';
localStorage.setItem('lora_manager_loras_sort', savedSort);
const { controls } = await createControls();
const sortSelect = document.getElementById('sortSelect');
expect(controls.pageState.sortBy).toBe(savedSort);
expect(sortSelect.value).toBe(savedSort);
expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull();
});
it('applies a non-random sort back to the plain random option', async () => {
renderControlsDom('loras');
const { controls } = await createControls();
const sortSelect = document.getElementById('sortSelect');
const randomOpt = sortSelect.querySelector('option[value="random"]');
// Seed a random sort, then switch to a normal sort
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
controls.applySortToSelect('name:desc');
expect(sortSelect.value).toBe('name:desc');
expect(randomOpt.value).toBe('random');
});
it('resets the seeded option when switching away from Random via the dropdown change handler', async () => {
renderControlsDom('loras');
const { controls } = await createControls();
const sortSelect = document.getElementById('sortSelect');
const randomOpt = sortSelect.querySelector('option[value="random"]');
// Pick Random: the option is now seeded
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/);
// Switch to a non-random sort through the change handler (as a menu
// click does); the option must go back to the plain "random" value
sortSelect.value = 'name:desc';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(controls.pageState.sortBy).toBe('name:desc');
expect(sortSelect.value).toBe('name:desc');
expect(randomOpt.value).toBe('random');
});
});

View File

@@ -0,0 +1,68 @@
import { describe, it, beforeEach, expect } from 'vitest';
import { initSortDropdown } from '../../../static/js/components/controls/SortDropdown.js';
function renderSortDropdownDom() {
document.body.innerHTML = `
<div class="sort-dropdown-group">
<select id="sortSelect">
<option value="name:asc">Name Asc</option>
<option value="name:desc">Name Desc</option>
<option value="random" selected>Randomize (shuffle)</option>
</select>
<button class="sort-trigger" type="button">
<span class="sort-trigger__label"></span>
</button>
<div class="sort-dropdown-menu"></div>
</div>
`;
return {
select: document.getElementById('sortSelect'),
menu: document.querySelector('.sort-dropdown-menu'),
label: document.querySelector('.sort-trigger__label'),
};
}
describe('SortDropdown menu sync', () => {
let select;
let menu;
let label;
beforeEach(() => {
({ select, menu, label } = renderSortDropdownDom());
initSortDropdown(select);
});
it('rebuilds the menu and highlights the selected item when an option value attribute changes', async () => {
// The seeded Random option gets a new value each time it is picked.
// The select's value getter follows the selected option's new value.
const randomOpt = select.querySelector('option[value="random"]');
randomOpt.value = 'random:abc123';
await Promise.resolve();
const items = [...menu.querySelectorAll('.sort-option')];
expect(items.map((el) => el.dataset.value)).toContain('random:abc123');
const seededItem = items.find((el) => el.dataset.value === 'random:abc123');
expect(seededItem.classList.contains('is-selected')).toBe(true);
expect(label.textContent).toBe('Randomize (shuffle)');
});
it('drops the stale seeded item and re-selects the plain random item when the option is reset', async () => {
const randomOpt = select.querySelector('option[value="random"]');
randomOpt.value = 'random:abc123';
await Promise.resolve();
// The rebuild must have happened: the seeded item is in the menu
const seededItems = [...menu.querySelectorAll('.sort-option')]
.filter((el) => el.dataset.value === 'random:abc123');
expect(seededItems).toHaveLength(1);
// PageControls resets the option to "random" when switching away
randomOpt.value = 'random';
await Promise.resolve();
const items = [...menu.querySelectorAll('.sort-option')];
expect(items.map((el) => el.dataset.value)).not.toContain('random:abc123');
const randomItem = items.find((el) => el.dataset.value === 'random');
expect(randomItem.classList.contains('is-selected')).toBe(true);
});
});

View File

@@ -183,7 +183,7 @@ class FakeCache:
def __init__(self, items): def __init__(self, items):
self.items = list(items) self.items = list(items)
async def get_sorted_data(self, sort_key, order): async def get_sorted_data(self, sort_key, order, seed=None):
if sort_key == "name": if sort_key == "name":
data = sorted(self.items, key=lambda x: x["model_name"].lower()) data = sorted(self.items, key=lambda x: x["model_name"].lower())
if order == "desc": if order == "desc":

View File

@@ -884,7 +884,7 @@ async def test_sync_cache_conditional_resort_skipped(tmp_path: Path, monkeypatch
raw_data=[dict(entry)], folders=[], name_display_mode="model_name" raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
) )
await scanner._cache.resort() await scanner._cache.resort()
scanner._cache._last_sort = ("name", "asc") # name sort is active scanner._cache._last_sort = ("name", "asc", None) # name sort is active
scanner._tags_count = {"alpha": 1} scanner._tags_count = {"alpha": 1}
scanner._hash_index.add_entry("abc123", "/m/a.safetensors") scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
@@ -935,7 +935,7 @@ async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypat
raw_data=[dict(entry)], folders=[], name_display_mode="model_name" raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
) )
await scanner._cache.resort() await scanner._cache.resort()
scanner._cache._last_sort = ("name", "asc") scanner._cache._last_sort = ("name", "asc", None)
scanner._tags_count = {"alpha": 1} scanner._tags_count = {"alpha": 1}
scanner._hash_index.add_entry("abc123", "/m/a.safetensors") scanner._hash_index.add_entry("abc123", "/m/a.safetensors")

View File

@@ -0,0 +1,97 @@
"""Tests for sort parsing and the seeded random sort mode."""
import asyncio
import pytest
from py.services.model_cache import ModelCache
from py.services.model_query import ModelCacheRepository, SortParams
def _make_cache(items):
return ModelCache(
raw_data=[
{
"file_path": f"/models/{name}.safetensors",
"file_name": f"{name}.safetensors",
"model_name": name,
"folder": "",
"size": 100,
"modified": 0.0,
}
for name in items
],
folders=[],
)
class TestParseSort:
def test_random_with_seed(self):
params = ModelCacheRepository.parse_sort("random:abc123")
assert params == SortParams(key="random", order="asc", seed="abc123")
def test_random_without_seed(self):
params = ModelCacheRepository.parse_sort("random")
assert params == SortParams(key="random", order="asc", seed=None)
def test_random_empty_seed_falls_back_to_none(self):
params = ModelCacheRepository.parse_sort("random:")
assert params.seed is None
def test_regular_sorts_unaffected(self):
params = ModelCacheRepository.parse_sort("name:desc")
assert params == SortParams(key="name", order="desc", seed=None)
class TestRandomShuffle:
@pytest.mark.asyncio
async def test_same_seed_yields_same_order(self):
cache = _make_cache(["a", "b", "c", "d", "e"])
await asyncio.sleep(0) # allow background resort task to run
first = await cache.get_sorted_data("random", "asc", "seed1")
second = await cache.get_sorted_data("random", "asc", "seed1")
assert [item["model_name"] for item in first] == [
item["model_name"] for item in second
]
@pytest.mark.asyncio
async def test_different_seeds_yield_different_orders(self):
cache = _make_cache([f"m{i}" for i in range(20)])
await asyncio.sleep(0)
first = await cache.get_sorted_data("random", "asc", "seed-a")
second = await cache.get_sorted_data("random", "asc", "seed-b")
assert [item["model_name"] for item in first] != [
item["model_name"] for item in second
]
@pytest.mark.asyncio
async def test_shuffle_is_a_permutation(self):
cache = _make_cache(["a", "b", "c", "d", "e"])
await asyncio.sleep(0)
shuffled = await cache.get_sorted_data("random", "asc", "seed")
assert sorted(item["model_name"] for item in shuffled) == [
"a",
"b",
"c",
"d",
"e",
]
assert len({item["file_path"] for item in shuffled}) == 5
@pytest.mark.asyncio
async def test_missing_seed_is_stable(self):
cache = _make_cache(["a", "b", "c", "d", "e"])
await asyncio.sleep(0)
first = await cache.get_sorted_data("random", "asc")
second = await cache.get_sorted_data("random", "asc")
assert [item["model_name"] for item in first] == [
item["model_name"] for item in second
]