mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-24 14:31:26 -03:00
feat(recipes): add lora availability filter to recipe filter panel
This commit is contained in:
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)"
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)",
|
||||
"loraAvailability": "LoRA-Verfügbarkeit",
|
||||
"availabilityReady": "Einsatzbereit",
|
||||
"availabilityMissing": "Mit fehlenden LoRAs",
|
||||
"availabilityDeleted": "Mit gelöschten LoRAs"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Theme wechseln",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"tagLogicAny": "Match any tag (OR)",
|
||||
"tagLogicAll": "Match all tags (AND)"
|
||||
"tagLogicAll": "Match all tags (AND)",
|
||||
"loraAvailability": "Lora Availability",
|
||||
"availabilityReady": "Ready to use",
|
||||
"availabilityMissing": "Has missing",
|
||||
"availabilityDeleted": "Has deleted"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "Cualquiera",
|
||||
"all": "Todos",
|
||||
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)",
|
||||
"loraAvailability": "Disponibilidad de LoRAs",
|
||||
"availabilityReady": "Listos para usar",
|
||||
"availabilityMissing": "Con LoRAs faltantes",
|
||||
"availabilityDeleted": "Con LoRAs eliminados"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "N'importe quel",
|
||||
"all": "Tous",
|
||||
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)"
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)",
|
||||
"loraAvailability": "Disponibilité des LoRAs",
|
||||
"availabilityReady": "Prêts à l'emploi",
|
||||
"availabilityMissing": "Avec LoRAs manquants",
|
||||
"availabilityDeleted": "Avec LoRAs supprimés"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le thème",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "כלשהו",
|
||||
"all": "כל התגים",
|
||||
"tagLogicAny": "התאם כל תג (או)",
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)"
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)",
|
||||
"loraAvailability": "זמינות LoRA",
|
||||
"availabilityReady": "מוכנים לשימוש",
|
||||
"availabilityMissing": "עם LoRAs חסרים",
|
||||
"availabilityDeleted": "עם LoRAs שנמחקו"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "החלף ערכת נושא",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
"tagLogicAny": "いずれかのタグに一致 (OR)",
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)"
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)",
|
||||
"loraAvailability": "LoRA の利用状況",
|
||||
"availabilityReady": "使用可能",
|
||||
"availabilityMissing": "不足 LoRA あり",
|
||||
"availabilityDeleted": "削除済み LoRA あり"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマの切り替え",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
"tagLogicAny": "모든 태그 일치 (OR)",
|
||||
"tagLogicAll": "모든 태그 일치 (AND)"
|
||||
"tagLogicAll": "모든 태그 일치 (AND)",
|
||||
"loraAvailability": "LoRA 가용성",
|
||||
"availabilityReady": "바로 사용 가능",
|
||||
"availabilityMissing": "누락된 LoRA 있음",
|
||||
"availabilityDeleted": "삭제된 LoRA 있음"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 토글",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "Любой",
|
||||
"all": "Все",
|
||||
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)"
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)",
|
||||
"loraAvailability": "Доступность LoRAs",
|
||||
"availabilityReady": "Готовы к использованию",
|
||||
"availabilityMissing": "Есть отсутствующие",
|
||||
"availabilityDeleted": "Есть удалённые"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "匹配任一标签 (或)",
|
||||
"tagLogicAll": "匹配所有标签 (与)"
|
||||
"tagLogicAll": "匹配所有标签 (与)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺失 LoRA",
|
||||
"availabilityDeleted": "包含已删除 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
|
||||
+5
-1
@@ -260,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "符合任一票籤 (或)",
|
||||
"tagLogicAll": "符合所有標籤 (與)"
|
||||
"tagLogicAll": "符合所有標籤 (與)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺少的 LoRA",
|
||||
"availabilityDeleted": "包含已刪除的 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切換主題",
|
||||
|
||||
@@ -274,6 +274,14 @@ class RecipeListingHandler:
|
||||
if tag_filters:
|
||||
filters["tags"] = tag_filters
|
||||
|
||||
lora_availability = {
|
||||
status.strip()
|
||||
for status in request.query.get("lora_availability", "").split(",")
|
||||
if status.strip() in ("ready", "missing", "deleted")
|
||||
}
|
||||
if lora_availability:
|
||||
filters["lora_availability"] = lora_availability
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ logger = logging.getLogger(__name__)
|
||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||
|
||||
# Valid LoRA availability statuses for the recipe listing filter.
|
||||
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
|
||||
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
@@ -2994,6 +2997,43 @@ class RecipeScanner:
|
||||
|
||||
return lora
|
||||
|
||||
def _compute_availability_statuses(self, recipe: Dict[str, Any]) -> Set[str]:
|
||||
"""Compute the LoRA availability status set for a recipe.
|
||||
|
||||
Returns ``{"ready"}`` when every non-excluded LoRA resolves to the
|
||||
local library (recipes without LoRAs count as ready); otherwise a
|
||||
subset of ``{"missing", "deleted"}``. Uses the same inLibrary
|
||||
resolution as ``_enrich_lora_entry`` (hash index with modelVersionId
|
||||
fallback) but performs only in-memory lookups.
|
||||
"""
|
||||
|
||||
statuses: Set[str] = set()
|
||||
for lora in recipe.get("loras") or []:
|
||||
if not isinstance(lora, dict) or lora.get("exclude"):
|
||||
continue
|
||||
|
||||
in_library = False
|
||||
if self._lora_scanner:
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if hash_value:
|
||||
in_library = self._lora_scanner.has_hash(hash_value)
|
||||
elif lora.get("modelVersionId") is not None:
|
||||
in_library = (
|
||||
self._get_lora_from_version_index(lora.get("modelVersionId"))
|
||||
is not None
|
||||
)
|
||||
|
||||
if in_library:
|
||||
continue
|
||||
if lora.get("isDeleted"):
|
||||
statuses.add("deleted")
|
||||
else:
|
||||
statuses.add("missing")
|
||||
|
||||
if not statuses:
|
||||
statuses.add("ready")
|
||||
return statuses
|
||||
|
||||
def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]:
|
||||
"""Return a preview URL that is reachable from the browser."""
|
||||
|
||||
@@ -3261,6 +3301,22 @@ class RecipeScanner:
|
||||
if not matches_exclude(item.get("tags"))
|
||||
]
|
||||
|
||||
# Filter by LoRA availability status
|
||||
availability = filters.get("lora_availability")
|
||||
if availability:
|
||||
selected = {
|
||||
status
|
||||
for status in availability
|
||||
if status in _VALID_LORA_AVAILABILITY_STATUSES
|
||||
}
|
||||
# Selecting every status (or none) means no filtering.
|
||||
if 0 < len(selected) < len(_VALID_LORA_AVAILABILITY_STATUSES):
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if self._compute_availability_statuses(item) & selected
|
||||
]
|
||||
|
||||
# Apply sorting if not already handled by pre-sorted cache
|
||||
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
|
||||
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||
|
||||
@@ -174,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add LoRA availability filter (no statuses selected = no filtering)
|
||||
if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) {
|
||||
params.append('lora_availability', pageState.filters.loraAvailability.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
|
||||
@@ -7,6 +7,10 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
|
||||
|
||||
// LoRA availability statuses available on the recipes page. No statuses
|
||||
// selected (the default) means no filtering.
|
||||
const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted'];
|
||||
|
||||
export class FilterManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
@@ -74,6 +78,11 @@ export class FilterManager {
|
||||
this.initializeLicenseFilters();
|
||||
}
|
||||
|
||||
// Add click handlers for LoRA availability tags (recipes page only)
|
||||
if (this.shouldShowLoraAvailabilityFilter()) {
|
||||
this.initializeLoraAvailabilityFilters();
|
||||
}
|
||||
|
||||
// Initialize tag logic toggle
|
||||
this.initializeTagLogicToggle();
|
||||
|
||||
@@ -421,6 +430,42 @@ export class FilterManager {
|
||||
});
|
||||
}
|
||||
|
||||
initializeLoraAvailabilityFilters() {
|
||||
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
|
||||
availabilityTags.forEach(tag => {
|
||||
tag.addEventListener('click', async () => {
|
||||
const status = tag.dataset.availability;
|
||||
const selected = this.filters.loraAvailability || [];
|
||||
|
||||
if (selected.includes(status)) {
|
||||
this.filters.loraAvailability = selected.filter(value => value !== status);
|
||||
tag.classList.remove('active');
|
||||
} else {
|
||||
this.filters.loraAvailability = [...selected, status];
|
||||
tag.classList.add('active');
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Update selections based on stored filters
|
||||
this.updateLoraAvailabilitySelections();
|
||||
}
|
||||
|
||||
updateLoraAvailabilitySelections() {
|
||||
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
|
||||
const selected = this.filters.loraAvailability || [];
|
||||
availabilityTags.forEach(tag => {
|
||||
if (selected.includes(tag.dataset.availability)) {
|
||||
tag.classList.add('active');
|
||||
} else {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createBaseModelTags() {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
if (!baseModelTagsContainer) return;
|
||||
@@ -681,6 +726,11 @@ export class FilterManager {
|
||||
}
|
||||
this.updateModelTypeSelections();
|
||||
|
||||
// Update LoRA availability tags if visible on this page
|
||||
if (this.shouldShowLoraAvailabilityFilter()) {
|
||||
this.updateLoraAvailabilitySelections();
|
||||
}
|
||||
|
||||
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
|
||||
autoTagEls.forEach(el => {
|
||||
const tag = el.dataset.autoTag;
|
||||
@@ -708,7 +758,9 @@ export class FilterManager {
|
||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||
// Active when at least one availability status is deselected
|
||||
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount;
|
||||
|
||||
if (this.activeFiltersCount) {
|
||||
if (totalActiveFilters > 0) {
|
||||
@@ -805,6 +857,7 @@ export class FilterManager {
|
||||
autoTags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
loraAvailability: [],
|
||||
tagLogic: 'any'
|
||||
});
|
||||
|
||||
@@ -891,12 +944,14 @@ export class FilterManager {
|
||||
const modelTypeCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
|
||||
return (
|
||||
baseModelCount > 0 ||
|
||||
tagCount > 0 ||
|
||||
autoTagCount > 0 ||
|
||||
licenseCount > 0 ||
|
||||
modelTypeCount > 0
|
||||
modelTypeCount > 0 ||
|
||||
loraAvailabilityCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -909,6 +964,7 @@ export class FilterManager {
|
||||
autoTags: this.normalizeTagFilters(source.autoTags),
|
||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||
loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability),
|
||||
tagLogic: source.tagLogic || 'any'
|
||||
};
|
||||
}
|
||||
@@ -917,6 +973,33 @@ export class FilterManager {
|
||||
return this.currentPage !== 'recipes';
|
||||
}
|
||||
|
||||
shouldShowLoraAvailabilityFilter() {
|
||||
return this.currentPage === 'recipes';
|
||||
}
|
||||
|
||||
normalizeLoraAvailabilityFilters(loraAvailability) {
|
||||
// Default to no statuses selected (= no filtering)
|
||||
if (!Array.isArray(loraAvailability)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return loraAvailability.reduce((acc, status) => {
|
||||
if (typeof status !== 'string') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!LORA_AVAILABILITY_STATUSES.includes(normalized) || seen.has(normalized)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
acc.push(normalized);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
normalizeTagFilters(tagFilters) {
|
||||
if (!tagFilters) {
|
||||
return {};
|
||||
@@ -994,6 +1077,7 @@ export class FilterManager {
|
||||
autoTags: { ...(this.filters.autoTags || {}) },
|
||||
license: { ...(this.filters.license || {}) },
|
||||
modelTypes: [...(this.filters.modelTypes || [])],
|
||||
loraAvailability: [...(this.filters.loraAvailability || [])],
|
||||
tagLogic: this.filters.tagLogic || 'any',
|
||||
search: pageState?.filters?.search ?? ''
|
||||
};
|
||||
|
||||
@@ -148,6 +148,7 @@ export const state = {
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
loraAvailability: [],
|
||||
search: ''
|
||||
},
|
||||
pageSize: 20,
|
||||
|
||||
@@ -264,6 +264,22 @@
|
||||
{{ t('header.filter.noTagMatches') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_page == 'recipes' %}
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.loraAvailability') }}</h4>
|
||||
<div class="filter-tags" id="loraAvailabilityTags">
|
||||
<div class="filter-tag lora-availability-tag" data-availability="ready">
|
||||
{{ t('header.filter.availabilityReady') }}
|
||||
</div>
|
||||
<div class="filter-tag lora-availability-tag" data-availability="missing">
|
||||
{{ t('header.filter.availabilityMissing') }}
|
||||
</div>
|
||||
<div class="filter-tag lora-availability-tag" data-availability="deleted">
|
||||
{{ t('header.filter.availabilityDeleted') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.modelTypes') }}</h4>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: {
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
getCurrentPageState: getCurrentPageStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
captureScrollPosition: vi.fn(),
|
||||
restoreScrollPosition: vi.fn(),
|
||||
recreateVirtualScroll: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchRecipesPage } from '../../../static/js/api/recipeApi.js';
|
||||
|
||||
function makePageState(loraAvailability) {
|
||||
return {
|
||||
pageSize: 50,
|
||||
currentPage: 1,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
sortBy: 'date:desc',
|
||||
showFavoritesOnly: false,
|
||||
activeFolder: null,
|
||||
searchOptions: { recursive: true },
|
||||
customFilter: { active: false },
|
||||
filters: { loraAvailability },
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchRecipesPage lora_availability param', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ items: [], total: 0, total_pages: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('appends lora_availability when a subset of statuses is selected', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(makePageState(['missing', 'deleted']));
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBe('missing,deleted');
|
||||
});
|
||||
|
||||
it('appends lora_availability when all statuses are selected (backend treats it as show-all)', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(
|
||||
makePageState(['ready', 'missing', 'deleted'])
|
||||
);
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBe('ready,missing,deleted');
|
||||
});
|
||||
|
||||
it('omits lora_availability when no statuses are selected', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(makePageState([]));
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBeNull();
|
||||
});
|
||||
|
||||
it('omits lora_availability when the filter is absent', async () => {
|
||||
getCurrentPageStateMock.mockReturnValue(makePageState(undefined));
|
||||
|
||||
await fetchRecipesPage(1, 50);
|
||||
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
expect(params.get('lora_availability')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: vi.fn(() => ({
|
||||
filters: {},
|
||||
})),
|
||||
state: {
|
||||
currentPageType: 'recipes',
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
updatePanelPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(() => ({
|
||||
loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(),
|
||||
setStorageItem: vi.fn(),
|
||||
removeStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key, _params, fallback) => fallback || key),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({
|
||||
FilterPresetManager: vi.fn().mockImplementation(() => ({
|
||||
renderPresets: vi.fn(),
|
||||
saveActivePreset: vi.fn(),
|
||||
restoreActivePreset: vi.fn(),
|
||||
updateAddButtonState: vi.fn(),
|
||||
hasEmptyWildcardResult: vi.fn(() => false),
|
||||
})),
|
||||
EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__',
|
||||
}));
|
||||
|
||||
import { FilterManager } from '../../../static/js/managers/FilterManager.js';
|
||||
import { getStorageItem } from '../../../static/js/utils/storageHelpers.js';
|
||||
|
||||
const ALL_STATUSES = ['ready', 'missing', 'deleted'];
|
||||
|
||||
describe('FilterManager - LoRA Availability', () => {
|
||||
let manager;
|
||||
let mockFilterPanel;
|
||||
let mockActiveFiltersCount;
|
||||
|
||||
function createAvailabilityTags() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'loraAvailabilityTags';
|
||||
ALL_STATUSES.forEach(status => {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'filter-tag lora-availability-tag';
|
||||
tag.dataset.availability = status;
|
||||
container.appendChild(tag);
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getStorageItem.mockReturnValue(undefined);
|
||||
document.body.innerHTML = '';
|
||||
|
||||
mockFilterPanel = document.createElement('div');
|
||||
mockFilterPanel.id = 'filterPanel';
|
||||
mockFilterPanel.classList.add('hidden');
|
||||
document.body.appendChild(mockFilterPanel);
|
||||
|
||||
mockActiveFiltersCount = document.createElement('span');
|
||||
createAvailabilityTags();
|
||||
|
||||
const originalGetElementById = document.getElementById;
|
||||
document.getElementById = vi.fn((id) => {
|
||||
if (id === 'filterPanel') return mockFilterPanel;
|
||||
if (id === 'filterButton') return document.createElement('button');
|
||||
if (id === 'activeFiltersCount') return mockActiveFiltersCount;
|
||||
if (id === 'baseModelTags') return document.createElement('div');
|
||||
if (id === 'modelTypeTags') return document.createElement('div');
|
||||
return originalGetElementById.call(document, id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeFilters', () => {
|
||||
it('should default to no statuses selected on the recipes page', () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
});
|
||||
|
||||
it('should restore a saved selection from storage', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['missing'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(['missing']);
|
||||
});
|
||||
|
||||
it('should drop invalid stored values', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['missing', 'bogus', 'missing'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(['missing']);
|
||||
});
|
||||
|
||||
it('should default to no statuses when the stored value is not an array', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: 'missing',
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveFilters', () => {
|
||||
it('should be inactive when no statuses are selected', () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.hasActiveFilters()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be active when at least one status is selected', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['ready'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.hasActiveFilters()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateActiveFiltersCount', () => {
|
||||
it('should count selected statuses', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['missing', 'deleted'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(mockActiveFiltersCount.textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chip interaction', () => {
|
||||
it('should select a status when its chip is clicked', async () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const readyTag = document.querySelector('[data-availability="ready"]');
|
||||
expect(readyTag.classList.contains('active')).toBe(false);
|
||||
|
||||
readyTag.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(['ready']);
|
||||
expect(readyTag.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('should deselect a selected status when its chip is clicked again', async () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['ready'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const readyTag = document.querySelector('[data-availability="ready"]');
|
||||
// Restored state should mark the chip active
|
||||
expect(readyTag.classList.contains('active')).toBe(true);
|
||||
|
||||
readyTag.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
expect(readyTag.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark all chips active when a stored all-statuses array is restored', () => {
|
||||
// Legacy stored value: all statuses selected. Under positive
|
||||
// selection semantics the backend treats this as show-all.
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: [...ALL_STATUSES],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual(ALL_STATUSES);
|
||||
document.querySelectorAll('.lora-availability-tag').forEach(tag => {
|
||||
expect(tag.classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneFilters', () => {
|
||||
it('should include loraAvailability in cloned filters', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['deleted'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const cloned = manager.cloneFilters();
|
||||
|
||||
expect(cloned.loraAvailability).toEqual(['deleted']);
|
||||
});
|
||||
|
||||
it('should clone an empty selection as an empty array', () => {
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
|
||||
const cloned = manager.cloneFilters();
|
||||
|
||||
expect(cloned.loraAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearFilters', () => {
|
||||
it('should reset loraAvailability to no statuses selected', () => {
|
||||
getStorageItem.mockReturnValue({
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
loraAvailability: ['deleted'],
|
||||
});
|
||||
|
||||
manager = new FilterManager({ page: 'recipes' });
|
||||
expect(manager.filters.loraAvailability).toEqual(['deleted']);
|
||||
|
||||
manager.clearFilters();
|
||||
|
||||
expect(manager.filters.loraAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -632,6 +632,43 @@ async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||
assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123"
|
||||
|
||||
|
||||
async def test_list_recipes_passes_lora_availability_filter(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes?lora_availability=missing,deleted"
|
||||
)
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["items"] == []
|
||||
assert harness.scanner.last_paginated_params is not None
|
||||
filters = harness.scanner.last_paginated_params["filters"]
|
||||
assert filters["lora_availability"] == {"missing", "deleted"}
|
||||
|
||||
|
||||
async def test_list_recipes_ignores_invalid_lora_availability_values(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
# Valid values are kept, invalid ones dropped
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes?lora_availability=bogus,ready"
|
||||
)
|
||||
assert response.status == 200
|
||||
assert harness.scanner.last_paginated_params is not None
|
||||
filters = harness.scanner.last_paginated_params["filters"]
|
||||
assert filters["lora_availability"] == {"ready"}
|
||||
|
||||
# No valid values at all -> no availability filter
|
||||
response = await harness.client.get("/api/lm/recipes?lora_availability=bogus")
|
||||
assert response.status == 200
|
||||
assert harness.scanner.last_paginated_params is not None
|
||||
filters = harness.scanner.last_paginated_params["filters"]
|
||||
assert "lora_availability" not in filters
|
||||
|
||||
|
||||
async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.checkpoint_lookup["abc123"] = [
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for the LoRA availability filter on the recipe listing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from py.config import config
|
||||
from py.services.recipe_scanner import RecipeScanner
|
||||
|
||||
|
||||
class StubLoraScanner:
|
||||
"""In-memory lora scanner double exposing the hash/version indexes."""
|
||||
|
||||
def __init__(self, hashes=(), version_index=None):
|
||||
self._hashes = {value.lower() for value in hashes}
|
||||
self._cache = SimpleNamespace(raw_data=[], version_index=version_index or {})
|
||||
|
||||
def has_hash(self, hash_value):
|
||||
return hash_value.lower() in self._hashes
|
||||
|
||||
def get_preview_url_by_hash(self, hash_value):
|
||||
return None
|
||||
|
||||
def get_path_by_hash(self, hash_value):
|
||||
return None
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
|
||||
async def refresh_cache(self, force=False):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recipe_scanner(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||
lora_scanner = StubLoraScanner(
|
||||
hashes={"aaa111"},
|
||||
version_index={42: {"file_path": "/loras/from-version.safetensors"}},
|
||||
)
|
||||
scanner = RecipeScanner(lora_scanner=lora_scanner) # pyright: ignore[reportArgumentType]
|
||||
|
||||
recipes = [
|
||||
# All loras in library (hash matching is case-insensitive) -> ready
|
||||
{"id": "r1", "title": "Ready", "loras": [{"hash": "AAA111"}]},
|
||||
# One lora not in library -> missing
|
||||
{
|
||||
"id": "r2",
|
||||
"title": "Missing",
|
||||
"loras": [{"hash": "aaa111"}, {"hash": "bbb222"}],
|
||||
},
|
||||
# Lora deleted on Civitai and not in library -> deleted
|
||||
{
|
||||
"id": "r3",
|
||||
"title": "Deleted",
|
||||
"loras": [{"hash": "ccc333", "isDeleted": True}],
|
||||
},
|
||||
# Missing + deleted -> both statuses
|
||||
{
|
||||
"id": "r4",
|
||||
"title": "Mixed",
|
||||
"loras": [{"hash": "bbb222"}, {"hash": "ccc333", "isDeleted": True}],
|
||||
},
|
||||
# No loras at all -> ready
|
||||
{"id": "r5", "title": "Empty", "loras": []},
|
||||
# Excluded lora is ignored -> ready
|
||||
{
|
||||
"id": "r6",
|
||||
"title": "Excluded",
|
||||
"loras": [{"hash": "bbb222", "exclude": True}],
|
||||
},
|
||||
# modelVersionId resolves via the version index -> ready
|
||||
{"id": "r7", "title": "VersionFallback", "loras": [{"modelVersionId": 42}]},
|
||||
]
|
||||
|
||||
scanner._cache = SimpleNamespace(
|
||||
raw_data=recipes,
|
||||
sorted_by_date=recipes,
|
||||
sorted_by_name=recipes,
|
||||
)
|
||||
return scanner
|
||||
|
||||
|
||||
async def _fetch_ids(scanner, filters=None):
|
||||
result = await scanner.get_paginated_data(page=1, page_size=50, filters=filters)
|
||||
return {item["id"] for item in result["items"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_ready_only(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"ready"}})
|
||||
assert ids == {"r1", "r5", "r6", "r7"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_missing_only(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing"}})
|
||||
assert ids == {"r2", "r4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_deleted_only(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"deleted"}})
|
||||
assert ids == {"r3", "r4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_missing_and_deleted(recipe_scanner):
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"missing", "deleted"}})
|
||||
assert ids == {"r2", "r3", "r4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_all_statuses_disables_filtering(recipe_scanner):
|
||||
ids = await _fetch_ids(
|
||||
recipe_scanner, {"lora_availability": {"ready", "missing", "deleted"}}
|
||||
)
|
||||
assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_absent_or_invalid_disables_filtering(recipe_scanner):
|
||||
assert await _fetch_ids(recipe_scanner) == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
|
||||
ids = await _fetch_ids(recipe_scanner, {"lora_availability": {"bogus"}})
|
||||
assert ids == {"r1", "r2", "r3", "r4", "r5", "r6", "r7"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_filter_counts_and_pagination(recipe_scanner):
|
||||
# Filtering happens before pagination, so totals reflect the filtered set.
|
||||
result = await recipe_scanner.get_paginated_data(
|
||||
page=1, page_size=1, filters={"lora_availability": {"ready"}}
|
||||
)
|
||||
assert result["total"] == 4
|
||||
assert result["total_pages"] == 4
|
||||
assert len(result["items"]) == 1
|
||||
Reference in New Issue
Block a user