Files
ComfyUI-Lora-Manager/vue-widgets/src/composables/useLoraPoolState.ts
Will Miao 76c15105e6 feat(lora-pool): add regex include/exclude name pattern filtering (#839)
Add name pattern filtering to LoRA Pool node allowing users to filter
LoRAs by filename or model name using either plain text or regex patterns.

Features:
- Include patterns: only show LoRAs matching at least one pattern
- Exclude patterns: exclude LoRAs matching any pattern
- Regex toggle: switch between substring and regex matching
- Case-insensitive matching for both modes
- Invalid regex automatically falls back to substring matching
- Filters apply to both file_name and model_name fields

Backend:
- Update LoraPoolLM._default_config() with namePatterns structure
- Add name pattern filtering to _apply_pool_filters() and _apply_specific_filters()
- Add API parameter parsing for name_pattern_include/exclude/use_regex
- Update LoraPoolConfig type with namePatterns field

Frontend:
- Add NamePatternsSection.vue component with pattern input UI
- Update useLoraPoolState to manage pattern state and API integration
- Update LoraPoolSummaryView to display NamePatternsSection
- Increase LORA_POOL_WIDGET_MIN_HEIGHT to accommodate new UI

Tests:
- Add 7 test cases covering text/regex include, exclude, combined
  filtering, model name fallback, and invalid regex handling

Closes #839
2026-03-19 17:15:05 +08:00

205 lines
6.0 KiB
TypeScript

import { ref, computed, watch } from 'vue'
import type {
LoraPoolConfig,
BaseModelOption,
TagOption,
FolderTreeNode,
LoraItem,
ComponentWidget
} from './types'
import { useLoraPoolApi } from './useLoraPoolApi'
export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
const api = useLoraPoolApi()
// Flag to prevent infinite loops during config restoration
// callback → restoreFromConfig → watch → refreshPreview → buildConfig → widget.value = config → callback → ...
let isRestoring = false
// Filter state
const selectedBaseModels = ref<string[]>([])
const includeTags = ref<string[]>([])
const excludeTags = ref<string[]>([])
const includeFolders = ref<string[]>([])
const excludeFolders = ref<string[]>([])
const noCreditRequired = ref(false)
const allowSelling = ref(false)
const includePatterns = ref<string[]>([])
const excludePatterns = ref<string[]>([])
const useRegex = ref(false)
// Available options from API
const availableBaseModels = ref<BaseModelOption[]>([])
const availableTags = ref<TagOption[]>([])
const folderTree = ref<FolderTreeNode[]>([])
// Preview state
const previewItems = ref<LoraItem[]>([])
const matchCount = ref(0)
const isLoading = computed(() => api.isLoading.value)
// Build config from current state
const buildConfig = (): LoraPoolConfig => {
const config: LoraPoolConfig = {
version: 2,
filters: {
baseModels: selectedBaseModels.value,
tags: {
include: includeTags.value,
exclude: excludeTags.value
},
folders: {
include: includeFolders.value,
exclude: excludeFolders.value
},
license: {
noCreditRequired: noCreditRequired.value,
allowSelling: allowSelling.value
},
namePatterns: {
include: includePatterns.value,
exclude: excludePatterns.value,
useRegex: useRegex.value
}
},
preview: {
matchCount: matchCount.value,
lastUpdated: Date.now()
}
}
// Update widget value (this triggers callback for UI sync)
// Skip during restoration to prevent infinite loops:
// callback → restoreFromConfig → watch → refreshPreview → buildConfig → widget.value = config → callback → ...
if (!isRestoring) {
widget.value = config
}
return config
}
// Restore state from config
const restoreFromConfig = (config: LoraPoolConfig) => {
// Set flag to prevent buildConfig from triggering widget.value updates during restoration
// This breaks the infinite loop: callback → restoreFromConfig → watch → refreshPreview → buildConfig → widget.value = config → callback
isRestoring = true
try {
if (!config?.filters) return
const { filters, preview } = config
// Helper to update ref only if value changed
const updateIfChanged = <T>(refValue: { value: T }, newValue: T) => {
if (JSON.stringify(refValue.value) !== JSON.stringify(newValue)) {
refValue.value = newValue
}
}
updateIfChanged(selectedBaseModels, filters.baseModels || [])
updateIfChanged(includeTags, filters.tags?.include || [])
updateIfChanged(excludeTags, filters.tags?.exclude || [])
updateIfChanged(includeFolders, filters.folders?.include || [])
updateIfChanged(excludeFolders, filters.folders?.exclude || [])
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
updateIfChanged(includePatterns, filters.namePatterns?.include || [])
updateIfChanged(excludePatterns, filters.namePatterns?.exclude || [])
updateIfChanged(useRegex, filters.namePatterns?.useRegex ?? false)
// matchCount doesn't trigger watchers, so direct assignment is fine
matchCount.value = preview?.matchCount || 0
} finally {
isRestoring = false
}
}
// Fetch filter options from API
const fetchFilterOptions = async () => {
const [baseModels, tags, folders] = await Promise.all([
api.fetchBaseModels(),
api.fetchTags(),
api.fetchFolderTree()
])
availableBaseModels.value = baseModels
availableTags.value = tags
folderTree.value = folders
}
// Refresh preview with current filters
const refreshPreview = async () => {
const result = await api.fetchLoras({
baseModels: selectedBaseModels.value,
tagsInclude: includeTags.value,
tagsExclude: excludeTags.value,
foldersInclude: includeFolders.value,
foldersExclude: excludeFolders.value,
noCreditRequired: noCreditRequired.value || undefined,
allowSelling: allowSelling.value || undefined,
namePatternsInclude: includePatterns.value,
namePatternsExclude: excludePatterns.value,
namePatternsUseRegex: useRegex.value,
pageSize: 6
})
previewItems.value = result.items
matchCount.value = result.total
buildConfig()
}
// Debounced filter change handler
let filterTimeout: ReturnType<typeof setTimeout> | null = null
const onFilterChange = () => {
if (filterTimeout) clearTimeout(filterTimeout)
filterTimeout = setTimeout(() => {
refreshPreview()
}, 300)
}
// Watch all filter changes
watch([
selectedBaseModels,
includeTags,
excludeTags,
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling,
includePatterns,
excludePatterns,
useRegex
], onFilterChange, { deep: true })
return {
// Filter state
selectedBaseModels,
includeTags,
excludeTags,
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling,
includePatterns,
excludePatterns,
useRegex,
// Available options
availableBaseModels,
availableTags,
folderTree,
// Preview state
previewItems,
matchCount,
isLoading,
// Actions
buildConfig,
restoreFromConfig,
fetchFilterOptions,
refreshPreview
}
}
export type LoraPoolStateReturn = ReturnType<typeof useLoraPoolState>