From 2783782669d2cb1748e85bd3c1441e37c0de465d Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 10 Mar 2025 19:33:45 +0800 Subject: [PATCH] Add search options panel and functionality for filename, model name, and tags --- py/routes/api_routes.py | 12 +- py/services/lora_scanner.py | 76 +++++++--- static/css/components/search-filter.css | 193 ++++++++++++++++++++++++ static/js/state/index.js | 8 +- static/js/utils/search.js | 149 ++++++++++++++---- templates/components/controls.html | 31 +++- 6 files changed, 421 insertions(+), 48 deletions(-) diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index ddfdf8b2..6db816ab 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -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 diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index 6777b577..487afe48 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -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 diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 5b0c440e..f18abfeb 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -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%; } \ No newline at end of file diff --git a/static/js/state/index.js b/static/js/state/index.js index f35d7ff3..a0d68a44 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -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(), diff --git a/static/js/utils/search.js b/static/js/utils/search.js index 682f0577..f956acbf 100644 --- a/static/js/utils/search.js +++ b/static/js/utils/search.js @@ -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); diff --git a/templates/components/controls.html b/templates/components/controls.html index 7147ee54..8624811b 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -37,8 +37,8 @@ - - + + @@ -48,6 +48,33 @@ + + + + Search Options + + + + + + Search In: + + Filename + Tags + Model Name + + + + + Include Subfolders + + + + + + + +