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

@@ -507,21 +507,96 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
}
/* Version row — flex container for badges + version names */
.version-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
margin-top: 2px;
}
/* Badge + version-name binding: they wrap as a single unit */
.badge-version-unit {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
flex-shrink: 0;
}
/* Medium density adjustments for version name */
.medium-density .version-name {
font-size: 0.8em;
}
.medium-density .badge-version-unit .version-name {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
/* Hide civitai version name when setting is disabled */
body.hide-card-version .civitai-version {
.compact-density .badge-version-unit .version-name {
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.medium-density .version-row {
gap: 2px;
}
/* HIGH / LOW badges — shown inline before version name in card footer */
.hl-badge {
display: inline-block;
font-size: 0.7em;
font-weight: 600;
line-height: 1.1;
padding: 1px 5px;
border-radius: var(--border-radius-xs);
border: 1px solid rgba(255, 255, 255, 0.2);
white-space: nowrap;
}
.hl-badge--high {
color: oklch(75% 0.12 230);
background: oklch(55% 0.15 240 / 0.25);
border-color: oklch(60% 0.18 250 / 0.3);
}
.hl-badge--low {
color: oklch(78% 0.10 185);
background: oklch(50% 0.10 190 / 0.25);
border-color: oklch(55% 0.12 195 / 0.3);
}
.medium-density .hl-badge {
font-size: 0.65em;
}
.compact-density .hl-badge {
font-size: 0.62em;
padding: 0px 4px;
}
/* Hide version-related elements when setting is disabled */
body.hide-card-version .civitai-version,
body.hide-card-version .hl-badge {
display: none;
}
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
/* Prevent text selection on cards and interactive elements */
.model-card,
.model-card *,

View File

@@ -978,6 +978,16 @@ export class BaseModelApiClient {
});
}
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';

View File

@@ -644,8 +644,23 @@ export function createModelCard(model, modelType) {
<div class="card-footer">
<div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div>
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
<div class="version-row">
${(() => {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
const badges = hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join('');
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
</div>
</div>

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'

View File

@@ -500,6 +500,18 @@ export function clearDynamicBaseModels() {
dynamicBaseModelsTimestamp = null;
}
export const AUTO_TAG_GROUPS = {
mode: new Set(['HIGH', 'LOW']),
video: new Set(['I2V', 'T2V', 'TI2V']),
speed: new Set(['Lightning', 'Turbo']),
};
export const AUTO_TAG_GROUP_LABELS = {
mode: 'High / Low',
video: 'I2V / T2V / TI2V',
speed: 'Lightning / Turbo',
};
/**
* Check if dynamic base models cache is valid
* @returns {boolean}