feat(ui): auto-detect HIGH/LOW badges and auto-tag filters (#918)

- Backend auto-tag extraction service: detect HIGH/LOW (Wan-only), I2V/T2V/TI2V,
  Lightning/Turbo from filename, base_model, and CivitAI version name
- HIGH/LOW badge in card footer (inline before version name), color-coded:
  blue for HIGH, teal for LOW; abbreviated to H/L in medium/compact density
- Auto-tag filter panel (I2V, T2V, TI2V, Lightning, Turbo) with tri-state
  include/exclude filtering
- Full filter pipeline: FilterCriteria → ModelFilterSet → baseModelApi params
- AUTO_TAG_GROUPS exported for frontend use
- 19 unit tests for auto-tag extraction edge cases
This commit is contained in:
Will Miao
2026-05-17 17:45:12 +08:00
parent a74cbe7aa2
commit cc20d3b992
23 changed files with 524 additions and 8 deletions

View File

@@ -70,6 +70,9 @@ export class FilterManager {
// Initialize tag logic toggle
this.initializeTagLogicToggle();
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
this.createAutoTagFilters();
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
@@ -480,6 +483,58 @@ export class FilterManager {
}
}
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
createAutoTagFilters() {
const container = document.getElementById('autoTagFilterTags');
if (container) return;
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
if (!modelTypeSection) return;
const section = document.createElement('div');
section.className = 'filter-section';
section.innerHTML = `
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
<div class="filter-tags" id="autoTagFilterTags"></div>
`;
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
const tagsContainer = document.getElementById('autoTagFilterTags');
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
const el = document.createElement('div');
el.className = 'filter-tag auto-tag-filter';
el.dataset.autoTag = tag;
el.textContent = tag;
// Restore previous state
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
el.addEventListener('click', async () => {
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
if (!this.filters.autoTags) this.filters.autoTags = {};
if (next === 'none') {
delete this.filters.autoTags[tag];
} else {
this.filters.autoTags[tag] = next;
}
this._applyTriState(el, next);
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
tagsContainer.appendChild(el);
});
}
_applyTriState(el, state) {
el.classList.remove('active', 'exclude');
if (state === 'include') el.classList.add('active');
else if (state === 'exclude') el.classList.add('exclude');
}
toggleFilterPanel() {
if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden');
@@ -540,6 +595,13 @@ export class FilterManager {
this.updateLicenseSelections();
}
this.updateModelTypeSelections();
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
autoTagEls.forEach(el => {
const tag = el.dataset.autoTag;
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
});
}
updateModelTypeSelections() {
@@ -556,11 +618,12 @@ export class FilterManager {
updateActiveFiltersCount() {
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
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 + licenseFilterCount + modelTypeFilterCount;
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
@@ -652,6 +715,7 @@ export class FilterManager {
...this.filters,
baseModel: [],
tags: {},
autoTags: {},
license: {},
modelTypes: [],
tagLogic: 'any'
@@ -721,6 +785,7 @@ export class FilterManager {
hasActiveFilters() {
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count
@@ -728,6 +793,7 @@ export class FilterManager {
return (
baseModelCount > 0 ||
tagCount > 0 ||
autoTagCount > 0 ||
licenseCount > 0 ||
modelTypeCount > 0
);
@@ -739,6 +805,7 @@ export class FilterManager {
...source,
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags),
autoTags: this.normalizeTagFilters(source.autoTags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any'
@@ -822,6 +889,7 @@ export class FilterManager {
...this.filters,
baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) },
autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any'