From 0069f8463011414ee8a9fc778bce37525f5c32c9 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 10 Mar 2025 00:20:31 +0800 Subject: [PATCH 01/29] Add model description in lora details --- py/routes/api_routes.py | 59 +++++++ py/services/civitai_client.py | 35 +++++ static/css/components/lora-modal.css | 220 ++++++++++++++++++++++++++ static/js/api/loraApi.js | 15 ++ static/js/components/LoraModal.js | 224 ++++++++++++++++++++------- 5 files changed, 495 insertions(+), 58 deletions(-) diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 01666146..49d760dd 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -41,6 +41,7 @@ class ApiRoutes: app.router.add_post('/api/download-lora', routes.download_lora) app.router.add_post('/api/settings', routes.update_settings) app.router.add_post('/api/move_model', routes.move_model) + app.router.add_get('/api/lora-model-description', routes.get_lora_model_description) # Add new route app.router.add_post('/loras/api/save-metadata', routes.save_metadata) app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route app.router.add_post('/api/move_models_bulk', routes.move_models_bulk) @@ -691,3 +692,61 @@ class ApiRoutes: except Exception as e: logger.error(f"Error moving models in bulk: {e}", exc_info=True) return web.Response(text=str(e), status=500) + + async def get_lora_model_description(self, request: web.Request) -> web.Response: + """Get model description for a Lora model""" + try: + # Get parameters + model_id = request.query.get('model_id') + file_path = request.query.get('file_path') + + if not model_id: + return web.json_response({ + 'success': False, + 'error': 'Model ID is required' + }, status=400) + + # Check if we already have the description stored in metadata + description = None + if file_path: + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + if os.path.exists(metadata_path): + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + description = metadata.get('modelDescription') + except Exception as e: + logger.error(f"Error loading metadata from {metadata_path}: {e}") + + # If description is not in metadata, fetch from CivitAI + if not description: + logger.info(f"Fetching model description for model ID: {model_id}") + description = await self.civitai_client.get_model_description(model_id) + + # Save the description to metadata if we have a file path and got a description + if file_path and description: + try: + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + if os.path.exists(metadata_path): + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + + metadata['modelDescription'] = description + + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + logger.info(f"Saved model description to metadata for {file_path}") + except Exception as e: + logger.error(f"Error saving model description to metadata: {e}") + + return web.json_response({ + 'success': True, + 'description': description or "

No model description available.

" + }) + + except Exception as e: + logger.error(f"Error getting model description: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index ae08e56c..a264d45b 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -163,6 +163,41 @@ class CivitaiClient: logger.error(f"Error fetching model version info: {e}") return None + async def get_model_description(self, model_id: str) -> Optional[str]: + """Fetch the model description from Civitai API + + Args: + model_id: The Civitai model ID + + Returns: + Optional[str]: The model description HTML or None if not found + """ + try: + session = await self.session + headers = self._get_request_headers() + url = f"{self.base_url}/models/{model_id}" + + logger.info(f"Fetching model description from {url}") + + async with session.get(url, headers=headers) as response: + if response.status != 200: + logger.warning(f"Failed to fetch model description: Status {response.status}") + return None + + data = await response.json() + description = data.get('description') + + if description: + logger.info(f"Successfully retrieved description for model {model_id}") + return description + else: + logger.warning(f"No description found for model {model_id}") + return None + + except Exception as e: + logger.error(f"Error fetching model description: {e}", exc_info=True) + return None + async def close(self): """Close the session if it exists""" if self._session is not None: diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 0e2f16a9..8cb990fb 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -479,4 +479,224 @@ /* Ensure close button is accessible */ .modal-content .close { z-index: 10; /* Ensure close button is above other elements */ +} + +/* Tab System Styling */ +.showcase-tabs { + display: flex; + border-bottom: 1px solid var(--lora-border); + margin-bottom: var(--space-2); + position: relative; + z-index: 2; +} + +.tab-btn { + padding: var(--space-1) var(--space-2); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-color); + cursor: pointer; + font-size: 0.95em; + transition: all 0.2s; + opacity: 0.7; + position: relative; +} + +.tab-btn:hover { + opacity: 1; + background: oklch(var(--lora-accent) / 0.05); +} + +.tab-btn.active { + border-bottom: 2px solid var(--lora-accent); + opacity: 1; + font-weight: 600; +} + +.tab-content { + position: relative; + min-height: 100px; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +/* Model Description Styling */ +.model-description-container { + background: var(--lora-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + min-height: 200px; + position: relative; +} + +.model-description-loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + color: var(--text-color); + opacity: 0.7; + font-size: 0.9em; +} + +.model-description-loading .fa-spinner { + margin-right: var(--space-1); +} + +.model-description-content { + padding: var(--space-2); + line-height: 1.5; + overflow-wrap: break-word; +} + +.model-description-content img { + max-width: 100%; + height: auto; +} + +.model-description-content pre { + background: rgba(0, 0, 0, 0.05); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + white-space: pre-wrap; +} + +.model-description-content code { + font-family: monospace; +} + +.model-description-content hr { + border: 0; + border-top: 1px solid var(--lora-border); + margin: var(--space-2) 0; +} + +.model-description-content a { + color: var(--lora-accent); + text-decoration: none; +} + +.model-description-content a:hover { + text-decoration: underline; +} + +/* Adjust dark mode for model description */ +[data-theme="dark"] .model-description-content pre { + background: rgba(255, 255, 255, 0.05); +} + +.hidden { + display: none !important; +} + +.error-message { + color: var(--lora-error); + text-align: center; + padding: var(--space-2); +} + +.no-examples { + text-align: center; + padding: var(--space-3); + color: var(--text-color); + opacity: 0.7; +} + +/* Adjust the media wrapper for tab system */ +#showcase-tab .carousel-container { + margin-top: var(--space-2); +} + +/* Enhanced Model Description Styling */ +.model-description-container { + background: var(--lora-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + min-height: 200px; + position: relative; + /* Remove the max-height and overflow-y to allow content to expand naturally */ +} + +.model-description-content { + padding: var(--space-2); + line-height: 1.5; + overflow-wrap: break-word; + font-size: 0.95em; +} + +.model-description-content h1, +.model-description-content h2, +.model-description-content h3, +.model-description-content h4, +.model-description-content h5, +.model-description-content h6 { + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.model-description-content p { + margin-bottom: 1em; +} + +.model-description-content img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius-xs); + display: block; + margin: 1em 0; +} + +.model-description-content pre { + background: rgba(0, 0, 0, 0.05); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + white-space: pre-wrap; + margin: 1em 0; + overflow-x: auto; +} + +.model-description-content code { + font-family: monospace; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.05); + padding: 0.1em 0.3em; + border-radius: 3px; +} + +.model-description-content pre code { + background: transparent; + padding: 0; +} + +.model-description-content ul, +.model-description-content ol { + margin-left: 1.5em; + margin-bottom: 1em; +} + +.model-description-content li { + margin-bottom: 0.5em; +} + +.model-description-content blockquote { + border-left: 3px solid var(--lora-accent); + padding-left: 1em; + margin-left: 0; + margin-right: 0; + font-style: italic; + opacity: 0.8; +} + +/* Adjust dark mode for model description */ +[data-theme="dark"] .model-description-content pre, +[data-theme="dark"] .model-description-content code { + background: rgba(255, 255, 255, 0.05); } \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 1ac6f012..132923f1 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -325,4 +325,19 @@ export async function refreshSingleLoraMetadata(filePath) { state.loadingManager.hide(); state.loadingManager.restoreProgressBar(); } +} + +export async function fetchModelDescription(modelId, filePath) { + try { + const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + + if (!response.ok) { + throw new Error(`Failed to fetch model description: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching model description:', error); + throw error; + } } \ No newline at end of file diff --git a/static/js/components/LoraModal.js b/static/js/components/LoraModal.js index 6e9653d1..ac55487d 100644 --- a/static/js/components/LoraModal.js +++ b/static/js/components/LoraModal.js @@ -81,10 +81,35 @@ export function showLoraModal(lora) {
${lora.description || 'N/A'}
- - ${renderShowcaseImages(lora.civitai.images)} +
+
+ + +
+ +
+
+ ${renderShowcaseContent(lora.civitai?.images)} +
+ +
+
+
+ Loading model description... +
+
+ ${lora.modelDescription || ''} +
+
+
+
+ + +
`; @@ -92,6 +117,143 @@ export function showLoraModal(lora) { modalManager.showModal('loraModal', content); setupEditableFields(); setupShowcaseScroll(); + setupTabSwitching(); + + // If we have a model ID but no description, fetch it + if (lora.civitai?.modelId && !lora.modelDescription) { + loadModelDescription(lora.civitai.modelId, lora.file_path); + } +} + +// Function to render showcase content +function renderShowcaseContent(images) { + if (!images?.length) return '
No example images available
'; + + return ` +
+ + Scroll or click to show ${images.length} examples +
+ + `; +} + +// New function to handle tab switching +function setupTabSwitching() { + const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + // Remove active class from all tabs + document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn => + btn.classList.remove('active') + ); + document.querySelectorAll('.tab-content .tab-pane').forEach(tab => + tab.classList.remove('active') + ); + + // Add active class to clicked tab + button.classList.add('active'); + const tabId = `${button.dataset.tab}-tab`; + document.getElementById(tabId).classList.add('active'); + + // If switching to description tab, make sure content is properly sized + if (button.dataset.tab === 'description') { + const descriptionContent = document.querySelector('.model-description-content'); + if (descriptionContent && descriptionContent.innerHTML.trim() !== '') { + document.querySelector('.model-description-loading')?.classList.add('hidden'); + } + } + }); + }); +} + +// New function to load model description +async function loadModelDescription(modelId, filePath) { + try { + const descriptionContainer = document.querySelector('.model-description-content'); + const loadingElement = document.querySelector('.model-description-loading'); + + if (!descriptionContainer || !loadingElement) return; + + // Show loading indicator + loadingElement.classList.remove('hidden'); + descriptionContainer.classList.add('hidden'); + + // Try to get model description from API + const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + + if (!response.ok) { + throw new Error(`Failed to fetch model description: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success && data.description) { + // Update the description content + descriptionContainer.innerHTML = data.description; + + // Process any links in the description to open in new tab + const links = descriptionContainer.querySelectorAll('a'); + links.forEach(link => { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + }); + + // Show the description and hide loading indicator + descriptionContainer.classList.remove('hidden'); + loadingElement.classList.add('hidden'); + } else { + throw new Error(data.error || 'No description available'); + } + } catch (error) { + console.error('Error loading model description:', error); + const loadingElement = document.querySelector('.model-description-loading'); + if (loadingElement) { + loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; + } + } } // 添加复制文件名的函数 @@ -350,61 +512,7 @@ function renderTriggerWords(words) { } function renderShowcaseImages(images) { - if (!images?.length) return ''; - - return ` -
-
- - Scroll or click to show ${images.length} examples -
- - -
- `; + return renderShowcaseContent(images); } export function toggleShowcase(element) { @@ -558,4 +666,4 @@ function formatFileSize(bytes) { } return `${size.toFixed(1)} ${units[unitIndex]}`; -} \ No newline at end of file +} \ No newline at end of file From c3fe58f055e441d8385ddd2bb74acd20d8261a99 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Mon, 10 Mar 2025 00:50:51 +0800 Subject: [PATCH 02/29] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..c2f81e86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +### **LoRA Manager Version** +- Version: `vX.X.X` + +### **Environment Information** +- **Operating System**: (e.g., Windows 11, macOS Ventura, Ubuntu 22.04) +- **Browser & Version**: (e.g., Chrome 120.0.0, Edge 115.0.0) + +### **Issue Description** +- Describe the issue in detail. + +### **Steps to Reproduce** +1. Open LoRA Manager in [your browser]. +2. Perform [specific action]. +3. Observe the issue. + +### **Expected Behavior** +- What did you expect to happen? + +### **Screenshots** *(If applicable)* +- Upload screenshots or screen recordings. + +### **Logs** +- Provide the **ComfyUI startup log** and any relevant error messages. +- Check the browser developer console (F12 → Console tab) and attach any errors. + +### **Additional Context** *(Optional)* +- Any other relevant details. From 1803a9085d35ac75235d3fd855c4b84f6dafa5f5 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Mon, 10 Mar 2025 00:53:31 +0800 Subject: [PATCH 03/29] Update issue templates --- .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 721bef3ff8efcfe9ffa873ea93ff5d2f5c85507e Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 10 Mar 2025 13:18:56 +0800 Subject: [PATCH 04/29] Add tag filtering checkpoint --- py/routes/api_routes.py | 89 +++++++++++++---- py/routes/lora_routes.py | 5 + py/services/civitai_client.py | 33 ++++--- py/services/file_monitor.py | 14 +++ py/services/lora_scanner.py | 113 +++++++++++++++++++++- py/utils/file_utils.py | 2 + py/utils/models.py | 11 ++- static/css/components/lora-modal.css | 39 ++++++++ static/css/components/search-filter.css | 40 +++++++- static/js/api/loraApi.js | 12 ++- static/js/components/LoraCard.js | 12 ++- static/js/components/LoraModal.js | 30 +++++- static/js/managers/FilterManager.js | 122 ++++++++++++++++++++++-- static/js/state/index.js | 3 +- templates/components/controls.html | 7 ++ 15 files changed, 482 insertions(+), 50 deletions(-) diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 49d760dd..cde1284a 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -45,6 +45,7 @@ class ApiRoutes: app.router.add_post('/loras/api/save-metadata', routes.save_metadata) app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route app.router.add_post('/api/move_models_bulk', routes.move_models_bulk) + app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags # Add update check routes UpdateRoutes.setup_routes(app) @@ -142,6 +143,10 @@ class ApiRoutes: 'error': 'Invalid sort parameter' }, status=400) + # Parse tags filter parameter + tags = request.query.get('tags', '').split(',') + tags = [tag.strip() for tag in tags if tag.strip()] + # Get paginated data with search and filters result = await self.scanner.get_paginated_data( page=page, @@ -151,7 +156,8 @@ class ApiRoutes: search=search, fuzzy=fuzzy, recursive=recursive, - base_models=base_models # Pass base models filter + base_models=base_models, # Pass base models filter + tags=tags # Add tags parameter ) # Format the response data @@ -190,6 +196,8 @@ class ApiRoutes: "file_path": lora["file_path"].replace(os.sep, "/"), "file_size": lora["size"], "modified": lora["modified"], + "tags": lora["tags"], + "modelDescription": lora["modelDescription"], "from_civitai": lora.get("from_civitai", True), "usage_tips": lora.get("usage_tips", ""), "notes": lora.get("notes", ""), @@ -335,6 +343,14 @@ class ApiRoutes: local_metadata['model_name'] = civitai_metadata['model'].get('name', local_metadata.get('model_name')) + # Fetch additional model metadata (description and tags) if we have model ID + model_id = civitai_metadata['modelId'] + if model_id: + model_metadata = await client.get_model_metadata(str(model_id)) + if model_metadata: + local_metadata['modelDescription'] = model_metadata.get('description', '') + local_metadata['tags'] = model_metadata.get('tags', []) + # Update base model local_metadata['base_model'] = civitai_metadata.get('baseModel') @@ -708,6 +724,7 @@ class ApiRoutes: # Check if we already have the description stored in metadata description = None + tags = [] if file_path: metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' if os.path.exists(metadata_path): @@ -715,38 +732,70 @@ class ApiRoutes: with open(metadata_path, 'r', encoding='utf-8') as f: metadata = json.load(f) description = metadata.get('modelDescription') + tags = metadata.get('tags', []) except Exception as e: logger.error(f"Error loading metadata from {metadata_path}: {e}") # If description is not in metadata, fetch from CivitAI if not description: - logger.info(f"Fetching model description for model ID: {model_id}") - description = await self.civitai_client.get_model_description(model_id) + logger.info(f"Fetching model metadata for model ID: {model_id}") + model_metadata = await self.civitai_client.get_model_metadata(model_id) - # Save the description to metadata if we have a file path and got a description - if file_path and description: - try: - metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' - if os.path.exists(metadata_path): - with open(metadata_path, 'r', encoding='utf-8') as f: - metadata = json.load(f) - - metadata['modelDescription'] = description - - with open(metadata_path, 'w', encoding='utf-8') as f: - json.dump(metadata, f, indent=2, ensure_ascii=False) - logger.info(f"Saved model description to metadata for {file_path}") - except Exception as e: - logger.error(f"Error saving model description to metadata: {e}") + if model_metadata: + description = model_metadata.get('description') + tags = model_metadata.get('tags', []) + + # Save the metadata to file if we have a file path and got metadata + if file_path: + try: + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + if os.path.exists(metadata_path): + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + + metadata['modelDescription'] = description + metadata['tags'] = tags + + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + logger.info(f"Saved model metadata to file for {file_path}") + except Exception as e: + logger.error(f"Error saving model metadata: {e}") return web.json_response({ 'success': True, - 'description': description or "

No model description available.

" + 'description': description or "

No model description available.

", + 'tags': tags }) except Exception as e: - logger.error(f"Error getting model description: {e}", exc_info=True) + logger.error(f"Error getting model metadata: {e}", exc_info=True) return web.json_response({ 'success': False, 'error': str(e) }, status=500) + + async def get_top_tags(self, request: web.Request) -> web.Response: + """Handle request for top tags sorted by frequency""" + try: + # Parse query parameters + limit = int(request.query.get('limit', '20')) + + # Validate limit + if limit < 1 or limit > 100: + limit = 20 # Default to a reasonable limit + + # Get top tags + top_tags = await self.scanner.get_top_tags(limit) + + return web.json_response({ + 'success': True, + 'tags': top_tags + }) + + except Exception as e: + logger.error(f"Error getting top tags: {str(e)}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': 'Internal server error' + }, status=500) diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index 64c71385..5359aacc 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -30,6 +30,11 @@ class LoraRoutes: "folder": lora["folder"], "sha256": lora["sha256"], "file_path": lora["file_path"].replace(os.sep, "/"), + "size": lora["size"], + "tags": lora["tags"], + "modelDescription": lora["modelDescription"], + "usage_tips": lora["usage_tips"], + "notes": lora["notes"], "modified": lora["modified"], "from_civitai": lora.get("from_civitai", True), "civitai": self._filter_civitai_data(lora.get("civitai", {})) diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index a264d45b..389fc491 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -163,41 +163,52 @@ class CivitaiClient: logger.error(f"Error fetching model version info: {e}") return None - async def get_model_description(self, model_id: str) -> Optional[str]: - """Fetch the model description from Civitai API + async def get_model_metadata(self, model_id: str) -> Optional[Dict]: + """Fetch model metadata (description and tags) from Civitai API Args: model_id: The Civitai model ID Returns: - Optional[str]: The model description HTML or None if not found + Optional[Dict]: A dictionary containing model metadata or None if not found """ try: session = await self.session headers = self._get_request_headers() url = f"{self.base_url}/models/{model_id}" - logger.info(f"Fetching model description from {url}") + logger.info(f"Fetching model metadata from {url}") async with session.get(url, headers=headers) as response: if response.status != 200: - logger.warning(f"Failed to fetch model description: Status {response.status}") + logger.warning(f"Failed to fetch model metadata: Status {response.status}") return None data = await response.json() - description = data.get('description') - if description: - logger.info(f"Successfully retrieved description for model {model_id}") - return description + # Extract relevant metadata + metadata = { + "description": data.get("description", ""), + "tags": data.get("tags", []) + } + + if metadata["description"] or metadata["tags"]: + logger.info(f"Successfully retrieved metadata for model {model_id}") + return metadata else: - logger.warning(f"No description found for model {model_id}") + logger.warning(f"No metadata found for model {model_id}") return None except Exception as e: - logger.error(f"Error fetching model description: {e}", exc_info=True) + logger.error(f"Error fetching model metadata: {e}", exc_info=True) return None + # Keep old method for backward compatibility, delegating to the new one + async def get_model_description(self, model_id: str) -> Optional[str]: + """Fetch the model description from Civitai API (Legacy method)""" + metadata = await self.get_model_metadata(model_id) + return metadata.get("description") if metadata else None + async def close(self): """Close the session if it exists""" if self._session is not None: diff --git a/py/services/file_monitor.py b/py/services/file_monitor.py index 3bef2dd5..33b53448 100644 --- a/py/services/file_monitor.py +++ b/py/services/file_monitor.py @@ -98,6 +98,10 @@ class LoraFileHandler(FileSystemEventHandler): # Scan new file lora_data = await self.scanner.scan_single_lora(file_path) if lora_data: + # Update tags count + for tag in lora_data.get('tags', []): + self.scanner._tags_count[tag] = self.scanner._tags_count.get(tag, 0) + 1 + cache.raw_data.append(lora_data) new_folders.add(lora_data['folder']) # Update hash index @@ -109,6 +113,16 @@ class LoraFileHandler(FileSystemEventHandler): needs_resort = True elif action == 'remove': + # Find the lora to remove so we can update tags count + lora_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None) + if lora_to_remove: + # Update tags count by reducing counts + for tag in lora_to_remove.get('tags', []): + if tag in self.scanner._tags_count: + self.scanner._tags_count[tag] = max(0, self.scanner._tags_count[tag] - 1) + if self.scanner._tags_count[tag] == 0: + del self.scanner._tags_count[tag] + # Remove from cache and hash index logger.info(f"Removing {file_path} from cache") self.scanner._hash_index.remove_by_path(file_path) diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index 857a4369..6777b577 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -34,6 +34,7 @@ class LoraScanner: self._initialization_task: Optional[asyncio.Task] = None self._initialized = True self.file_monitor = None # Add this line + self._tags_count = {} # Add a dictionary to store tag counts def set_file_monitor(self, monitor): """Set file monitor instance""" @@ -90,13 +91,21 @@ class LoraScanner: # Clear existing hash index self._hash_index.clear() + # Clear existing tags count + self._tags_count = {} + # Scan for new data raw_data = await self.scan_all_loras() - # Build hash index + # Build hash index and tags count for lora_data in raw_data: if 'sha256' in lora_data and 'file_path' in lora_data: self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path']) + + # Count tags + if 'tags' in lora_data and lora_data['tags']: + for tag in lora_data['tags']: + self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 # Update cache self._cache = LoraCache( @@ -158,7 +167,7 @@ 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): + recursive: bool = False, base_models: list = None, tags: list = None) -> Dict: """Get paginated and filtered lora data Args: @@ -170,6 +179,7 @@ class LoraScanner: fuzzy: Use fuzzy matching for search recursive: Include subfolders when folder filter is applied base_models: List of base models to filter by + tags: List of tags to filter by """ cache = await self.get_cached_data() @@ -198,6 +208,13 @@ class LoraScanner: if item.get('base_model') in base_models ] + # Apply tag filtering + if tags and len(tags) > 0: + filtered_data = [ + item for item in filtered_data + if any(tag in item.get('tags', []) for tag in tags) + ] + # 应用搜索过滤 if search: if fuzzy: @@ -311,12 +328,67 @@ class LoraScanner: # Convert to dict and add folder info lora_data = metadata.to_dict() + # Try to fetch missing metadata from Civitai if needed + await self._fetch_missing_metadata(file_path, lora_data) rel_path = os.path.relpath(file_path, root_path) folder = os.path.dirname(rel_path) lora_data['folder'] = folder.replace(os.path.sep, '/') return lora_data + async def _fetch_missing_metadata(self, file_path: str, lora_data: Dict) -> None: + """Fetch missing description and tags from Civitai if needed + + Args: + file_path: Path to the lora file + lora_data: Lora metadata dictionary to update + """ + try: + # Check if we need to fetch additional metadata from Civitai + needs_metadata_update = False + model_id = None + + # Check if we have Civitai model ID but missing metadata + if lora_data.get('civitai'): + # Try to get model ID directly from the correct location + model_id = lora_data['civitai'].get('modelId') + + if model_id: + model_id = str(model_id) + # Check if tags are missing or empty + tags_missing = not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0 + + # Check if description is missing or empty + desc_missing = not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "") + + needs_metadata_update = tags_missing or desc_missing + + # Fetch missing metadata if needed + if needs_metadata_update and model_id: + logger.info(f"Fetching missing metadata for {file_path} with model ID {model_id}") + from ..services.civitai_client import CivitaiClient + client = CivitaiClient() + model_metadata = await client.get_model_metadata(model_id) + await client.close() + + if model_metadata: + logger.info(f"Updating metadata for {file_path} with model ID {model_id}") + + # Update tags if they were missing + if model_metadata.get('tags') and (not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0): + lora_data['tags'] = model_metadata['tags'] + + # Update description if it was missing + if model_metadata.get('description') and (not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")): + lora_data['modelDescription'] = model_metadata['description'] + + # Save the updated metadata back to file + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(lora_data, f, indent=2, ensure_ascii=False) + except Exception as e: + logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}") + async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool: """Update preview URL in cache for a specific lora @@ -427,6 +499,15 @@ class LoraScanner: async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool: cache = await self.get_cached_data() + # Find the existing item to remove its tags from count + existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None) + if existing_item and 'tags' in existing_item: + for tag in existing_item.get('tags', []): + if tag in self._tags_count: + self._tags_count[tag] = max(0, self._tags_count[tag] - 1) + if self._tags_count[tag] == 0: + del self._tags_count[tag] + # Remove old path from hash index if exists self._hash_index.remove_by_path(original_path) @@ -460,6 +541,11 @@ class LoraScanner: # Update folders list all_folders = set(item['folder'] for item in cache.raw_data) cache.folders = sorted(list(all_folders), key=lambda x: x.lower()) + + # Update tags count with the new/updated tags + if 'tags' in metadata: + for tag in metadata.get('tags', []): + self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 # Resort cache await cache.resort() @@ -505,3 +591,26 @@ class LoraScanner: """Get hash for a LoRA by its file path""" return self._hash_index.get_hash(file_path) + # Add new method to get top tags + async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]: + """Get top tags sorted by count + + Args: + limit: Maximum number of tags to return + + Returns: + List of dictionaries with tag name and count, sorted by count + """ + # Make sure cache is initialized + await self.get_cached_data() + + # Sort tags by count in descending order + sorted_tags = sorted( + [{"tag": tag, "count": count} for tag, count in self._tags_count.items()], + key=lambda x: x['count'], + reverse=True + ) + + # Return limited number + return sorted_tags[:limit] + diff --git a/py/utils/file_utils.py b/py/utils/file_utils.py index 2aaa1ed2..f9758e12 100644 --- a/py/utils/file_utils.py +++ b/py/utils/file_utils.py @@ -69,6 +69,8 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]: notes="", from_civitai=True, preview_url=normalize_path(preview_url), + tags=[], + modelDescription="" ) # create metadata file diff --git a/py/utils/models.py b/py/utils/models.py index 0a39e065..49568fee 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, asdict -from typing import Dict, Optional +from typing import Dict, Optional, List from datetime import datetime import os from .model_utils import determine_base_model @@ -17,8 +17,15 @@ class LoraMetadata: preview_url: str # Preview image URL usage_tips: str = "{}" # Usage tips for the model, json string notes: str = "" # Additional notes - from_civitai: bool = True # Whether the lora is from Civitai + from_civitai: bool = True # Whether the lora is from Civitai civitai: Optional[Dict] = None # Civitai API data if available + tags: List[str] = None # Model tags + modelDescription: str = "" # Full model description + + def __post_init__(self): + # Initialize empty lists to avoid mutable default parameter issue + if self.tags is None: + self.tags = [] @classmethod def from_dict(cls, data: Dict) -> 'LoraMetadata': diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 8cb990fb..3e7bebc6 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -699,4 +699,43 @@ [data-theme="dark"] .model-description-content pre, [data-theme="dark"] .model-description-content code { background: rgba(255, 255, 255, 0.05); +} + +/* Model Tags styles */ +.model-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + margin-bottom: 4px; +} + +.model-tag { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-xs); + padding: 3px 8px; + font-size: 0.8em; + color: var(--lora-accent); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + transition: all 0.2s; +} + +.model-tag i { + font-size: 0.85em; + opacity: 0.6; + color: var(--text-color); +} + +.model-tag:hover { + background: oklch(var(--lora-accent) / 0.1); + border-color: var(--lora-accent); +} + +.model-tag:hover i { + opacity: 1; + color: var(--lora-accent); } \ No newline at end of file diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 50f7fc40..5b0c440e 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -237,6 +237,44 @@ border-color: var(--lora-accent); } +/* Tag filter styles */ +.tag-filter { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 60px; +} + +.tag-count { + background: rgba(0, 0, 0, 0.1); + padding: 1px 6px; + border-radius: 10px; + font-size: 0.8em; + margin-left: 4px; +} + +[data-theme="dark"] .tag-count { + background: rgba(255, 255, 255, 0.1); +} + +.tag-filter.active .tag-count { + background: rgba(255, 255, 255, 0.3); + color: white; +} + +.tags-loading, .tags-error, .no-tags { + width: 100%; + padding: 8px; + text-align: center; + font-size: 0.9em; + color: var(--text-color); + opacity: 0.7; +} + +.tags-error { + color: var(--lora-error); +} + /* Filter actions */ .filter-actions { display: flex; @@ -276,4 +314,4 @@ right: 20px; top: 140px; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 132923f1..fe961412 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -32,9 +32,15 @@ export async function loadMoreLoras(boolUpdateFolders = false) { } // Add filter parameters if active - if (state.filters && state.filters.baseModel && state.filters.baseModel.length > 0) { - // Convert the array of base models to a comma-separated string - params.append('base_models', state.filters.baseModel.join(',')); + if (state.filters) { + if (state.filters.tags && state.filters.tags.length > 0) { + // Convert the array of tags to a comma-separated string + params.append('tags', state.filters.tags.join(',')); + } + if (state.filters.baseModel && state.filters.baseModel.length > 0) { + // Convert the array of base models to a comma-separated string + params.append('base_models', state.filters.baseModel.join(',')); + } } console.log('Loading loras with params:', params.toString()); diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index 99985f4d..35de22ea 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -18,6 +18,14 @@ export function createLoraCard(lora) { card.dataset.usage_tips = lora.usage_tips; card.dataset.notes = lora.notes; card.dataset.meta = JSON.stringify(lora.civitai || {}); + + // Store tags and model description + if (lora.tags && Array.isArray(lora.tags)) { + card.dataset.tags = JSON.stringify(lora.tags); + } + if (lora.modelDescription) { + card.dataset.modelDescription = lora.modelDescription; + } // Apply selection state if in bulk mode and this card is in the selected set if (state.bulkMode && state.selectedLoras.has(lora.file_path)) { @@ -86,7 +94,9 @@ export function createLoraCard(lora) { base_model: card.dataset.base_model, usage_tips: card.dataset.usage_tips, notes: card.dataset.notes, - civitai: JSON.parse(card.dataset.meta || '{}') + civitai: JSON.parse(card.dataset.meta || '{}'), + tags: JSON.parse(card.dataset.tags || '[]'), + modelDescription: card.dataset.modelDescription || '' }; showLoraModal(loraMeta); } diff --git a/static/js/components/LoraModal.js b/static/js/components/LoraModal.js index ac55487d..4f4b3c3d 100644 --- a/static/js/components/LoraModal.js +++ b/static/js/components/LoraModal.js @@ -15,6 +15,7 @@ export function showLoraModal(lora) { + ${renderTags(lora.tags || [])} +
+

Tags

+
+ +
Loading tags...
+
+
- ${renderTags(lora.tags || [])} + ${renderCompactTags(lora.tags || [])}
-

Tags

+

Tags (Top 20)

Loading tags...
From 2bdecf571b468af2ba691a4f8a79e6503a56dced Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 10 Mar 2025 17:10:58 +0800 Subject: [PATCH 06/29] Add trigger words editting --- py/routes/api_routes.py | 13 +- py/utils/file_utils.py | 9 + static/css/components/lora-modal.css | 167 +++++++++++++- static/js/components/LoraModal.js | 318 +++++++++++++++++++++++++-- 4 files changed, 483 insertions(+), 24 deletions(-) diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index cde1284a..ddfdf8b2 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -384,7 +384,7 @@ class ApiRoutes: # 准备要处理的 loras to_process = [ lora for lora in cache.raw_data - if lora.get('sha256') and not lora.get('civitai') and lora.get('from_civitai') + if lora.get('sha256') and (not lora.get('civitai') or 'id' not in lora.get('civitai')) and lora.get('from_civitai') # TODO: for lora not from CivitAI but added traineWords ] total_to_process = len(to_process) @@ -617,8 +617,15 @@ class ApiRoutes: else: metadata = {} - # Update metadata with new values - metadata.update(metadata_updates) + # Handle nested updates (for civitai.trainedWords) + for key, value in metadata_updates.items(): + if isinstance(value, dict) and key in metadata and isinstance(metadata[key], dict): + # Deep update for nested dictionaries + for nested_key, nested_value in value.items(): + metadata[key][nested_key] = nested_value + else: + # Regular update for top-level keys + metadata[key] = value # Save updated metadata with open(metadata_path, 'w', encoding='utf-8') as f: diff --git a/py/utils/file_utils.py b/py/utils/file_utils.py index f9758e12..2ae05a60 100644 --- a/py/utils/file_utils.py +++ b/py/utils/file_utils.py @@ -121,6 +121,15 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]: elif preview_url != normalize_path(preview_url): data['preview_url'] = normalize_path(preview_url) needs_update = True + + # Ensure all fields are present, due to updates adding new fields + if 'tags' not in data: + data['tags'] = [] + needs_update = True + + if 'modelDescription' not in data: + data['modelDescription'] = "" + needs_update = True if needs_update: with open(metadata_path, 'w', encoding='utf-8') as f: diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 7ecd93ad..df2bd828 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -163,12 +163,57 @@ border: 1px solid var(--lora-border); } +/* New header style for trigger words */ +.trigger-words-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.edit-trigger-words-btn { + background: transparent; + border: none; + color: var(--text-color); + opacity: 0.5; + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + transition: all 0.2s ease; +} + +.edit-trigger-words-btn:hover { + opacity: 0.8; + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .edit-trigger-words-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Edit mode active state */ +.trigger-words.edit-mode .edit-trigger-words-btn { + opacity: 0.8; + color: var(--lora-accent); +} + +.trigger-words-content { + margin-bottom: var(--space-1); +} + .trigger-words-tags { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; - margin-top: var(--space-1); +} + +/* No trigger words message */ +.no-trigger-words { + color: var(--text-color); + opacity: 0.7; + font-style: italic; + font-size: 0.9em; } /* Update Trigger Words styles */ @@ -182,6 +227,7 @@ cursor: pointer; transition: all 0.2s ease; gap: 6px; + position: relative; } /* Update trigger word content color to use theme accent */ @@ -207,6 +253,123 @@ transition: opacity 0.2s; } +/* Delete button for trigger word */ +.delete-trigger-word-btn { + position: absolute; + top: -5px; + right: -5px; + width: 16px; + height: 16px; + background: var(--lora-error); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + transition: transform 0.2s ease; +} + +.delete-trigger-word-btn:hover { + transform: scale(1.1); +} + +/* Edit controls */ +.trigger-words-edit-controls { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.trigger-words-edit-controls button { + padding: 3px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.85em; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; +} + +.trigger-words-edit-controls button:hover { + background: oklch(var(--lora-accent) / 0.1); + border-color: var(--lora-accent); +} + +.trigger-words-edit-controls button i { + font-size: 0.8em; +} + +.save-trigger-words-btn { + background: var(--lora-accent) !important; + color: white !important; + border-color: var(--lora-accent) !important; +} + +.save-trigger-words-btn:hover { + opacity: 0.9; +} + +/* Add trigger word form */ +.add-trigger-word-form { + margin-top: var(--space-2); + display: flex; + gap: var(--space-1); +} + +.new-trigger-word-input { + flex: 1; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9em; +} + +.new-trigger-word-input:focus { + border-color: var(--lora-accent); + outline: none; +} + +.confirm-add-trigger-word-btn, +.cancel-add-trigger-word-btn { + padding: 4px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.85em; + cursor: pointer; + transition: all 0.2s ease; +} + +.confirm-add-trigger-word-btn { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +.confirm-add-trigger-word-btn:hover { + opacity: 0.9; +} + +.cancel-add-trigger-word-btn:hover { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .cancel-add-trigger-word-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + /* Editable Fields */ .editable-field { position: relative; @@ -724,7 +887,7 @@ } .model-description-content blockquote { - border-left: 3px solid var(--lora-accent); + border-left: 3px solid var (--lora-accent); padding-left: 1em; margin-left: 0; margin-right: 0; diff --git a/static/js/components/LoraModal.js b/static/js/components/LoraModal.js index 49eaf368..2567e752 100644 --- a/static/js/components/LoraModal.js +++ b/static/js/components/LoraModal.js @@ -67,7 +67,7 @@ export function showLoraModal(lora) {
- ${renderTriggerWords(escapedWords)} + ${renderTriggerWords(escapedWords, lora.file_path)}
@@ -120,6 +120,7 @@ export function showLoraModal(lora) { setupShowcaseScroll(); setupTabSwitching(); setupTagTooltip(); + setupTriggerWordsEditMode(); // If we have a model ID but no description, fetch it if (lora.civitai?.modelId && !lora.modelDescription) { @@ -488,35 +489,75 @@ async function saveModelMetadata(filePath, data) { } } -function renderTriggerWords(words) { +function renderTriggerWords(words, filePath) { if (!words.length) return `
- - No trigger word needed +
+ + +
+
+ No trigger word needed + +
+ +
`; return `
- -
- ${words.map(word => ` -
- ${word} - - - -
- `).join('')} +
+ + +
+
+
+ ${words.map(word => ` +
+ ${word} + + + + +
+ `).join('')} +
+
+ +
`; } -function renderShowcaseImages(images) { - return renderShowcaseContent(images); -} - export function toggleShowcase(element) { const carousel = element.nextElementSibling; const isCollapsed = carousel.classList.contains('collapsed'); @@ -738,4 +779,243 @@ function setupTagTooltip() { tooltip.classList.remove('visible'); }); } -} \ No newline at end of file +} + +// Set up trigger words edit mode +function setupTriggerWordsEditMode() { + const editBtn = document.querySelector('.edit-trigger-words-btn'); + if (!editBtn) return; + + editBtn.addEventListener('click', function() { + const triggerWordsSection = this.closest('.trigger-words'); + const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); + + // Toggle edit mode UI elements + const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag'); + const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls'); + const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words'); + const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags'); + + if (isEditMode) { + this.innerHTML = ''; // Change to cancel icon + this.title = "Cancel editing"; + editControls.style.display = 'flex'; + + // If we have no trigger words yet, hide the "No trigger word needed" text + // and show the empty tags container + if (noTriggerWords) { + noTriggerWords.style.display = 'none'; + if (tagsContainer) tagsContainer.style.display = 'flex'; + } + + // Disable click-to-copy and show delete buttons + triggerWordTags.forEach(tag => { + tag.onclick = null; + tag.querySelector('.trigger-word-copy').style.display = 'none'; + tag.querySelector('.delete-trigger-word-btn').style.display = 'block'; + }); + } else { + this.innerHTML = ''; // Change back to edit icon + this.title = "Edit trigger words"; + editControls.style.display = 'none'; + + // If we have no trigger words, show the "No trigger word needed" text + // and hide the empty tags container + const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag'); + if (noTriggerWords && currentTags.length === 0) { + noTriggerWords.style.display = ''; + if (tagsContainer) tagsContainer.style.display = 'none'; + } + + // Restore original state + triggerWordTags.forEach(tag => { + const word = tag.dataset.word; + tag.onclick = () => copyTriggerWord(word); + tag.querySelector('.trigger-word-copy').style.display = 'flex'; + tag.querySelector('.delete-trigger-word-btn').style.display = 'none'; + }); + + // Hide add form if open + triggerWordsSection.querySelector('.add-trigger-word-form').style.display = 'none'; + } + }); + + // Set up add trigger word button + const addBtn = document.querySelector('.add-trigger-word-btn'); + if (addBtn) { + addBtn.addEventListener('click', function() { + const triggerWordsSection = this.closest('.trigger-words'); + const addForm = triggerWordsSection.querySelector('.add-trigger-word-form'); + addForm.style.display = 'flex'; + addForm.querySelector('input').focus(); + }); + } + + // Set up confirm and cancel add buttons + const confirmAddBtn = document.querySelector('.confirm-add-trigger-word-btn'); + const cancelAddBtn = document.querySelector('.cancel-add-trigger-word-btn'); + const triggerWordInput = document.querySelector('.new-trigger-word-input'); + + if (confirmAddBtn && triggerWordInput) { + confirmAddBtn.addEventListener('click', function() { + addNewTriggerWord(triggerWordInput.value); + }); + + // Add keydown event to input + triggerWordInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addNewTriggerWord(this.value); + } + }); + } + + if (cancelAddBtn) { + cancelAddBtn.addEventListener('click', function() { + const addForm = this.closest('.add-trigger-word-form'); + addForm.style.display = 'none'; + addForm.querySelector('input').value = ''; + }); + } + + // Set up save button + const saveBtn = document.querySelector('.save-trigger-words-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', saveTriggerWords); + } + + // Set up delete buttons + document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + const tag = this.closest('.trigger-word-tag'); + tag.remove(); + }); + }); +} + +// Function to add a new trigger word +function addNewTriggerWord(word) { + word = word.trim(); + if (!word) return; + + const triggerWordsSection = document.querySelector('.trigger-words'); + let tagsContainer = document.querySelector('.trigger-words-tags'); + + // Ensure tags container exists and is visible + if (tagsContainer) { + tagsContainer.style.display = 'flex'; + } else { + // Create tags container if it doesn't exist + const contentDiv = triggerWordsSection.querySelector('.trigger-words-content'); + if (contentDiv) { + tagsContainer = document.createElement('div'); + tagsContainer.className = 'trigger-words-tags'; + contentDiv.appendChild(tagsContainer); + } + } + + if (!tagsContainer) return; + + // Hide "no trigger words" message if it exists + const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words'); + if (noTriggerWordsMsg) { + noTriggerWordsMsg.style.display = 'none'; + } + + // Validation: Check length + if (word.split(/\s+/).length > 30) { + showToast('Trigger word should not exceed 30 words', 'error'); + return; + } + + // Validation: Check total number + const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag'); + if (currentTags.length >= 10) { + showToast('Maximum 10 trigger words allowed', 'error'); + return; + } + + // Validation: Check for duplicates + const existingWords = Array.from(currentTags).map(tag => tag.dataset.word); + if (existingWords.includes(word)) { + showToast('This trigger word already exists', 'error'); + return; + } + + // Create new tag + const newTag = document.createElement('div'); + newTag.className = 'trigger-word-tag'; + newTag.dataset.word = word; + newTag.innerHTML = ` + ${word} + + + `; + + // Add event listener to delete button + const deleteBtn = newTag.querySelector('.delete-trigger-word-btn'); + deleteBtn.addEventListener('click', function() { + newTag.remove(); + }); + + tagsContainer.appendChild(newTag); + + // Clear and hide the input form + const triggerWordInput = document.querySelector('.new-trigger-word-input'); + triggerWordInput.value = ''; + document.querySelector('.add-trigger-word-form').style.display = 'none'; +} + +// Function to save updated trigger words +async function saveTriggerWords() { + const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath; + const triggerWordTags = document.querySelectorAll('.trigger-word-tag'); + const words = Array.from(triggerWordTags).map(tag => tag.dataset.word); + + try { + // Special format for updating nested civitai.trainedWords + await saveModelMetadata(filePath, { + civitai: { trainedWords: words } + }); + + // Update UI + const editBtn = document.querySelector('.edit-trigger-words-btn'); + editBtn.click(); // Exit edit mode + + // Update the LoRA card's dataset + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (loraCard && loraCard.dataset.civitai) { + const civitaiData = JSON.parse(loraCard.dataset.civitai); + civitaiData.trainedWords = words; + loraCard.dataset.civitai = JSON.stringify(civitaiData); + } + + // If we saved an empty array and there's a no-trigger-words element, show it + const noTriggerWords = document.querySelector('.no-trigger-words'); + const tagsContainer = document.querySelector('.trigger-words-tags'); + if (words.length === 0 && noTriggerWords) { + noTriggerWords.style.display = ''; + if (tagsContainer) tagsContainer.style.display = 'none'; + } + + showToast('Trigger words updated successfully', 'success'); + } catch (error) { + showToast('Failed to update trigger words', 'error'); + } +} + +// Add copy trigger word function +window.copyTriggerWord = async function(word) { + try { + await navigator.clipboard.writeText(word); + showToast('Trigger word copied', 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } +}; \ No newline at end of file From cbb76580e4ddd959e053dde7932f4ebf270571a8 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 10 Mar 2025 17:32:28 +0800 Subject: [PATCH 07/29] Enhance error handling for civitai metadata parsing and update logic; add empty state messages for model descriptions --- static/js/components/LoraCard.js | 11 +++++- static/js/components/LoraModal.js | 58 ++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index 35de22ea..09c0b96f 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -94,7 +94,16 @@ export function createLoraCard(lora) { base_model: card.dataset.base_model, usage_tips: card.dataset.usage_tips, notes: card.dataset.notes, - civitai: JSON.parse(card.dataset.meta || '{}'), + // Parse civitai metadata from the card's dataset + civitai: (() => { + try { + // Attempt to parse the JSON string + return JSON.parse(card.dataset.meta || '{}'); + } catch (e) { + console.error('Failed to parse civitai metadata:', e); + return {}; // Return empty object on error + } + })(), tags: JSON.parse(card.dataset.tags || '[]'), modelDescription: card.dataset.modelDescription || '' }; diff --git a/static/js/components/LoraModal.js b/static/js/components/LoraModal.js index 2567e752..26e953d4 100644 --- a/static/js/components/LoraModal.js +++ b/static/js/components/LoraModal.js @@ -204,8 +204,15 @@ function setupTabSwitching() { // If switching to description tab, make sure content is properly sized if (button.dataset.tab === 'description') { const descriptionContent = document.querySelector('.model-description-content'); - if (descriptionContent && descriptionContent.innerHTML.trim() !== '') { + if (descriptionContent) { + const hasContent = descriptionContent.innerHTML.trim() !== ''; document.querySelector('.model-description-loading')?.classList.add('hidden'); + + // If no content, show a message + if (!hasContent) { + descriptionContent.innerHTML = '
No model description available
'; + descriptionContent.classList.remove('hidden'); + } } } }); @@ -256,6 +263,13 @@ async function loadModelDescription(modelId, filePath) { if (loadingElement) { loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; } + + // Show empty state message in the description container + const descriptionContainer = document.querySelector('.model-description-content'); + if (descriptionContainer) { + descriptionContainer.innerHTML = '
No model description available
'; + descriptionContainer.classList.remove('hidden'); + } } } @@ -711,22 +725,6 @@ function formatFileSize(bytes) { return `${size.toFixed(1)} ${units[unitIndex]}`; } -// Function to render model tags -function renderTags(tags) { - if (!tags || tags.length === 0) return ''; - - return ` -
- ${tags.map(tag => ` - - ${tag} - - - `).join('')} -
- `; -} - // Add tag copy functionality window.copyTag = async function(tag) { try { @@ -989,10 +987,27 @@ async function saveTriggerWords() { // Update the LoRA card's dataset const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (loraCard && loraCard.dataset.civitai) { - const civitaiData = JSON.parse(loraCard.dataset.civitai); - civitaiData.trainedWords = words; - loraCard.dataset.civitai = JSON.stringify(civitaiData); + if (loraCard) { + try { + // Create a proper structure for civitai data + let civitaiData = {}; + + // Parse existing data if available + if (loraCard.dataset.meta) { + civitaiData = JSON.parse(loraCard.dataset.meta); + } + + // Update trainedWords property + civitaiData.trainedWords = words; + + // Update the meta dataset attribute with the full civitai data + loraCard.dataset.meta = JSON.stringify(civitaiData); + + // For debugging, log the updated data to verify it's correct + console.log("Updated civitai data:", civitaiData); + } catch (e) { + console.error('Error updating civitai data:', e); + } } // If we saved an empty array and there's a no-trigger-words element, show it @@ -1005,6 +1020,7 @@ async function saveTriggerWords() { showToast('Trigger words updated successfully', 'success'); } catch (error) { + console.error('Error saving trigger words:', error); showToast('Failed to update trigger words', 'error'); } } 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 08/29] 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 @@ -
+ + +