mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 14:42:11 -03:00
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
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
:exclude-tags="state.excludeTags.value"
|
||||
:include-folders="state.includeFolders.value"
|
||||
:exclude-folders="state.excludeFolders.value"
|
||||
:include-patterns="state.includePatterns.value"
|
||||
:exclude-patterns="state.excludePatterns.value"
|
||||
:use-regex="state.useRegex.value"
|
||||
:no-credit-required="state.noCreditRequired.value"
|
||||
:allow-selling="state.allowSelling.value"
|
||||
:preview-items="state.previewItems.value"
|
||||
@@ -16,6 +19,9 @@
|
||||
@open-modal="openModal"
|
||||
@update:include-folders="state.includeFolders.value = $event"
|
||||
@update:exclude-folders="state.excludeFolders.value = $event"
|
||||
@update:include-patterns="state.includePatterns.value = $event"
|
||||
@update:exclude-patterns="state.excludePatterns.value = $event"
|
||||
@update:use-regex="state.useRegex.value = $event"
|
||||
@update:no-credit-required="state.noCreditRequired.value = $event"
|
||||
@update:allow-selling="state.allowSelling.value = $event"
|
||||
@refresh="state.refreshPreview"
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
@edit-exclude="$emit('open-modal', 'excludeFolders')"
|
||||
/>
|
||||
|
||||
<NamePatternsSection
|
||||
:include-patterns="includePatterns"
|
||||
:exclude-patterns="excludePatterns"
|
||||
:use-regex="useRegex"
|
||||
@update:include-patterns="$emit('update:includePatterns', $event)"
|
||||
@update:exclude-patterns="$emit('update:excludePatterns', $event)"
|
||||
@update:use-regex="$emit('update:useRegex', $event)"
|
||||
/>
|
||||
|
||||
<LicenseSection
|
||||
:no-credit-required="noCreditRequired"
|
||||
:allow-selling="allowSelling"
|
||||
@@ -46,6 +55,7 @@
|
||||
import BaseModelSection from './sections/BaseModelSection.vue'
|
||||
import TagsSection from './sections/TagsSection.vue'
|
||||
import FoldersSection from './sections/FoldersSection.vue'
|
||||
import NamePatternsSection from './sections/NamePatternsSection.vue'
|
||||
import LicenseSection from './sections/LicenseSection.vue'
|
||||
import LoraPoolPreview from './LoraPoolPreview.vue'
|
||||
import type { BaseModelOption, LoraItem } from '../../composables/types'
|
||||
@@ -61,6 +71,10 @@ defineProps<{
|
||||
// Folders
|
||||
includeFolders: string[]
|
||||
excludeFolders: string[]
|
||||
// Name patterns
|
||||
includePatterns: string[]
|
||||
excludePatterns: string[]
|
||||
useRegex: boolean
|
||||
// License
|
||||
noCreditRequired: boolean
|
||||
allowSelling: boolean
|
||||
@@ -74,6 +88,9 @@ defineEmits<{
|
||||
'open-modal': [modal: ModalType]
|
||||
'update:includeFolders': [value: string[]]
|
||||
'update:excludeFolders': [value: string[]]
|
||||
'update:includePatterns': [value: string[]]
|
||||
'update:excludePatterns': [value: string[]]
|
||||
'update:useRegex': [value: boolean]
|
||||
'update:noCreditRequired': [value: boolean]
|
||||
'update:allowSelling': [value: boolean]
|
||||
refresh: []
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<span class="section__title">NAME PATTERNS</span>
|
||||
<label class="section__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="useRegex"
|
||||
@change="$emit('update:useRegex', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="section__toggle-label">Use Regex</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section__columns">
|
||||
<!-- Include column -->
|
||||
<div class="section__column">
|
||||
<div class="section__column-header">
|
||||
<span class="section__column-title section__column-title--include">INCLUDE</span>
|
||||
</div>
|
||||
<div class="section__input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
v-model="includeInput"
|
||||
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
|
||||
class="section__input"
|
||||
@keydown.enter="addInclude"
|
||||
/>
|
||||
<button type="button" class="section__add-btn" @click="addInclude">+</button>
|
||||
</div>
|
||||
<div class="section__patterns">
|
||||
<FilterChip
|
||||
v-for="pattern in includePatterns"
|
||||
:key="pattern"
|
||||
:label="pattern"
|
||||
variant="include"
|
||||
removable
|
||||
@remove="removeInclude(pattern)"
|
||||
/>
|
||||
<div v-if="includePatterns.length === 0" class="section__empty">
|
||||
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exclude column -->
|
||||
<div class="section__column">
|
||||
<div class="section__column-header">
|
||||
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
|
||||
</div>
|
||||
<div class="section__input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
v-model="excludeInput"
|
||||
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
|
||||
class="section__input"
|
||||
@keydown.enter="addExclude"
|
||||
/>
|
||||
<button type="button" class="section__add-btn" @click="addExclude">+</button>
|
||||
</div>
|
||||
<div class="section__patterns">
|
||||
<FilterChip
|
||||
v-for="pattern in excludePatterns"
|
||||
:key="pattern"
|
||||
:label="pattern"
|
||||
variant="exclude"
|
||||
removable
|
||||
@remove="removeExclude(pattern)"
|
||||
/>
|
||||
<div v-if="excludePatterns.length === 0" class="section__empty">
|
||||
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FilterChip from '../shared/FilterChip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
includePatterns: string[]
|
||||
excludePatterns: string[]
|
||||
useRegex: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:includePatterns': [value: string[]]
|
||||
'update:excludePatterns': [value: string[]]
|
||||
'update:useRegex': [value: boolean]
|
||||
}>()
|
||||
|
||||
const includeInput = ref('')
|
||||
const excludeInput = ref('')
|
||||
|
||||
const addInclude = () => {
|
||||
const pattern = includeInput.value.trim()
|
||||
if (pattern && !props.includePatterns.includes(pattern)) {
|
||||
emit('update:includePatterns', [...props.includePatterns, pattern])
|
||||
includeInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const addExclude = () => {
|
||||
const pattern = excludeInput.value.trim()
|
||||
if (pattern && !props.excludePatterns.includes(pattern)) {
|
||||
emit('update:excludePatterns', [...props.excludePatterns, pattern])
|
||||
excludeInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeInclude = (pattern: string) => {
|
||||
emit('update:includePatterns', props.includePatterns.filter(p => p !== pattern))
|
||||
}
|
||||
|
||||
const removeExclude = (pattern: string) => {
|
||||
emit('update:excludePatterns', props.excludePatterns.filter(p => p !== pattern))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.section__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section__toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section__toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section__column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section__column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section__column-title {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.section__column-title--include {
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
.section__column-title--exclude {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.section__input-wrapper {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--comfy-input-border, #444);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section__input:focus {
|
||||
border-color: #4299e1;
|
||||
}
|
||||
|
||||
.section__add-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--comfy-input-border, #444);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.section__add-btn:hover {
|
||||
background: var(--comfy-input-bg-hover, #444);
|
||||
border-color: #4299e1;
|
||||
}
|
||||
|
||||
.section__patterns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.section__empty {
|
||||
font-size: 10px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.3;
|
||||
font-style: italic;
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,11 @@ export interface LoraPoolConfig {
|
||||
noCreditRequired: boolean
|
||||
allowSelling: boolean
|
||||
}
|
||||
namePatterns: {
|
||||
include: string[]
|
||||
exclude: string[]
|
||||
useRegex: boolean
|
||||
}
|
||||
includeEmptyLora?: boolean // Optional, deprecated (moved to Cycler)
|
||||
}
|
||||
preview: { matchCount: number; lastUpdated: number }
|
||||
|
||||
@@ -62,6 +62,9 @@ export function useLoraPoolApi() {
|
||||
foldersExclude?: string[]
|
||||
noCreditRequired?: boolean
|
||||
allowSelling?: boolean
|
||||
namePatternsInclude?: string[]
|
||||
namePatternsExclude?: string[]
|
||||
namePatternsUseRegex?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
@@ -92,6 +95,13 @@ export function useLoraPoolApi() {
|
||||
urlParams.set('allow_selling_generated_content', String(params.allowSelling))
|
||||
}
|
||||
|
||||
// Name pattern filters
|
||||
params.namePatternsInclude?.forEach(pattern => urlParams.append('name_pattern_include', pattern))
|
||||
params.namePatternsExclude?.forEach(pattern => urlParams.append('name_pattern_exclude', pattern))
|
||||
if (params.namePatternsUseRegex !== undefined) {
|
||||
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
|
||||
const data = await response.json()
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
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[]>([])
|
||||
@@ -52,6 +55,11 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
license: {
|
||||
noCreditRequired: noCreditRequired.value,
|
||||
allowSelling: allowSelling.value
|
||||
},
|
||||
namePatterns: {
|
||||
include: includePatterns.value,
|
||||
exclude: excludePatterns.value,
|
||||
useRegex: useRegex.value
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
@@ -94,6 +102,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
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
|
||||
@@ -125,6 +136,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
foldersExclude: excludeFolders.value,
|
||||
noCreditRequired: noCreditRequired.value || undefined,
|
||||
allowSelling: allowSelling.value || undefined,
|
||||
namePatternsInclude: includePatterns.value,
|
||||
namePatternsExclude: excludePatterns.value,
|
||||
namePatternsUseRegex: useRegex.value,
|
||||
pageSize: 6
|
||||
})
|
||||
|
||||
@@ -150,7 +164,10 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
includeFolders,
|
||||
excludeFolders,
|
||||
noCreditRequired,
|
||||
allowSelling
|
||||
allowSelling,
|
||||
includePatterns,
|
||||
excludePatterns,
|
||||
useRegex
|
||||
], onFilterChange, { deep: true })
|
||||
|
||||
return {
|
||||
@@ -162,6 +179,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
excludeFolders,
|
||||
noCreditRequired,
|
||||
allowSelling,
|
||||
includePatterns,
|
||||
excludePatterns,
|
||||
useRegex,
|
||||
|
||||
// Available options
|
||||
availableBaseModels,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './mode-change-handler'
|
||||
|
||||
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
||||
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
|
||||
const LORA_POOL_WIDGET_MIN_HEIGHT = 520
|
||||
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
|
||||
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
|
||||
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
||||
|
||||
Reference in New Issue
Block a user