mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Add search options panel and functionality for filename, model name, and tags
This commit is contained in:
@@ -132,6 +132,11 @@ class ApiRoutes:
|
||||
base_models = request.query.get('base_models', '').split(',')
|
||||
base_models = [model.strip() for model in base_models if model.strip()]
|
||||
|
||||
# Parse search options
|
||||
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
||||
search_modelname = request.query.get('search_modelname', 'true').lower() == 'true'
|
||||
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
||||
|
||||
# Validate parameters
|
||||
if page < 1 or page_size < 1 or page_size > 100:
|
||||
return web.json_response({
|
||||
@@ -157,7 +162,12 @@ class ApiRoutes:
|
||||
fuzzy=fuzzy,
|
||||
recursive=recursive,
|
||||
base_models=base_models, # Pass base models filter
|
||||
tags=tags # Add tags parameter
|
||||
tags=tags, # Add tags parameter
|
||||
search_options={
|
||||
'filename': search_filename,
|
||||
'modelname': search_modelname,
|
||||
'tags': search_tags
|
||||
}
|
||||
)
|
||||
|
||||
# Format the response data
|
||||
|
||||
@@ -167,7 +167,8 @@ class LoraScanner:
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
||||
folder: str = None, search: str = None, fuzzy: bool = False,
|
||||
recursive: bool = False, base_models: list = None, tags: list = None) -> Dict:
|
||||
recursive: bool = False, base_models: list = None, tags: list = None,
|
||||
search_options: dict = None) -> Dict:
|
||||
"""Get paginated and filtered lora data
|
||||
|
||||
Args:
|
||||
@@ -180,22 +181,31 @@ class LoraScanner:
|
||||
recursive: Include subfolders when folder filter is applied
|
||||
base_models: List of base models to filter by
|
||||
tags: List of tags to filter by
|
||||
search_options: Dictionary with search options (filename, modelname, tags)
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# 先获取基础数据集
|
||||
# Get default search options if not provided
|
||||
if search_options is None:
|
||||
search_options = {
|
||||
'filename': True,
|
||||
'modelname': True,
|
||||
'tags': False
|
||||
}
|
||||
|
||||
# Get the base data set
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# 应用文件夹过滤
|
||||
# Apply folder filtering
|
||||
if folder is not None:
|
||||
if recursive:
|
||||
# 递归模式:匹配所有以该文件夹开头的路径
|
||||
# Recursive mode: match all paths starting with this folder
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'].startswith(folder + '/') or item['folder'] == folder
|
||||
]
|
||||
else:
|
||||
# 非递归模式:只匹配确切的文件夹
|
||||
# Non-recursive mode: match exact folder
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'] == folder
|
||||
@@ -215,28 +225,20 @@ class LoraScanner:
|
||||
if any(tag in item.get('tags', []) for tag in tags)
|
||||
]
|
||||
|
||||
# 应用搜索过滤
|
||||
# Apply search filtering
|
||||
if search:
|
||||
if fuzzy:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(
|
||||
self.fuzzy_match(str(value), search)
|
||||
for value in [
|
||||
item.get('model_name', ''),
|
||||
item.get('base_model', '')
|
||||
]
|
||||
if value
|
||||
)
|
||||
if self._fuzzy_search_match(item, search, search_options)
|
||||
]
|
||||
else:
|
||||
# Original exact search logic
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if search in str(item.get('model_name', '')).lower()
|
||||
if self._exact_search_match(item, search, search_options)
|
||||
]
|
||||
|
||||
# 计算分页
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
@@ -251,6 +253,44 @@ class LoraScanner:
|
||||
|
||||
return result
|
||||
|
||||
def _fuzzy_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
|
||||
"""Check if an item matches the search term using fuzzy matching with search options"""
|
||||
# Check filename if enabled
|
||||
if search_options.get('filename', True) and self.fuzzy_match(item.get('file_name', ''), search):
|
||||
return True
|
||||
|
||||
# Check model name if enabled
|
||||
if search_options.get('modelname', True) and self.fuzzy_match(item.get('model_name', ''), search):
|
||||
return True
|
||||
|
||||
# Check tags if enabled
|
||||
if search_options.get('tags', False) and item.get('tags'):
|
||||
for tag in item['tags']:
|
||||
if self.fuzzy_match(tag, search):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _exact_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
|
||||
"""Check if an item matches the search term using exact matching with search options"""
|
||||
search = search.lower()
|
||||
|
||||
# Check filename if enabled
|
||||
if search_options.get('filename', True) and search in item.get('file_name', '').lower():
|
||||
return True
|
||||
|
||||
# Check model name if enabled
|
||||
if search_options.get('modelname', True) and search in item.get('model_name', '').lower():
|
||||
return True
|
||||
|
||||
# Check tags if enabled
|
||||
if search_options.get('tags', False) and item.get('tags'):
|
||||
for tag in item['tags']:
|
||||
if search in tag.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def invalidate_cache(self):
|
||||
"""Invalidate the current cache"""
|
||||
self._cache = None
|
||||
@@ -371,7 +411,7 @@ class LoraScanner:
|
||||
model_metadata = await client.get_model_metadata(model_id)
|
||||
await client.close()
|
||||
|
||||
if model_metadata:
|
||||
if (model_metadata):
|
||||
logger.info(f"Updating metadata for {file_path} with model ID {model_id}")
|
||||
|
||||
# Update tags if they were missing
|
||||
|
||||
@@ -314,4 +314,197 @@
|
||||
right: 20px;
|
||||
top: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Search Options Toggle */
|
||||
.search-options-toggle {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid oklch(65% 0.02 256);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-options-toggle:hover {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-options-toggle.active {
|
||||
background-color: oklch(95% 0.05 256);
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-options-toggle i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Search Options Panel */
|
||||
.search-options-panel {
|
||||
position: absolute;
|
||||
top: 140px;
|
||||
right: 65px; /* Position it closer to the search options button */
|
||||
width: 280px; /* Slightly wider to accommodate tags better */
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 16px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.search-options-panel.hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.options-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.options-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.close-options-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-options-btn:hover {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.options-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.options-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-option-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px; /* Increased gap for better spacing */
|
||||
}
|
||||
|
||||
.search-option-tag {
|
||||
padding: 6px 8px; /* Adjusted padding for better text display */
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-size: 13px; /* Slightly smaller font size */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap; /* Prevent text wrapping */
|
||||
min-width: 80px; /* Ensure minimum width for each tag */
|
||||
display: inline-flex; /* Better control over layout */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-option-tag:hover {
|
||||
background-color: var(--lora-surface-hover);
|
||||
}
|
||||
|
||||
.search-option-tag.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Switch styles */
|
||||
.search-option-switch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px var(--lora-accent);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -8,9 +8,15 @@ export const state = {
|
||||
observer: null,
|
||||
previewVersions: new Map(),
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
recursive: false
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: [] // Make sure tags are included in state
|
||||
tags: []
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedLoras: new Set(),
|
||||
|
||||
@@ -7,11 +7,12 @@ export class SearchManager {
|
||||
constructor() {
|
||||
// Initialize search manager
|
||||
this.searchInput = document.getElementById('searchInput');
|
||||
this.searchModeToggle = document.getElementById('searchModeToggle');
|
||||
this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
|
||||
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
|
||||
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
|
||||
this.searchDebounceTimeout = null;
|
||||
this.currentSearchTerm = '';
|
||||
this.isSearching = false;
|
||||
this.isRecursiveSearch = false;
|
||||
|
||||
// Add clear button
|
||||
this.createClearButton();
|
||||
@@ -27,15 +28,19 @@ export class SearchManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.searchModeToggle) {
|
||||
// Initialize toggle state from localStorage or default to false
|
||||
this.isRecursiveSearch = localStorage.getItem('recursiveSearch') === 'true';
|
||||
this.updateToggleUI();
|
||||
// Initialize search options
|
||||
this.initSearchOptions();
|
||||
}
|
||||
|
||||
this.searchModeToggle.addEventListener('click', () => {
|
||||
this.isRecursiveSearch = !this.isRecursiveSearch;
|
||||
localStorage.setItem('recursiveSearch', this.isRecursiveSearch);
|
||||
this.updateToggleUI();
|
||||
initSearchOptions() {
|
||||
// Load recursive search state from localStorage
|
||||
state.searchOptions.recursive = localStorage.getItem('recursiveSearch') === 'true';
|
||||
|
||||
if (this.recursiveSearchToggle) {
|
||||
this.recursiveSearchToggle.checked = state.searchOptions.recursive;
|
||||
this.recursiveSearchToggle.addEventListener('change', (e) => {
|
||||
state.searchOptions.recursive = e.target.checked;
|
||||
localStorage.setItem('recursiveSearch', state.searchOptions.recursive);
|
||||
|
||||
// Rerun search if there's an active search term
|
||||
if (this.currentSearchTerm) {
|
||||
@@ -43,6 +48,108 @@ export class SearchManager {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup search options toggle
|
||||
if (this.searchOptionsToggle) {
|
||||
this.searchOptionsToggle.addEventListener('click', () => {
|
||||
this.toggleSearchOptionsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Close button for search options panel
|
||||
const closeButton = document.getElementById('closeSearchOptions');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => {
|
||||
this.closeSearchOptionsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup search option tags
|
||||
const optionTags = document.querySelectorAll('.search-option-tag');
|
||||
optionTags.forEach(tag => {
|
||||
const option = tag.dataset.option;
|
||||
|
||||
// Initialize tag state from state
|
||||
tag.classList.toggle('active', state.searchOptions[option]);
|
||||
|
||||
tag.addEventListener('click', () => {
|
||||
// Check if clicking would deselect the last active option
|
||||
const activeOptions = document.querySelectorAll('.search-option-tag.active');
|
||||
if (activeOptions.length === 1 && activeOptions[0] === tag) {
|
||||
// Don't allow deselecting the last option and show toast
|
||||
showToast('At least one search option must be selected', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
tag.classList.toggle('active');
|
||||
state.searchOptions[option] = tag.classList.contains('active');
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(`searchOption_${option}`, state.searchOptions[option]);
|
||||
|
||||
// Rerun search if there's an active search term
|
||||
if (this.currentSearchTerm) {
|
||||
this.performSearch(this.currentSearchTerm);
|
||||
}
|
||||
});
|
||||
|
||||
// Load option state from localStorage or use default
|
||||
const savedState = localStorage.getItem(`searchOption_${option}`);
|
||||
if (savedState !== null) {
|
||||
state.searchOptions[option] = savedState === 'true';
|
||||
tag.classList.toggle('active', state.searchOptions[option]);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure at least one search option is selected
|
||||
this.validateSearchOptions();
|
||||
|
||||
// Close panel when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.searchOptionsPanel &&
|
||||
!this.searchOptionsPanel.contains(e.target) &&
|
||||
e.target !== this.searchOptionsToggle &&
|
||||
!this.searchOptionsToggle.contains(e.target)) {
|
||||
this.closeSearchOptionsPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add method to validate search options
|
||||
validateSearchOptions() {
|
||||
const hasActiveOption = Object.values(state.searchOptions)
|
||||
.some(value => value === true && value !== state.searchOptions.recursive);
|
||||
|
||||
// If no search options are active, activate at least one default option
|
||||
if (!hasActiveOption) {
|
||||
state.searchOptions.filename = true;
|
||||
localStorage.setItem('searchOption_filename', 'true');
|
||||
|
||||
// Update UI to match
|
||||
const fileNameTag = document.querySelector('.search-option-tag[data-option="filename"]');
|
||||
if (fileNameTag) {
|
||||
fileNameTag.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleSearchOptionsPanel() {
|
||||
if (this.searchOptionsPanel) {
|
||||
const isHidden = this.searchOptionsPanel.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
this.searchOptionsPanel.classList.remove('hidden');
|
||||
this.searchOptionsToggle.classList.add('active');
|
||||
} else {
|
||||
this.closeSearchOptionsPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeSearchOptionsPanel() {
|
||||
if (this.searchOptionsPanel) {
|
||||
this.searchOptionsPanel.classList.add('hidden');
|
||||
this.searchOptionsToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
createClearButton() {
|
||||
@@ -74,21 +181,6 @@ export class SearchManager {
|
||||
}
|
||||
}
|
||||
|
||||
updateToggleUI() {
|
||||
if (this.searchModeToggle) {
|
||||
this.searchModeToggle.classList.toggle('active', this.isRecursiveSearch);
|
||||
this.searchModeToggle.title = this.isRecursiveSearch
|
||||
? 'Recursive folder search (including subfolders)'
|
||||
: 'Current folder search only';
|
||||
|
||||
// Update the icon to indicate the mode
|
||||
const icon = this.searchModeToggle.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = this.isRecursiveSearch ? 'fas fa-folder-tree' : 'fas fa-folder';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(event) {
|
||||
if (this.searchDebounceTimeout) {
|
||||
clearTimeout(this.searchDebounceTimeout);
|
||||
@@ -126,12 +218,17 @@ export class SearchManager {
|
||||
url.searchParams.set('sort_by', state.sortBy);
|
||||
url.searchParams.set('search', searchTerm);
|
||||
url.searchParams.set('fuzzy', 'true');
|
||||
|
||||
// Add search options
|
||||
url.searchParams.set('search_filename', state.searchOptions.filename.toString());
|
||||
url.searchParams.set('search_modelname', state.searchOptions.modelname.toString());
|
||||
url.searchParams.set('search_tags', state.searchOptions.tags.toString());
|
||||
|
||||
// Always send folder parameter if there is an active folder
|
||||
if (state.activeFolder) {
|
||||
url.searchParams.set('folder', state.activeFolder);
|
||||
// Add recursive parameter when recursive search is enabled
|
||||
url.searchParams.set('recursive', this.isRecursiveSearch.toString());
|
||||
url.searchParams.set('recursive', state.searchOptions.recursive.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
<input type="text" id="searchInput" placeholder="Search models..." />
|
||||
<!-- 清空按钮将由JavaScript动态添加到这里 -->
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<button class="search-mode-toggle" id="searchModeToggle" title="Toggle recursive search in folders">
|
||||
<i class="fas fa-folder"></i>
|
||||
<button class="search-options-toggle" id="searchOptionsToggle" title="Search Options">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
<button class="search-filter-toggle" id="filterButton" onclick="filterManager.toggleFilterPanel()" title="Filter models">
|
||||
<i class="fas fa-filter"></i>
|
||||
@@ -48,6 +48,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add search options panel -->
|
||||
<div id="searchOptionsPanel" class="search-options-panel hidden">
|
||||
<div class="options-header">
|
||||
<h3>Search Options</h3>
|
||||
<button class="close-options-btn" id="closeSearchOptions">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="options-section">
|
||||
<h4>Search In:</h4>
|
||||
<div class="search-option-tags">
|
||||
<div class="search-option-tag active" data-option="filename">Filename</div>
|
||||
<div class="search-option-tag active" data-option="tags">Tags</div>
|
||||
<div class="search-option-tag active" data-option="modelname">Model Name</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-section">
|
||||
<div class="search-option-switch">
|
||||
<span>Include Subfolders</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="recursiveSearchToggle">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add filter panel -->
|
||||
<div id="filterPanel" class="filter-panel hidden">
|
||||
<div class="filter-header">
|
||||
|
||||
Reference in New Issue
Block a user