mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
feat: Introduce "No tags" filter option for models and recipes. fixes #728
This commit is contained in:
@@ -200,6 +200,7 @@
|
|||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
|
"noTags": "Keine Tags",
|
||||||
"clearAll": "Alle Filter löschen"
|
"clearAll": "Alle Filter löschen"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
|
"noTags": "No tags",
|
||||||
"clearAll": "Clear All Filters"
|
"clearAll": "Clear All Filters"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
|
"noTags": "Sin etiquetas",
|
||||||
"clearAll": "Limpiar todos los filtros"
|
"clearAll": "Limpiar todos los filtros"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
|
"noTags": "Aucun tag",
|
||||||
"clearAll": "Effacer tous les filtres"
|
"clearAll": "Effacer tous les filtres"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
|
"noTags": "ללא תגיות",
|
||||||
"clearAll": "נקה את כל המסננים"
|
"clearAll": "נקה את כל המסננים"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
|
"noTags": "タグなし",
|
||||||
"clearAll": "すべてのフィルタをクリア"
|
"clearAll": "すべてのフィルタをクリア"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
|
"noTags": "태그 없음",
|
||||||
"clearAll": "모든 필터 지우기"
|
"clearAll": "모든 필터 지우기"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
|
"noTags": "Без тегов",
|
||||||
"clearAll": "Очистить все фильтры"
|
"clearAll": "Очистить все фильтры"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
|
"noTags": "无标签",
|
||||||
"clearAll": "清除所有筛选"
|
"clearAll": "清除所有筛选"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
|
"noTags": "無標籤",
|
||||||
"clearAll": "清除所有篩選"
|
"clearAll": "清除所有篩選"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -161,15 +161,25 @@ class ModelFilterSet:
|
|||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags = {tag for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
|
def matches_include(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in include_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in include_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
item for item in items
|
item for item in items
|
||||||
if any(tag in include_tags for tag in (item.get("tags", []) or []))
|
if matches_include(item.get("tags"))
|
||||||
]
|
]
|
||||||
|
|
||||||
if exclude_tags:
|
if exclude_tags:
|
||||||
|
def matches_exclude(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
item for item in items
|
item for item in items
|
||||||
if not any(tag in exclude_tags for tag in (item.get("tags", []) or []))
|
if not matches_exclude(item.get("tags"))
|
||||||
]
|
]
|
||||||
|
|
||||||
model_types = criteria.model_types or []
|
model_types = criteria.model_types or []
|
||||||
|
|||||||
@@ -1162,15 +1162,25 @@ class RecipeScanner:
|
|||||||
include_tags = {tag for tag in tag_spec if tag}
|
include_tags = {tag for tag in tag_spec if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
|
def matches_include(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in include_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in include_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
item for item in filtered_data
|
||||||
if any(tag in include_tags for tag in (item.get('tags', []) or []))
|
if matches_include(item.get('tags'))
|
||||||
]
|
]
|
||||||
|
|
||||||
if exclude_tags:
|
if exclude_tags:
|
||||||
|
def matches_exclude(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
item for item in filtered_data
|
||||||
if not any(tag in exclude_tags for tag in (item.get('tags', []) or []))
|
if not matches_exclude(item.get('tags'))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,20 @@
|
|||||||
border-color: var(--lora-error-border);
|
border-color: var(--lora-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle styling for special system tags like "No tags" */
|
||||||
|
.filter-tag.special-tag {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure solid border and full opacity when active or excluded */
|
||||||
|
.filter-tag.special-tag.active,
|
||||||
|
.filter-tag.special-tag.exclude {
|
||||||
|
border-style: solid;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag filter styles */
|
/* Tag filter styles */
|
||||||
.tag-filter {
|
.tag-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,32 +3,33 @@ import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
|||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.options = {
|
this.options = {
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
this.filters = this.initializeFilters(pageState ? pageState.filters : undefined);
|
this.filters = this.initializeFilters(pageState ? pageState.filters : undefined);
|
||||||
|
|
||||||
this.filterPanel = document.getElementById('filterPanel');
|
this.filterPanel = document.getElementById('filterPanel');
|
||||||
this.filterButton = document.getElementById('filterButton');
|
this.filterButton = document.getElementById('filterButton');
|
||||||
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||||
this.tagsLoaded = false;
|
this.tagsLoaded = false;
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
// Store this instance in the state
|
// Store this instance in the state
|
||||||
if (pageState) {
|
if (pageState) {
|
||||||
pageState.filterManager = this;
|
pageState.filterManager = this;
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Create base model filter tags if they exist
|
// Create base model filter tags if they exist
|
||||||
if (document.getElementById('baseModelTags')) {
|
if (document.getElementById('baseModelTags')) {
|
||||||
@@ -50,39 +51,39 @@ export class FilterManager {
|
|||||||
this.toggleFilterPanel();
|
this.toggleFilterPanel();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close filter panel when clicking outside
|
// Close filter panel when clicking outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
|
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
|
||||||
e.target !== this.filterButton &&
|
e.target !== this.filterButton &&
|
||||||
!this.filterButton.contains(e.target) &&
|
!this.filterButton.contains(e.target) &&
|
||||||
!this.filterPanel.classList.contains('hidden')) {
|
!this.filterPanel.classList.contains('hidden')) {
|
||||||
this.closeFilterPanel();
|
this.closeFilterPanel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize active filters from localStorage if available
|
// Initialize active filters from localStorage if available
|
||||||
this.loadFiltersFromStorage();
|
this.loadFiltersFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTopTags() {
|
async loadTopTags() {
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
|
||||||
// Determine the API endpoint based on the page type
|
// Determine the API endpoint based on the page type
|
||||||
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
||||||
|
|
||||||
const response = await fetch(tagsEndpoint);
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.tags) {
|
if (data.success && data.tags) {
|
||||||
this.createTagFilterElements(data.tags);
|
this.createTagFilterElements(data.tags);
|
||||||
|
|
||||||
// After creating tag elements, mark any previously selected ones
|
// After creating tag elements, mark any previously selected ones
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
} else {
|
} else {
|
||||||
@@ -96,57 +97,79 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createTagFilterElements(tags) {
|
createTagFilterElements(tags) {
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '';
|
tagsContainer.innerHTML = '';
|
||||||
|
|
||||||
if (!tags.length) {
|
if (!tags.length) {
|
||||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const tagEl = document.createElement('div');
|
const tagEl = document.createElement('div');
|
||||||
tagEl.className = 'filter-tag tag-filter';
|
tagEl.className = 'filter-tag tag-filter';
|
||||||
const tagName = tag.tag;
|
const tagName = tag.tag;
|
||||||
tagEl.dataset.tag = tagName;
|
tagEl.dataset.tag = tagName;
|
||||||
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||||
|
|
||||||
// Add click handler to cycle through tri-state filter and automatically apply
|
// Add click handler to cycle through tri-state filter and automatically apply
|
||||||
tagEl.addEventListener('click', async () => {
|
tagEl.addEventListener('click', async () => {
|
||||||
const currentState = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
const currentState = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
||||||
const newState = this.getNextTriStateState(currentState);
|
const newState = this.getNextTriStateState(currentState);
|
||||||
this.setTagFilterState(tagName, newState);
|
this.setTagFilterState(tagName, newState);
|
||||||
this.applyTagElementState(tagEl, newState);
|
this.applyTagElementState(tagEl, newState);
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Auto-apply filter when tag is clicked
|
// Auto-apply filter when tag is clicked
|
||||||
await this.applyFilters(false);
|
await this.applyFilters(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
||||||
tagsContainer.appendChild(tagEl);
|
tagsContainer.appendChild(tagEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add "No tags" as a special filter at the end
|
||||||
|
const noTagsEl = document.createElement('div');
|
||||||
|
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||||
|
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||||
|
const noTagsKey = '__no_tags__';
|
||||||
|
noTagsEl.dataset.tag = noTagsKey;
|
||||||
|
noTagsEl.innerHTML = noTagsLabel;
|
||||||
|
|
||||||
|
noTagsEl.addEventListener('click', async () => {
|
||||||
|
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||||
|
const newState = this.getNextTriStateState(currentState);
|
||||||
|
this.setTagFilterState(noTagsKey, newState);
|
||||||
|
this.applyTagElementState(noTagsEl, newState);
|
||||||
|
|
||||||
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
|
await this.applyFilters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
|
||||||
|
tagsContainer.appendChild(noTagsEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeLicenseFilters() {
|
initializeLicenseFilters() {
|
||||||
const licenseTags = document.querySelectorAll('.license-tag');
|
const licenseTags = document.querySelectorAll('.license-tag');
|
||||||
licenseTags.forEach(tag => {
|
licenseTags.forEach(tag => {
|
||||||
tag.addEventListener('click', async () => {
|
tag.addEventListener('click', async () => {
|
||||||
const licenseType = tag.dataset.license;
|
const licenseType = tag.dataset.license;
|
||||||
|
|
||||||
// Ensure license object exists
|
// Ensure license object exists
|
||||||
if (!this.filters.license) {
|
if (!this.filters.license) {
|
||||||
this.filters.license = {};
|
this.filters.license = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current state
|
// Get current state
|
||||||
let currentState = this.filters.license[licenseType] || 'none'; // none, include, exclude
|
let currentState = this.filters.license[licenseType] || 'none'; // none, include, exclude
|
||||||
|
|
||||||
// Cycle through states: none -> include -> exclude -> none
|
// Cycle through states: none -> include -> exclude -> none
|
||||||
let newState;
|
let newState;
|
||||||
switch (currentState) {
|
switch (currentState) {
|
||||||
@@ -165,7 +188,7 @@ export class FilterManager {
|
|||||||
tag.classList.remove('active', 'exclude');
|
tag.classList.remove('active', 'exclude');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update filter state
|
// Update filter state
|
||||||
if (newState === 'none') {
|
if (newState === 'none') {
|
||||||
delete this.filters.license[licenseType];
|
delete this.filters.license[licenseType];
|
||||||
@@ -176,27 +199,27 @@ export class FilterManager {
|
|||||||
} else {
|
} else {
|
||||||
this.filters.license[licenseType] = newState;
|
this.filters.license[licenseType] = newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Auto-apply filter when tag is clicked
|
// Auto-apply filter when tag is clicked
|
||||||
await this.applyFilters(false);
|
await this.applyFilters(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update selections based on stored filters
|
// Update selections based on stored filters
|
||||||
this.updateLicenseSelections();
|
this.updateLicenseSelections();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLicenseSelections() {
|
updateLicenseSelections() {
|
||||||
const licenseTags = document.querySelectorAll('.license-tag');
|
const licenseTags = document.querySelectorAll('.license-tag');
|
||||||
licenseTags.forEach(tag => {
|
licenseTags.forEach(tag => {
|
||||||
const licenseType = tag.dataset.license;
|
const licenseType = tag.dataset.license;
|
||||||
const state = (this.filters.license && this.filters.license[licenseType]) || 'none';
|
const state = (this.filters.license && this.filters.license[licenseType]) || 'none';
|
||||||
|
|
||||||
// Reset classes
|
// Reset classes
|
||||||
tag.classList.remove('active', 'exclude');
|
tag.classList.remove('active', 'exclude');
|
||||||
|
|
||||||
// Apply appropriate class based on state
|
// Apply appropriate class based on state
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'include':
|
case 'include':
|
||||||
@@ -211,31 +234,31 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createBaseModelTags() {
|
createBaseModelTags() {
|
||||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
// Set the API endpoint based on current page
|
// Set the API endpoint based on current page
|
||||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
|
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
|
||||||
|
|
||||||
// Fetch base models
|
// Fetch base models
|
||||||
fetch(apiEndpoint)
|
fetch(apiEndpoint)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.base_models) {
|
if (data.success && data.base_models) {
|
||||||
baseModelTagsContainer.innerHTML = '';
|
baseModelTagsContainer.innerHTML = '';
|
||||||
|
|
||||||
data.base_models.forEach(model => {
|
data.base_models.forEach(model => {
|
||||||
const tag = document.createElement('div');
|
const tag = document.createElement('div');
|
||||||
tag.className = `filter-tag base-model-tag`;
|
tag.className = `filter-tag base-model-tag`;
|
||||||
tag.dataset.baseModel = model.name;
|
tag.dataset.baseModel = model.name;
|
||||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||||
|
|
||||||
// Add click handler to toggle selection and automatically apply
|
// Add click handler to toggle selection and automatically apply
|
||||||
tag.addEventListener('click', async () => {
|
tag.addEventListener('click', async () => {
|
||||||
tag.classList.toggle('active');
|
tag.classList.toggle('active');
|
||||||
|
|
||||||
if (tag.classList.contains('active')) {
|
if (tag.classList.contains('active')) {
|
||||||
if (!this.filters.baseModel.includes(model.name)) {
|
if (!this.filters.baseModel.includes(model.name)) {
|
||||||
this.filters.baseModel.push(model.name);
|
this.filters.baseModel.push(model.name);
|
||||||
@@ -243,24 +266,24 @@ export class FilterManager {
|
|||||||
} else {
|
} else {
|
||||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Auto-apply filter when tag is clicked
|
// Auto-apply filter when tag is clicked
|
||||||
await this.applyFilters(false);
|
await this.applyFilters(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
baseModelTagsContainer.appendChild(tag);
|
baseModelTagsContainer.appendChild(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update selections based on stored filters
|
// Update selections based on stored filters
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
||||||
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createModelTypeTags() {
|
async createModelTypeTags() {
|
||||||
@@ -336,18 +359,18 @@ export class FilterManager {
|
|||||||
modelTypeContainer.innerHTML = '<div class="tags-error">Failed to load model types</div>';
|
modelTypeContainer.innerHTML = '<div class="tags-error">Failed to load model types</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFilterPanel() {
|
toggleFilterPanel() {
|
||||||
if (this.filterPanel) {
|
if (this.filterPanel) {
|
||||||
const isHidden = this.filterPanel.classList.contains('hidden');
|
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||||
|
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
// Update panel positions before showing
|
// Update panel positions before showing
|
||||||
updatePanelPositions();
|
updatePanelPositions();
|
||||||
|
|
||||||
this.filterPanel.classList.remove('hidden');
|
this.filterPanel.classList.remove('hidden');
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
|
|
||||||
// Load tags if they haven't been loaded yet
|
// Load tags if they haven't been loaded yet
|
||||||
if (!this.tagsLoaded) {
|
if (!this.tagsLoaded) {
|
||||||
this.loadTopTags();
|
this.loadTopTags();
|
||||||
@@ -358,7 +381,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeFilterPanel() {
|
closeFilterPanel() {
|
||||||
if (this.filterPanel) {
|
if (this.filterPanel) {
|
||||||
this.filterPanel.classList.add('hidden');
|
this.filterPanel.classList.add('hidden');
|
||||||
@@ -367,7 +390,7 @@ export class FilterManager {
|
|||||||
this.filterButton.classList.remove('active');
|
this.filterButton.classList.remove('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTagSelections() {
|
updateTagSelections() {
|
||||||
// Update base model tags
|
// Update base model tags
|
||||||
const baseModelTags = document.querySelectorAll('.base-model-tag');
|
const baseModelTags = document.querySelectorAll('.base-model-tag');
|
||||||
@@ -379,7 +402,7 @@ export class FilterManager {
|
|||||||
tag.classList.remove('active');
|
tag.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update model tags
|
// Update model tags
|
||||||
const modelTags = document.querySelectorAll('.tag-filter');
|
const modelTags = document.querySelectorAll('.tag-filter');
|
||||||
modelTags.forEach(tag => {
|
modelTags.forEach(tag => {
|
||||||
@@ -387,7 +410,7 @@ export class FilterManager {
|
|||||||
const state = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
const state = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
||||||
this.applyTagElementState(tag, state);
|
this.applyTagElementState(tag, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update license tags if visible on this page
|
// Update license tags if visible on this page
|
||||||
if (this.shouldShowLicenseFilters()) {
|
if (this.shouldShowLicenseFilters()) {
|
||||||
this.updateLicenseSelections();
|
this.updateLicenseSelections();
|
||||||
@@ -406,13 +429,13 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateActiveFiltersCount() {
|
updateActiveFiltersCount() {
|
||||||
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||||
const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||||
|
|
||||||
if (this.activeFiltersCount) {
|
if (this.activeFiltersCount) {
|
||||||
if (totalActiveFilters > 0) {
|
if (totalActiveFilters > 0) {
|
||||||
this.activeFiltersCount.textContent = totalActiveFilters;
|
this.activeFiltersCount.textContent = totalActiveFilters;
|
||||||
@@ -422,18 +445,18 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyFilters(showToastNotification = true) {
|
async applyFilters(showToastNotification = true) {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
|
||||||
// Save filters to localStorage
|
// Save filters to localStorage
|
||||||
const filtersSnapshot = this.cloneFilters();
|
const filtersSnapshot = this.cloneFilters();
|
||||||
setStorageItem(storageKey, filtersSnapshot);
|
setStorageItem(storageKey, filtersSnapshot);
|
||||||
|
|
||||||
// Update state with current filters
|
// Update state with current filters
|
||||||
pageState.filters = filtersSnapshot;
|
pageState.filters = filtersSnapshot;
|
||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes(true);
|
||||||
@@ -441,14 +464,14 @@ export class FilterManager {
|
|||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update filter button to show active state
|
// Update filter button to show active state
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
if (showToastNotification) {
|
if (showToastNotification) {
|
||||||
const baseModelCount = this.filters.baseModel.length;
|
const baseModelCount = this.filters.baseModel.length;
|
||||||
const tagsCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagsCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
if (baseModelCount > 0 && tagsCount > 0) {
|
if (baseModelCount > 0 && tagsCount > 0) {
|
||||||
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||||
@@ -457,7 +480,7 @@ export class FilterManager {
|
|||||||
} else if (tagsCount > 0) {
|
} else if (tagsCount > 0) {
|
||||||
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.filters.applied', { message }, 'success');
|
showToast('toast.filters.applied', { message }, 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -467,7 +490,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearFilters() {
|
async clearFilters() {
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
this.filters = this.initializeFilters({
|
this.filters = this.initializeFilters({
|
||||||
@@ -477,52 +500,52 @@ export class FilterManager {
|
|||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Remove from local Storage
|
// Remove from local Storage
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
removeStorageItem(storageKey);
|
removeStorageItem(storageKey);
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
this.filterButton.classList.remove('active');
|
this.filterButton.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.filters.cleared', {}, 'info');
|
showToast('toast.filters.cleared', {}, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFiltersFromStorage() {
|
loadFiltersFromStorage() {
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
const savedFilters = getStorageItem(storageKey);
|
const savedFilters = getStorageItem(storageKey);
|
||||||
|
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
// Ensure backward compatibility with older filter format
|
// Ensure backward compatibility with older filter format
|
||||||
this.filters = this.initializeFilters(savedFilters);
|
this.filters = this.initializeFilters(savedFilters);
|
||||||
|
|
||||||
// Update state with loaded filters
|
// Update state with loaded filters
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
|
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -531,7 +554,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActiveFilters() {
|
hasActiveFilters() {
|
||||||
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
|
|||||||
104
tests/services/test_no_tags_filter.py
Normal file
104
tests/services/test_no_tags_filter.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import pytest
|
||||||
|
from py.services.model_query import ModelFilterSet, FilterCriteria
|
||||||
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from pathlib import Path
|
||||||
|
from py.config import config
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
class StubSettings:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# --- Model Filtering Tests ---
|
||||||
|
|
||||||
|
def test_model_filter_set_no_tags_include():
|
||||||
|
filter_set = ModelFilterSet(StubSettings())
|
||||||
|
data = [
|
||||||
|
{"name": "m1", "tags": ["tag1"]},
|
||||||
|
{"name": "m2", "tags": []},
|
||||||
|
{"name": "m3", "tags": None},
|
||||||
|
{"name": "m4", "tags": ["tag2"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Include __no_tags__
|
||||||
|
criteria = FilterCriteria(tags={"__no_tags__": "include"})
|
||||||
|
result = filter_set.apply(data, criteria)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert {item["name"] for item in result} == {"m2", "m3"}
|
||||||
|
|
||||||
|
def test_model_filter_set_no_tags_exclude():
|
||||||
|
filter_set = ModelFilterSet(StubSettings())
|
||||||
|
data = [
|
||||||
|
{"name": "m1", "tags": ["tag1"]},
|
||||||
|
{"name": "m2", "tags": []},
|
||||||
|
{"name": "m3", "tags": None},
|
||||||
|
{"name": "m4", "tags": ["tag2"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Exclude __no_tags__
|
||||||
|
criteria = FilterCriteria(tags={"__no_tags__": "exclude"})
|
||||||
|
result = filter_set.apply(data, criteria)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert {item["name"] for item in result} == {"m1", "m4"}
|
||||||
|
|
||||||
|
def test_model_filter_set_no_tags_mixed():
|
||||||
|
filter_set = ModelFilterSet(StubSettings())
|
||||||
|
data = [
|
||||||
|
{"name": "m1", "tags": ["tag1"]},
|
||||||
|
{"name": "m2", "tags": []},
|
||||||
|
{"name": "m3", "tags": None},
|
||||||
|
{"name": "m4", "tags": ["tag1", "tag2"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Include tag1 AND __no_tags__
|
||||||
|
criteria = FilterCriteria(tags={"tag1": "include", "__no_tags__": "include"})
|
||||||
|
result = filter_set.apply(data, criteria)
|
||||||
|
# m1 (tag1), m2 (no tags), m3 (no tags), m4 (tag1)
|
||||||
|
assert len(result) == 4
|
||||||
|
|
||||||
|
# --- Recipe Filtering Tests ---
|
||||||
|
|
||||||
|
class StubLoraScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self._cache = SimpleNamespace(raw_data=[], version_index={})
|
||||||
|
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)])
|
||||||
|
stub = StubLoraScanner()
|
||||||
|
scanner = RecipeScanner(lora_scanner=stub)
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipe_scanner_no_tags_filter(recipe_scanner):
|
||||||
|
scanner = recipe_scanner
|
||||||
|
|
||||||
|
# Mock some recipe data
|
||||||
|
recipes = [
|
||||||
|
{"id": "r1", "tags": ["tag1"], "title": "R1"},
|
||||||
|
{"id": "r2", "tags": [], "title": "R2"},
|
||||||
|
{"id": "r3", "tags": None, "title": "R3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# We need to inject these into the scanner's cache
|
||||||
|
# Since get_paginated_data calls get_cached_data() which we stubbed
|
||||||
|
scanner._cache = SimpleNamespace(
|
||||||
|
raw_data=recipes,
|
||||||
|
sorted_by_date=recipes,
|
||||||
|
sorted_by_name=recipes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test Include __no_tags__
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "include"}})
|
||||||
|
assert len(result["items"]) == 2
|
||||||
|
assert {item["id"] for item in result["items"]} == {"r2", "r3"}
|
||||||
|
|
||||||
|
# Test Exclude __no_tags__
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "exclude"}})
|
||||||
|
assert len(result["items"]) == 1
|
||||||
|
assert result["items"][0]["id"] == "r1"
|
||||||
Reference in New Issue
Block a user