Compare commits

..

43 Commits

Author SHA1 Message Date
Will Miao
199e374318 feat: update release notes for v0.8.27 and bump version to 0.8.27 2025-08-14 07:32:09 +08:00
pixelpaws
8375c1413d Merge pull request #354 from Clusters/main
feat: Add qwen-image as a selectable base model
2025-08-14 07:14:27 +08:00
Andreas
9e268cf016 Merge branch 'willmiao:main' into main 2025-08-13 17:51:10 +02:00
Andreas
112b3abc26 feat: add qwen-image base model to ModelMetadata 2025-08-13 15:49:30 +00:00
Andreas
a8331a2357 feat: add qwen-image to base model constants.js 2025-08-13 15:48:10 +00:00
Will Miao
52e3ad08c1 feat: add placeholder for empty folder tree in download modal 2025-08-13 23:45:37 +08:00
Will Miao
8d01d04ef0 feat: add default path toggle and update download modal for improved path selection 2025-08-13 23:30:48 +08:00
Will Miao
a141384907 feat: update default path customization image for improved clarity 2025-08-13 20:15:11 +08:00
Will Miao
b8aa7184bd feat: update download path template handling for model types and migrate old settings 2025-08-13 19:23:37 +08:00
Will Miao
e4195f874d feat: implement download path templates configuration with support for multiple model types and custom templates 2025-08-13 17:42:40 +08:00
Will Miao
d04deff5ca feat: enhance download and move modals with improved folder path input, autocomplete, and folder tree integration 2025-08-13 14:41:21 +08:00
Will Miao
20ce0778a0 fix: correct default root key generation by using singular model type 2025-08-13 11:06:39 +08:00
Will Miao
5a0b3470f1 feat: enhance auto-organize functionality with empty directory cleanup and progress reporting 2025-08-13 10:36:31 +08:00
Will Miao
a920921570 feat: implement auto-organize models endpoint with batch processing and error handling 2025-08-12 22:39:40 +08:00
Will Miao
286f4ff384 feat: add folder tree and unified folder tree endpoints, enhance download modal with folder path input and tree navigation 2025-08-12 22:34:53 +08:00
Will Miao
71ddfafa98 refactor: move download modal styles to a dedicated file and update import path 2025-08-12 21:40:43 +08:00
Will Miao
b7e3e53697 feat: implement version mismatch handling and banner registration in UpdateService 2025-08-12 15:09:45 +08:00
Will Miao
16df548b77 fix: expand supported file extensions in CheckpointScanner initialization, fixes #353 2025-08-12 09:20:08 +08:00
Will Miao
425c33ae00 fix: update model identifier handling in RecipeModal and DownloadManager for consistency 2025-08-11 17:13:42 +08:00
Will Miao
c9289ed2dc fix: improve duplicate filename handling and logging in ModelScanner and ModelHashIndex 2025-08-11 17:13:21 +08:00
Will Miao
96517cbdef fix: update model_id and model_version_id handling across various services for improved flexibility 2025-08-11 15:31:49 +08:00
Will Miao
b03420faac fix: skip LoRAs without proper identification in Civitai metadata parser 2025-08-11 11:14:45 +08:00
Will Miao
65a1aa7ca2 fix: add missing embeddings folder paths in settings example 2025-08-11 07:05:58 +08:00
pixelpaws
3a92e8eaf9 Update README.md 2025-08-10 16:11:28 +08:00
Will Miao
a8dc50d64a fix: update portable package link to version 0.8.26 in README 2025-08-10 16:05:50 +08:00
Will Miao
3397cc7d8d fix: update screenshot image to reflect latest UI changes 2025-08-10 09:02:46 +08:00
Will Miao
c3e8131b24 feat: enhance download manager to track failed models and update progress reporting 2025-08-10 08:07:52 +08:00
Will Miao
f8ca8584ae feat: enhance URL safety in path mapping by encoding special characters 2025-08-09 16:25:55 +08:00
Will Miao
3050bbe260 fix: improve image handling logic to ensure input is always a list or array, see #346 2025-08-09 07:20:28 +08:00
Will Miao
e1dda2795a update README.md 2025-08-08 20:13:20 +08:00
Will Miao
6d8408e626 feat: update release notes and version to 0.8.26, adding creator search and enhancing node usability 2025-08-08 20:10:06 +08:00
Will Miao
0906271aa9 refactor: simplify auto download check logic by removing unnecessary progress updates 2025-08-08 19:58:20 +08:00
Will Miao
4c33c9d256 feat: enhance folder update logic with error handling in fetchModelsPage 2025-08-08 17:33:11 +08:00
Will Miao
fa9c78209f feat: update API endpoints to include '/list' for model retrieval in routes and templates, fixes #344 2025-08-07 18:06:40 +08:00
Will Miao
6678ec8a60 refactor: remove unused height properties and simplify widget height handling in various components, fixes #284 2025-08-07 16:49:39 +08:00
Will Miao
854e467c12 feat: add debug logging for default root settings in DownloadManager 2025-08-07 14:42:05 +08:00
Will Miao
e6b94c7b21 refactor: remove unused import and simplify filename handling in ModelHashIndex, fixes #342 2025-08-06 19:11:07 +08:00
Will Miao
2c6f9d8602 feat: add creator search option and update related functionality across models and UI 2025-08-06 18:32:57 +08:00
Will Miao
c74033b9c0 refactor: conditionally initialize managers in HeaderManager to avoid unnecessary setup on statistics page 2025-08-06 11:14:02 +08:00
Will Miao
d2b21d27bb refactor: remove unused imports from update_routes.py and requirements.txt 2025-08-06 10:34:40 +08:00
Will Miao
215272469f refactor: replace model API client import and remove performance logging, add reset and reload functionality 2025-08-06 07:56:48 +08:00
Will Miao
f7d05ab0f1 refactor: change logging level from info to debug for download progress messages 2025-08-06 06:44:35 +08:00
Will Miao
6f2ad2be77 fix: update LoRA model type check to use constant for improved readability, fixes #341 2025-08-05 19:11:28 +08:00
59 changed files with 2939 additions and 674 deletions

View File

@@ -34,6 +34,17 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes ## Release Notes
### v0.8.27
* **User Experience Enhancements** - Improved the model download target folder selection with path input autocomplete and interactive folder tree navigation, making it easier and faster to choose where models are saved.
* **Default Path Option for Downloads** - Added a "Use Default Path" option when downloading models. When enabled, models are automatically organized and stored according to your configured path template settings.
* **Advanced Download Path Templates** - Expanded path template settings, allowing users to set individual templates for LoRA, checkpoint, and embedding models for greater flexibility. Introduced the `{author}` placeholder, enabling automatic organization of model files by creator name.
* **Bug Fixes & Stability Improvements** - Addressed various bugs and improved overall stability for a smoother experience.
### v0.8.26
* **Creator Search Option** - Added ability to search models by creator name, making it easier to find models from specific authors.
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
### v0.8.25 ### v0.8.25
* **LoRA List Reordering** * **LoRA List Reordering**
- Drag & Drop: Easily rearrange LoRA entries using the drag handle. - Drag & Drop: Easily rearrange LoRA entries using the drag handle.
@@ -146,10 +157,11 @@ Enhance your Civitai browsing experience with our companion browser extension! S
### Option 2: **Portable Standalone Edition** (No ComfyUI required) ### Option 2: **Portable Standalone Edition** (No ComfyUI required)
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.15/lora_manager_portable.7z) 1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.26/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder 2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key 3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
4. Run run.bat 4. Run run.bat
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
### Option 3: **Manual Installation** ### Option 3: **Manual Installation**

View File

@@ -5,6 +5,7 @@ from typing import List
import logging import logging
import sys import sys
import json import json
import urllib.parse
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules standalone_mode = 'nodes' not in sys.modules
@@ -275,8 +276,10 @@ class Config:
for path, route in self._route_mappings.items(): for path, route in self._route_mappings.items():
if real_path.startswith(path): if real_path.startswith(path):
relative_path = os.path.relpath(real_path, path) relative_path = os.path.relpath(real_path, path).replace(os.sep, '/')
return f'{route}/{relative_path.replace(os.sep, "/")}' safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
safe_path = '/'.join(safe_parts)
return f'{route}/{safe_path}'
return "" return ""

View File

@@ -1,6 +1,5 @@
import json import json
import os import os
import asyncio
import re import re
import numpy as np import numpy as np
import folder_paths # type: ignore import folder_paths # type: ignore
@@ -419,11 +418,15 @@ class SaveImage:
# Make sure the output directory exists # Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
# Ensure images is always a list of images # If images is already a list or array of images, do nothing; otherwise, convert to list
if len(images.shape) == 3: # Single image (height, width, channels) if isinstance(images, (list, np.ndarray)):
images = [images] pass
else: # Multiple images (batch, height, width, channels) else:
images = [img for img in images] # Ensure images is always a list of images
if len(images.shape) == 3: # Single image (height, width, channels)
images = [images]
else: # Multiple images (batch, height, width, channels)
images = [img for img in images]
# Save all images # Save all images
results = self.save_images( results = self.save_images(

View File

@@ -101,6 +101,11 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource.get("type", "lora") == "lora": if resource.get("type", "lora") == "lora":
lora_hash = resource.get("hash", "") lora_hash = resource.get("hash", "")
# Skip LoRAs without proper identification (hash or modelVersionId)
if not lora_hash and not resource.get("modelVersionId"):
logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId")
continue
# Skip if we've already added this LoRA by hash # Skip if we've already added this LoRA by hash
if lora_hash and lora_hash in added_loras: if lora_hash and lora_hash in added_loras:
continue continue

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import os
import json import json
import logging import logging
from aiohttp import web from aiohttp import web
@@ -10,6 +11,8 @@ import jinja2
from ..utils.routes_common import ModelRouteUtils from ..utils.routes_common import ModelRouteUtils
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings from ..services.settings_manager import settings
from ..utils.utils import calculate_relative_path_for_model
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..config import config from ..config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,7 +41,7 @@ class BaseModelRoutes(ABC):
prefix: URL prefix (e.g., 'loras', 'checkpoints') prefix: URL prefix (e.g., 'loras', 'checkpoints')
""" """
# Common model management routes # Common model management routes
app.router.add_get(f'/api/{prefix}', self.get_models) app.router.add_get(f'/api/{prefix}/list', self.get_models)
app.router.add_post(f'/api/{prefix}/delete', self.delete_model) app.router.add_post(f'/api/{prefix}/delete', self.delete_model)
app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model) app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model)
app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai) app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai)
@@ -50,6 +53,7 @@ class BaseModelRoutes(ABC):
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates) app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
app.router.add_post(f'/api/{prefix}/move_model', self.move_model) app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk) app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
# Common query routes # Common query routes
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags) app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
@@ -57,6 +61,8 @@ class BaseModelRoutes(ABC):
app.router.add_get(f'/api/{prefix}/scan', self.scan_models) app.router.add_get(f'/api/{prefix}/scan', self.scan_models)
app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots) app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots)
app.router.add_get(f'/api/{prefix}/folders', self.get_folders) app.router.add_get(f'/api/{prefix}/folders', self.get_folders)
app.router.add_get(f'/api/{prefix}/folder-tree', self.get_folder_tree)
app.router.add_get(f'/api/{prefix}/unified-folder-tree', self.get_unified_folder_tree)
app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models) app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models)
app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts) app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
@@ -177,6 +183,7 @@ class BaseModelRoutes(ABC):
'filename': request.query.get('search_filename', 'true').lower() == 'true', 'filename': request.query.get('search_filename', 'true').lower() == 'true',
'modelname': request.query.get('search_modelname', 'true').lower() == 'true', 'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
'tags': request.query.get('search_tags', 'false').lower() == 'true', 'tags': request.query.get('search_tags', 'false').lower() == 'true',
'creator': request.query.get('search_creator', 'false').lower() == 'true',
'recursive': request.query.get('recursive', 'false').lower() == 'true', 'recursive': request.query.get('recursive', 'false').lower() == 'true',
} }
@@ -345,6 +352,43 @@ class BaseModelRoutes(ABC):
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response:
"""Get hierarchical folder tree structure for download modal"""
try:
model_root = request.query.get('model_root')
if not model_root:
return web.json_response({
'success': False,
'error': 'model_root parameter is required'
}, status=400)
folder_tree = await self.service.get_folder_tree(model_root)
return web.json_response({
'success': True,
'tree': folder_tree
})
except Exception as e:
logger.error(f"Error getting folder tree: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
"""Get unified folder tree across all model roots"""
try:
unified_tree = await self.service.get_unified_folder_tree()
return web.json_response({
'success': True,
'tree': unified_tree
})
except Exception as e:
logger.error(f"Error getting unified folder tree: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def find_duplicate_models(self, request: web.Request) -> web.Response: async def find_duplicate_models(self, request: web.Request) -> web.Response:
"""Find models with duplicate SHA256 hashes""" """Find models with duplicate SHA256 hashes"""
try: try:
@@ -695,4 +739,245 @@ class BaseModelRoutes(ABC):
}) })
except Exception as e: except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True) logger.error(f"Error moving models in bulk: {e}", exc_info=True)
return web.Response(text=str(e), status=500) return web.Response(text=str(e), status=500)
async def auto_organize_models(self, request: web.Request) -> web.Response:
"""Auto-organize all models based on current settings"""
try:
# Get all models from cache
cache = await self.service.scanner.get_cached_data()
all_models = cache.raw_data
# Get model roots for this scanner
model_roots = self.service.get_model_roots()
if not model_roots:
return web.json_response({
'success': False,
'error': 'No model roots configured'
}, status=400)
# Check if flat structure is configured for this model type
path_template = settings.get_download_path_template(self.service.model_type)
is_flat_structure = not path_template
# Prepare results tracking
results = []
total_models = len(all_models)
processed = 0
success_count = 0
failure_count = 0
skipped_count = 0
# Send initial progress via WebSocket
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'started',
'total': total_models,
'processed': 0,
'success': 0,
'failures': 0,
'skipped': 0
})
# Process models in batches
for i in range(0, total_models, AUTO_ORGANIZE_BATCH_SIZE):
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
try:
file_path = model.get('file_path')
if not file_path:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "No file path found"
})
failure_count += 1
processed += 1
continue
# Find which model root this file belongs to
current_root = None
for root in model_roots:
# Normalize paths for comparison
normalized_root = os.path.normpath(root).replace(os.sep, '/')
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
if normalized_file.startswith(normalized_root):
current_root = root
break
if not current_root:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Model file not found in any configured root directory"
})
failure_count += 1
processed += 1
continue
# Handle flat structure case
if is_flat_structure:
current_dir = os.path.dirname(file_path)
# Check if already in root directory
if os.path.normpath(current_dir) == os.path.normpath(current_root):
skipped_count += 1
processed += 1
continue
# Move to root directory for flat structure
target_dir = current_root
else:
# Calculate new relative path based on settings
new_relative_path = calculate_relative_path_for_model(model, self.service.model_type)
# If no relative path calculated (insufficient metadata), skip
if not new_relative_path:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Skipped - insufficient metadata for organization"
})
skipped_count += 1
processed += 1
continue
# Calculate target directory
target_dir = os.path.join(current_root, new_relative_path).replace(os.sep, '/')
current_dir = os.path.dirname(file_path)
# Skip if already in correct location
if current_dir.replace(os.sep, '/') == target_dir.replace(os.sep, '/'):
skipped_count += 1
processed += 1
continue
# Check if target file would conflict
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_dir, file_name)
if os.path.exists(target_file_path):
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": f"Target file already exists: {target_file_path}"
})
failure_count += 1
processed += 1
continue
# Perform the move
success = await self.service.scanner.move_model(file_path, target_dir)
if success:
success_count += 1
else:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Failed to move model"
})
failure_count += 1
processed += 1
except Exception as e:
logger.error(f"Error processing model {model.get('model_name', 'Unknown')}: {e}", exc_info=True)
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": f"Error: {str(e)}"
})
failure_count += 1
processed += 1
# Send progress update after each batch
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'processing',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count
})
# Small delay between batches to prevent overwhelming the system
await asyncio.sleep(0.1)
# Send completion message
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'cleaning',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'message': 'Cleaning up empty directories...'
})
# Clean up empty directories after organizing
from ..utils.utils import remove_empty_dirs
cleanup_counts = {}
for root in model_roots:
removed = remove_empty_dirs(root)
cleanup_counts[root] = removed
# Send cleanup completed message
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'completed',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'cleanup': cleanup_counts
})
# Prepare response with limited details
response_data = {
'success': True,
'message': f'Auto-organize completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
'summary': {
'total': total_models,
'success': success_count,
'skipped': skipped_count,
'failures': failure_count,
'organization_type': 'flat' if is_flat_structure else 'structured',
'cleaned_dirs': cleanup_counts
}
}
# Only include detailed results if under limit
if len(results) <= 100:
response_data['results'] = results
else:
response_data['results_truncated'] = True
response_data['sample_results'] = results[:50] # Show first 50 as sample
return web.json_response(response_data)
except Exception as e:
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
# Send error message via WebSocket
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'error',
'error': str(e)
})
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -184,7 +184,7 @@ class MiscRoutes:
logger.info(f"Example images path changed to {value} - server restart required") logger.info(f"Example images path changed to {value} - server restart required")
# Special handling for base_model_path_mappings - parse JSON string # Special handling for base_model_path_mappings - parse JSON string
if key == 'base_model_path_mappings' and value: if (key == 'base_model_path_mappings' or key == 'download_path_templates') and value:
try: try:
value = json.loads(value) value = json.loads(value)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -654,13 +654,13 @@ class MiscRoutes:
exists = False exists = False
model_type = None model_type = None
if await lora_scanner.check_model_version_exists(model_id, model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
exists = True exists = True
model_type = 'lora' model_type = 'lora'
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_id, model_version_id): elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id):
exists = True exists = True
model_type = 'checkpoint' model_type = 'checkpoint'
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_id, model_version_id): elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id):
exists = True exists = True
model_type = 'embedding' model_type = 'embedding'

View File

@@ -1,5 +1,4 @@
import os import os
import subprocess
import aiohttp import aiohttp
import logging import logging
import toml import toml
@@ -7,7 +6,6 @@ import git
import zipfile import zipfile
import shutil import shutil
import tempfile import tempfile
from datetime import datetime
from aiohttp import web from aiohttp import web
from typing import Dict, List from typing import Dict, List

View File

@@ -199,6 +199,22 @@ class BaseModelService(ABC):
for tag in item['tags']): for tag in item['tags']):
search_results.append(item) search_results.append(item)
continue continue
# Search by creator
civitai = item.get('civitai')
creator_username = ''
if civitai and isinstance(civitai, dict):
creator = civitai.get('creator')
if creator and isinstance(creator, dict):
creator_username = creator.get('username', '')
if search_options.get('creator', False) and creator_username:
if fuzzy_search:
if fuzzy_match(creator_username, search):
search_results.append(item)
continue
elif search.lower() in creator_username.lower():
search_results.append(item)
continue
return search_results return search_results
@@ -256,4 +272,62 @@ class BaseModelService(ABC):
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get model root directories""" """Get model root directories"""
return self.scanner.get_model_roots() return self.scanner.get_model_roots()
async def get_folder_tree(self, model_root: str) -> Dict:
"""Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data()
# Build tree structure from folders
tree = {}
for folder in cache.folders:
# Check if this folder belongs to the specified model root
folder_belongs_to_root = False
for root in self.scanner.get_model_roots():
if root == model_root:
folder_belongs_to_root = True
break
if not folder_belongs_to_root:
continue
# Split folder path into components
parts = folder.split('/') if folder else []
current_level = tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return tree
async def get_unified_folder_tree(self) -> Dict:
"""Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data()
# Build unified tree structure by analyzing all relative paths
unified_tree = {}
# Get all model roots for path normalization
model_roots = self.scanner.get_model_roots()
for folder in cache.folders:
if not folder: # Skip empty folders
continue
# Find which root this folder belongs to by checking the actual file paths
# This is a simplified approach - we'll use the folder as-is since it should already be relative
relative_path = folder
# Split folder path into components
parts = relative_path.split('/')
current_level = unified_tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return unified_tree

View File

@@ -13,7 +13,7 @@ class CheckpointScanner(ModelScanner):
def __init__(self): def __init__(self):
# Define supported file extensions # Define supported file extensions
file_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.sft', '.gguf'} file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'}
super().__init__( super().__init__(
model_type="checkpoint", model_type="checkpoint",
model_class=CheckpointMetadata, model_class=CheckpointMetadata,

View File

@@ -223,11 +223,11 @@ class CivitaiClient:
logger.error(f"Error fetching model versions: {e}") logger.error(f"Error fetching model versions: {e}")
return None return None
async def get_model_version(self, model_id: int, version_id: int = None) -> Optional[Dict]: async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata """Get specific model version with additional metadata
Args: Args:
model_id: The Civitai model ID model_id: The Civitai model ID (optional if version_id is provided)
version_id: Optional specific version ID to retrieve version_id: Optional specific version ID to retrieve
Returns: Returns:
@@ -235,37 +235,72 @@ class CivitaiClient:
""" """
try: try:
session = await self._ensure_fresh_session() session = await self._ensure_fresh_session()
# Step 1: Get model data to find version_id if not provided and get additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Step 2: Determine the version_id to use
target_version_id = version_id
if target_version_id is None:
target_version_id = model_versions[0].get('id')
# Step 3: Get detailed version info using the version_id
headers = self._get_request_headers() headers = self._get_request_headers()
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
if response.status != 200: # Case 1: Only version_id is provided
return None if model_id is None and version_id is not None:
# First get the version info to extract model_id
async with session.get(f"{self.base_url}/model-versions/{version_id}", headers=headers) as response:
if response.status != 200:
return None
version = await response.json()
model_id = version.get('modelId')
if not model_id:
logger.error(f"No modelId found in version {version_id}")
return None
version = await response.json() # Now get the model data for additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return version # Return version without additional metadata
model_data = await response.json()
# Enrich version with model data
version['model']['description'] = model_data.get("description")
version['model']['tags'] = model_data.get("tags", [])
version['creator'] = model_data.get("creator")
return version
# Case 2: model_id is provided (with or without version_id)
elif model_id is not None:
# Step 1: Get model data to find version_id if not provided and get additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Step 2: Determine the version_id to use
target_version_id = version_id
if target_version_id is None:
target_version_id = model_versions[0].get('id')
# Step 4: Enrich version_info with model data # Step 3: Get detailed version info using the version_id
# Add description and tags from model data async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
version['model']['description'] = data.get("description") if response.status != 200:
version['model']['tags'] = data.get("tags", []) return None
# Add creator from model data version = await response.json()
version['creator'] = data.get("creator")
# Step 4: Enrich version_info with model data
return version # Add description and tags from model data
version['model']['description'] = data.get("description")
version['model']['tags'] = data.get("tags", [])
# Add creator from model data
version['creator'] = data.get("creator")
return version
# Case 3: Neither model_id nor version_id provided
else:
logger.error("Either model_id or version_id must be provided")
return None
except Exception as e: except Exception as e:
logger.error(f"Error fetching model version: {e}") logger.error(f"Error fetching model version: {e}")

View File

@@ -54,15 +54,15 @@ class DownloadManager:
"""Get the checkpoint scanner from registry""" """Get the checkpoint scanner from registry"""
return await ServiceRegistry.get_checkpoint_scanner() return await ServiceRegistry.get_checkpoint_scanner()
async def download_from_civitai(self, model_id: int, model_version_id: int, async def download_from_civitai(self, model_id: int = None, model_version_id: int = None,
save_dir: str = None, relative_path: str = '', save_dir: str = None, relative_path: str = '',
progress_callback=None, use_default_paths: bool = False, progress_callback=None, use_default_paths: bool = False,
download_id: str = None) -> Dict: download_id: str = None) -> Dict:
"""Download model from Civitai with task tracking and concurrency control """Download model from Civitai with task tracking and concurrency control
Args: Args:
model_id: Civitai model ID model_id: Civitai model ID (optional if model_version_id is provided)
model_version_id: Civitai model version ID model_version_id: Civitai model version ID (optional if model_id is provided)
save_dir: Directory to save the model save_dir: Directory to save the model
relative_path: Relative path within save_dir relative_path: Relative path within save_dir
progress_callback: Callback function for progress updates progress_callback: Callback function for progress updates
@@ -72,6 +72,10 @@ class DownloadManager:
Returns: Returns:
Dict with download result Dict with download result
""" """
# Validate that at least one identifier is provided
if not model_id and not model_version_id:
return {'success': False, 'error': 'Either model_id or model_version_id must be provided'}
# Use provided download_id or generate new one # Use provided download_id or generate new one
task_id = download_id or str(uuid.uuid4()) task_id = download_id or str(uuid.uuid4())
@@ -181,14 +185,19 @@ class DownloadManager:
# Check both scanners # Check both scanners
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
# Check lora scanner first # Check lora scanner first
if await lora_scanner.check_model_version_exists(model_id, model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in lora library'} return {'success': False, 'error': 'Model version already exists in lora library'}
# Check checkpoint scanner # Check checkpoint scanner
if await checkpoint_scanner.check_model_version_exists(model_id, model_version_id): if await checkpoint_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in checkpoint library'} return {'success': False, 'error': 'Model version already exists in checkpoint library'}
# Check embedding scanner
if await embedding_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'}
# Get civitai client # Get civitai client
civitai_client = await self._get_civitai_client() civitai_client = await self._get_civitai_client()
@@ -211,23 +220,22 @@ class DownloadManager:
# Case 2: model_version_id was None, check after getting version_info # Case 2: model_version_id was None, check after getting version_info
if model_version_id is None: if model_version_id is None:
version_model_id = version_info.get('modelId')
version_id = version_info.get('id') version_id = version_info.get('id')
if model_type == 'lora': if model_type == 'lora':
# Check lora scanner # Check lora scanner
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
if await lora_scanner.check_model_version_exists(version_model_id, version_id): if await lora_scanner.check_model_version_exists(version_id):
return {'success': False, 'error': 'Model version already exists in lora library'} return {'success': False, 'error': 'Model version already exists in lora library'}
elif model_type == 'checkpoint': elif model_type == 'checkpoint':
# Check checkpoint scanner # Check checkpoint scanner
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
if await checkpoint_scanner.check_model_version_exists(version_model_id, version_id): if await checkpoint_scanner.check_model_version_exists(version_id):
return {'success': False, 'error': 'Model version already exists in checkpoint library'} return {'success': False, 'error': 'Model version already exists in checkpoint library'}
elif model_type == 'embedding': elif model_type == 'embedding':
# Embeddings are not checked in scanners, but we can still check if it exists # Embeddings are not checked in scanners, but we can still check if it exists
embedding_scanner = await ServiceRegistry.get_embedding_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner()
if await embedding_scanner.check_model_version_exists(version_model_id, version_id): if await embedding_scanner.check_model_version_exists(version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'} return {'success': False, 'error': 'Model version already exists in embedding library'}
# Handle use_default_paths # Handle use_default_paths
@@ -250,7 +258,7 @@ class DownloadManager:
save_dir = default_path save_dir = default_path
# Calculate relative path using template # Calculate relative path using template
relative_path = self._calculate_relative_path(version_info) relative_path = self._calculate_relative_path(version_info, model_type)
# Update save directory with relative path if provided # Update save directory with relative path if provided
if relative_path: if relative_path:
@@ -323,17 +331,18 @@ class DownloadManager:
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."} return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
def _calculate_relative_path(self, version_info: Dict) -> str: def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str:
"""Calculate relative path using template from settings """Calculate relative path using template from settings
Args: Args:
version_info: Version info from Civitai API version_info: Version info from Civitai API
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns: Returns:
Relative path string Relative path string
""" """
# Get path template from settings, default to '{base_model}/{first_tag}' # Get path template from settings for specific model type
path_template = settings.get('download_path_template', '{base_model}/{first_tag}') path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure) # If template is empty, return empty path (flat structure)
if not path_template: if not path_template:
@@ -342,6 +351,9 @@ class DownloadManager:
# Get base model name # Get base model name
base_model = version_info.get('baseModel', '') base_model = version_info.get('baseModel', '')
# Get author from creator data
author = version_info.get('creator', {}).get('username', 'Anonymous')
# Apply mapping if available # Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {}) base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model) mapped_base_model = base_model_mappings.get(base_model, base_model)
@@ -364,6 +376,7 @@ class DownloadManager:
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag) formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path return formatted_path

View File

@@ -31,29 +31,34 @@ class ModelHashIndex:
if file_path not in self._duplicate_hashes.get(sha256, []): if file_path not in self._duplicate_hashes.get(sha256, []):
self._duplicate_hashes.setdefault(sha256, []).append(file_path) self._duplicate_hashes.setdefault(sha256, []).append(file_path)
# Track duplicates by filename # Track duplicates by filename - FIXED LOGIC
if filename in self._filename_to_hash: if filename in self._filename_to_hash:
old_hash = self._filename_to_hash[filename] existing_hash = self._filename_to_hash[filename]
if old_hash != sha256: # Different models with the same name existing_path = self._hash_to_path.get(existing_hash)
old_path = self._hash_to_path.get(old_hash)
if old_path: # If this is a different file with the same filename
if filename not in self._duplicate_filenames: if existing_path and existing_path != file_path:
self._duplicate_filenames[filename] = [old_path] # Initialize duplicates tracking if needed
if file_path not in self._duplicate_filenames.get(filename, []): if filename not in self._duplicate_filenames:
self._duplicate_filenames.setdefault(filename, []).append(file_path) self._duplicate_filenames[filename] = [existing_path]
# Add current file to duplicates if not already present
if file_path not in self._duplicate_filenames[filename]:
self._duplicate_filenames[filename].append(file_path)
# Remove old path mapping if hash exists # Remove old path mapping if hash exists
if sha256 in self._hash_to_path: if sha256 in self._hash_to_path:
old_path = self._hash_to_path[sha256] old_path = self._hash_to_path[sha256]
old_filename = self._get_filename_from_path(old_path) old_filename = self._get_filename_from_path(old_path)
if old_filename in self._filename_to_hash: if old_filename in self._filename_to_hash and self._filename_to_hash[old_filename] == sha256:
del self._filename_to_hash[old_filename] del self._filename_to_hash[old_filename]
# Remove old hash mapping if filename exists # Remove old hash mapping if filename exists and points to different hash
if filename in self._filename_to_hash: if filename in self._filename_to_hash:
old_hash = self._filename_to_hash[filename] old_hash = self._filename_to_hash[filename]
if old_hash in self._hash_to_path: if old_hash != sha256 and old_hash in self._hash_to_path:
del self._hash_to_path[old_hash] # Don't delete the old hash mapping, just update filename mapping
pass
# Add new mappings # Add new mappings
self._hash_to_path[sha256] = file_path self._hash_to_path[sha256] = file_path
@@ -199,8 +204,6 @@ class ModelHashIndex:
def get_hash_by_filename(self, filename: str) -> Optional[str]: def get_hash_by_filename(self, filename: str) -> Optional[str]:
"""Get hash for a filename without extension""" """Get hash for a filename without extension"""
# Strip extension if present to make the function more flexible
filename = os.path.splitext(filename)[0]
return self._filename_to_hash.get(filename) return self._filename_to_hash.get(filename)
def clear(self) -> None: def clear(self) -> None:

View File

@@ -302,6 +302,13 @@ class ModelScanner:
for tag in model_data['tags']: for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
duplicate_filenames = self._hash_index.get_duplicate_filenames()
if duplicate_filenames:
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
for filename, paths in duplicate_filenames.items():
logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache # Update cache
self._cache.raw_data = raw_data self._cache.raw_data = raw_data
loop.run_until_complete(self._cache.resort()) loop.run_until_complete(self._cache.resort())
@@ -367,6 +374,13 @@ class ModelScanner:
for tag in model_data['tags']: for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
duplicate_filenames = self._hash_index.get_duplicate_filenames()
if duplicate_filenames:
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
for filename, paths in duplicate_filenames.items():
logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache # Update cache
self._cache = ModelCache( self._cache = ModelCache(
raw_data=raw_data, raw_data=raw_data,
@@ -670,6 +684,14 @@ class ModelScanner:
if model_data.get('exclude', False): if model_data.get('exclude', False):
self._excluded_models.append(model_data['file_path']) self._excluded_models.append(model_data['file_path'])
return None return None
# Check for duplicate filename before adding to hash index
filename = os.path.splitext(os.path.basename(file_path))[0]
existing_hash = self._hash_index.get_hash_by_filename(filename)
if existing_hash and existing_hash != model_data.get('sha256', '').lower():
existing_path = self._hash_index.get_path(existing_hash)
if existing_path and existing_path != file_path:
logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
await self._fetch_missing_metadata(file_path, model_data) await self._fetch_missing_metadata(file_path, model_data)
rel_path = os.path.relpath(file_path, root_path) rel_path = os.path.relpath(file_path, root_path)
@@ -1149,13 +1171,12 @@ class ModelScanner:
if len(self._hash_index._duplicate_filenames[file_name]) <= 1: if len(self._hash_index._duplicate_filenames[file_name]) <= 1:
del self._hash_index._duplicate_filenames[file_name] del self._hash_index._duplicate_filenames[file_name]
async def check_model_version_exists(self, model_id: int, model_version_id: int) -> bool: async def check_model_version_exists(self, model_version_id: int) -> bool:
"""Check if a specific model version exists in the cache """Check if a specific model version exists in the cache
Args: Args:
model_id: Civitai model ID
model_version_id: Civitai model version ID model_version_id: Civitai model version ID
Returns: Returns:
bool: True if the model version exists, False otherwise bool: True if the model version exists, False otherwise
""" """
@@ -1163,13 +1184,11 @@ class ModelScanner:
cache = await self.get_cached_data() cache = await self.get_cached_data()
if not cache or not cache.raw_data: if not cache or not cache.raw_data:
return False return False
for item in cache.raw_data: for item in cache.raw_data:
if (item.get('civitai') and if item.get('civitai') and item['civitai'].get('id') == model_version_id:
item['civitai'].get('modelId') == model_id and
item['civitai'].get('id') == model_version_id):
return True return True
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error checking model version existence: {e}") logger.error(f"Error checking model version existence: {e}")

View File

@@ -9,6 +9,7 @@ class SettingsManager:
def __init__(self): def __init__(self):
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json') self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_download_path_template()
self._auto_set_default_roots() self._auto_set_default_roots()
self._check_environment_variables() self._check_environment_variables()
@@ -22,6 +23,24 @@ class SettingsManager:
logger.error(f"Error loading settings: {e}") logger.error(f"Error loading settings: {e}")
return self._get_default_settings() return self._get_default_settings()
def _migrate_download_path_template(self):
"""Migrate old download_path_template to new download_path_templates"""
old_template = self.settings.get('download_path_template')
templates = self.settings.get('download_path_templates')
# If old template exists and new templates don't exist, migrate
if old_template is not None and not templates:
logger.info("Migrating download_path_template to download_path_templates")
self.settings['download_path_templates'] = {
'lora': old_template,
'checkpoint': old_template,
'embedding': old_template
}
# Remove old setting
del self.settings['download_path_template']
self._save_settings()
logger.info("Migration completed")
def _auto_set_default_roots(self): def _auto_set_default_roots(self):
"""Auto set default root paths if only one folder is present and default is empty.""" """Auto set default root paths if only one folder is present and default is empty."""
folder_paths = self.settings.get('folder_paths', {}) folder_paths = self.settings.get('folder_paths', {})
@@ -81,4 +100,16 @@ class SettingsManager:
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
def get_download_path_template(self, model_type: str) -> str:
"""Get download path template for specific model type
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding')
Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}'
"""
templates = self.settings.get('download_path_templates', {})
return templates.get(model_type, '{base_model}/{first_tag}')
settings = SettingsManager() settings = SettingsManager()

View File

@@ -48,9 +48,13 @@ SUPPORTED_MEDIA_EXTENSIONS = {
# Valid Lora types # Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon', 'dora'] VALID_LORA_TYPES = ['lora', 'locon', 'dora']
# Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system
# Civitai model tags in priority order for subfolder organization # Civitai model tags in priority order for subfolder organization
CIVITAI_MODEL_TAGS = [ CIVITAI_MODEL_TAGS = [
'character', 'style', 'concept', 'clothing', 'base model', 'character', 'style', 'concept', 'clothing',
# 'base model', # exclude 'base model'
'poses', 'background', 'tool', 'vehicle', 'buildings', 'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action' 'objects', 'assets', 'animal', 'action'
] ]

View File

@@ -24,7 +24,8 @@ download_progress = {
'start_time': None, 'start_time': None,
'end_time': None, 'end_time': None,
'processed_models': set(), # Track models that have been processed 'processed_models': set(), # Track models that have been processed
'refreshed_models': set() # Track models that had metadata refreshed 'refreshed_models': set(), # Track models that had metadata refreshed
'failed_models': set() # Track models that failed to download after metadata refresh
} }
class DownloadManager: class DownloadManager:
@@ -50,6 +51,7 @@ class DownloadManager:
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({ return web.json_response({
'success': False, 'success': False,
@@ -91,12 +93,15 @@ class DownloadManager:
with open(progress_file, 'r', encoding='utf-8') as f: with open(progress_file, 'r', encoding='utf-8') as f:
saved_progress = json.load(f) saved_progress = json.load(f)
download_progress['processed_models'] = set(saved_progress.get('processed_models', [])) download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
logger.info(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed") download_progress['failed_models'] = set(saved_progress.get('failed_models', []))
logger.debug(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed, {len(download_progress['failed_models'])} models marked as failed")
except Exception as e: except Exception as e:
logger.error(f"Failed to load progress file: {e}") logger.error(f"Failed to load progress file: {e}")
download_progress['processed_models'] = set() download_progress['processed_models'] = set()
download_progress['failed_models'] = set()
else: else:
download_progress['processed_models'] = set() download_progress['processed_models'] = set()
download_progress['failed_models'] = set()
# Start the download task # Start the download task
is_downloading = True is_downloading = True
@@ -113,6 +118,7 @@ class DownloadManager:
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -136,6 +142,7 @@ class DownloadManager:
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -230,7 +237,7 @@ class DownloadManager:
# Update total count # Update total count
download_progress['total'] = len(all_models) download_progress['total'] = len(all_models)
logger.info(f"Found {download_progress['total']} models to process") logger.debug(f"Found {download_progress['total']} models to process")
# Process each model # Process each model
for i, (scanner_type, model, scanner) in enumerate(all_models): for i, (scanner_type, model, scanner) in enumerate(all_models):
@@ -250,7 +257,7 @@ class DownloadManager:
# Mark as completed # Mark as completed
download_progress['status'] = 'completed' download_progress['status'] = 'completed'
download_progress['end_time'] = time.time() download_progress['end_time'] = time.time()
logger.info(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed") logger.debug(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
except Exception as e: except Exception as e:
error_msg = f"Error during example images download: {str(e)}" error_msg = f"Error during example images download: {str(e)}"
@@ -299,6 +306,11 @@ class DownloadManager:
# Update current model info # Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})" download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already in failed models
if model_hash in download_progress['failed_models']:
logger.debug(f"Skipping known failed model: {model_name}")
return False
# Skip if already processed AND directory exists with files # Skip if already processed AND directory exists with files
if model_hash in download_progress['processed_models']: if model_hash in download_progress['processed_models']:
model_dir = os.path.join(output_dir, model_hash) model_dir = os.path.join(output_dir, model_hash)
@@ -308,6 +320,8 @@ class DownloadManager:
return False return False
else: else:
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing") logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Remove from processed models since we need to reprocess
download_progress['processed_models'].discard(model_hash)
# Create model directory # Create model directory
model_dir = os.path.join(output_dir, model_hash) model_dir = os.path.join(output_dir, model_hash)
@@ -351,12 +365,23 @@ class DownloadManager:
success, _ = await ExampleImagesProcessor.download_model_images( success, _ = await ExampleImagesProcessor.download_model_images(
model_hash, model_name, updated_images, model_dir, optimize, independent_session model_hash, model_name, updated_images, model_dir, optimize, independent_session
) )
download_progress['refreshed_models'].add(model_hash)
# Only mark as processed if all images were downloaded successfully # Mark as processed if successful, or as failed if unsuccessful after refresh
if success: if success:
download_progress['processed_models'].add(model_hash) download_progress['processed_models'].add(model_hash)
else:
# If we refreshed metadata and still failed, mark as permanently failed
if model_hash in download_progress['refreshed_models']:
download_progress['failed_models'].add(model_hash)
logger.info(f"Marking model {model_name} as failed after metadata refresh")
return True # Return True to indicate a remote download happened return True # Return True to indicate a remote download happened
else:
# No civitai data or images available, mark as failed to avoid future attempts
download_progress['failed_models'].add(model_hash)
logger.debug(f"No civitai images available for model {model_name}, marking as failed")
# Save progress periodically # Save progress periodically
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1: if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
@@ -391,6 +416,7 @@ class DownloadManager:
progress_data = { progress_data = {
'processed_models': list(download_progress['processed_models']), 'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']), 'refreshed_models': list(download_progress['refreshed_models']),
'failed_models': list(download_progress['failed_models']),
'completed': download_progress['completed'], 'completed': download_progress['completed'],
'total': download_progress['total'], 'total': download_progress['total'],
'last_update': time.time() 'last_update': time.time()

View File

@@ -580,16 +580,19 @@ class ModelRouteUtils:
}) })
# Check which identifier is provided and convert to int # Check which identifier is provided and convert to int
try: model_id = None
model_id = int(data.get('model_id')) model_version_id = None
except (TypeError, ValueError):
return web.json_response({ if data.get('model_id'):
'success': False, try:
'error': "Invalid model_id: Must be an integer" model_id = int(data.get('model_id'))
}, status=400) except (TypeError, ValueError):
return web.json_response({
'success': False,
'error': "Invalid model_id: Must be an integer"
}, status=400)
# Convert model_version_id to int if provided # Convert model_version_id to int if provided
model_version_id = None
if data.get('model_version_id'): if data.get('model_version_id'):
try: try:
model_version_id = int(data.get('model_version_id')) model_version_id = int(data.get('model_version_id'))
@@ -599,11 +602,11 @@ class ModelRouteUtils:
'error': "Invalid model_version_id: Must be an integer" 'error': "Invalid model_version_id: Must be an integer"
}, status=400) }, status=400)
# Only model_id is required, model_version_id is optional # At least one identifier is required
if not model_id: if not model_id and not model_version_id:
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': "Missing required parameter: Please provide 'model_id'" 'error': "Missing required parameter: Please provide either 'model_id' or 'model_version_id'"
}, status=400) }, status=400)
use_default_paths = data.get('use_default_paths', False) use_default_paths = data.get('use_default_paths', False)

View File

@@ -1,7 +1,10 @@
from difflib import SequenceMatcher from difflib import SequenceMatcher
import os import os
from typing import Dict
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..config import config from ..config import config
from ..services.settings_manager import settings
from .constants import CIVITAI_MODEL_TAGS
import asyncio import asyncio
def get_lora_info(lora_name): def get_lora_info(lora_name):
@@ -47,7 +50,7 @@ def get_lora_info(lora_name):
# No event loop is running, we can use asyncio.run() # No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_async()) return asyncio.run(_get_lora_info_async())
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool: def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
""" """
Check if text matches pattern using fuzzy matching. Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold. Returns True if similarity ratio is above threshold.
@@ -128,3 +131,94 @@ def calculate_recipe_fingerprint(loras):
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras]) fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
return fingerprint return fingerprint
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
"""Calculate relative path for existing model using template from settings
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Relative path string (empty string for flat structure)
"""
# Get path template from settings for specific model type
path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
return ''
# Get base model name from model metadata
civitai_data = model_data.get('civitai', {})
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
if civitai_data and civitai_data.get('id') is not None:
base_model = civitai_data.get('baseModel', '')
# Get author from civitai creator data
author = civitai_data.get('creator', {}).get('username', 'Anonymous')
else:
# Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '')
author = 'Anonymous' # Default for non-CivitAI models
model_tags = model_data.get('tags', [])
# Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
# Find the first Civitai model tag that exists in model_tags
first_tag = ''
for civitai_tag in CIVITAI_MODEL_TAGS:
if civitai_tag in model_tags:
first_tag = civitai_tag
break
# If no Civitai model tag found, fallback to first tag
if not first_tag and model_tags:
first_tag = model_tags[0]
if not first_tag:
first_tag = 'no tags' # Default if no tags available
# Format the template with available data
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path
def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path.
Args:
path (str): Root directory to start cleaning from
Returns:
int: Number of empty directories removed
"""
removed_count = 0
if not os.path.isdir(path):
return removed_count
# List all files in directory
files = os.listdir(path)
# Process all subdirectories first
for file in files:
full_path = os.path.join(path, file)
if os.path.isdir(full_path):
removed_count += remove_empty_dirs(full_path)
# Check if directory is now empty (after processing subdirectories)
if not os.listdir(path):
try:
os.rmdir(path)
removed_count += 1
except OSError:
pass
return removed_count

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.8.25" version = "0.8.27"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -7,5 +7,4 @@ olefile
toml toml
numpy numpy
natsort natsort
pyyaml
GitPython GitPython

View File

@@ -9,6 +9,10 @@
"checkpoints": [ "checkpoints": [
"C:/path/to/your/checkpoints_folder", "C:/path/to/your/checkpoints_folder",
"C:/path/to/another/checkpoints_folder" "C:/path/to/another/checkpoints_folder"
],
"embeddings": [
"C:/path/to/your/embeddings_folder",
"C:/path/to/another/embeddings_folder"
] ]
} }
} }

View File

@@ -1,197 +0,0 @@
/* Download Modal Styles */
.download-step {
margin: var(--space-2) 0;
}
.input-group {
margin-bottom: var(--space-2);
}
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.input-group input,
.input-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
/* Version List Styles */
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.version-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.version-content .version-info {
display: flex;
flex-wrap: wrap;
flex-direction: row !important;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.version-content .version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
gap: 4px;
}
/* Folder Browser Styles */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
/* Path Preview Styles */
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Dark theme adjustments */
[data-theme="dark"] .version-item {
background: var(--lora-surface);
}
[data-theme="dark"] .local-path {
background: var(--lora-surface);
border-color: var(--lora-border);
}
/* Enhance the local badge to make it more noticeable */
.version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}

View File

@@ -0,0 +1,514 @@
/* Download Modal Styles */
.input-group {
margin-bottom: var(--space-2);
}
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.input-group input,
.input-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
/* Version List Styles */
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.version-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.version-content .version-info {
display: flex;
flex-wrap: wrap;
flex-direction: row !important;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.version-content .version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
gap: 4px;
}
/* Folder Browser Styles */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
/* Path Input Styles */
.path-input-container {
position: relative;
display: flex;
gap: 8px;
align-items: center;
}
.path-input-container input {
flex: 1;
}
.create-folder-btn {
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.create-folder-btn:hover {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.path-suggestions {
position: absolute;
top: 46%;
left: 0;
right: 0;
z-index: 1000;
margin: 0 24px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
max-height: 200px;
overflow-y: auto;
}
.path-suggestion {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--border-color);
}
.path-suggestion:last-child {
border-bottom: none;
}
.path-suggestion:hover {
background: var(--lora-surface);
}
.path-suggestion.active {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
/* Breadcrumb Navigation Styles */
.breadcrumb-nav {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-2);
padding: var(--space-1);
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
overflow-x: auto;
white-space: nowrap;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-color);
opacity: 0.7;
text-decoration: none;
}
.breadcrumb-item:hover {
background: var(--bg-color);
opacity: 1;
}
.breadcrumb-item.active {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
opacity: 1;
}
.breadcrumb-separator {
color: var(--text-color);
opacity: 0.5;
margin: 0 4px;
}
/* Folder Tree Styles */
.folder-tree-container {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
max-height: 300px;
overflow-y: auto;
}
.folder-tree {
padding: var(--space-1);
}
.tree-node {
user-select: none;
}
.tree-node-content {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
position: relative;
}
.tree-node-content:hover {
background: var(--lora-surface);
}
.tree-node-content.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
.tree-expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
transition: all 0.2s ease;
}
.tree-expand-icon:hover {
background: var(--lora-surface);
}
.tree-expand-icon.expanded {
transform: rotate(90deg);
}
.tree-folder-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--lora-accent);
}
.tree-folder-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
}
.tree-children {
margin-left: 20px;
display: none;
}
.tree-children.expanded {
display: block;
}
.tree-node.has-children > .tree-node-content .tree-expand-icon {
opacity: 1;
}
.tree-node:not(.has-children) > .tree-node-content .tree-expand-icon {
opacity: 0;
pointer-events: none;
}
/* Create folder inline form */
.create-folder-form {
display: flex;
gap: 8px;
margin-left: 20px;
align-items: center;
height: 21px;
}
.create-folder-form input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9em;
}
.create-folder-form button {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s ease;
}
.create-folder-form button.confirm {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.create-folder-form button:hover {
background: var(--lora-surface);
}
/* Path Preview Styles */
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: var(--space-2);
}
.path-preview-header label {
margin: 0;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Inline Toggle Styles */
.inline-toggle-container {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
}
.inline-toggle-label {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.9;
white-space: nowrap;
}
.inline-toggle-container .toggle-switch {
position: relative;
width: 36px;
height: 18px;
flex-shrink: 0;
}
.inline-toggle-container .toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.inline-toggle-container .toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: all 0.3s ease;
border-radius: 18px;
}
.inline-toggle-container .toggle-slider:before {
position: absolute;
content: "";
height: 12px;
width: 12px;
left: 3px;
bottom: 3px;
background-color: white;
transition: all 0.3s ease;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.inline-toggle-container .toggle-switch input:checked + .toggle-slider {
background-color: var(--lora-accent);
}
.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(18px);
}
/* Dark theme adjustments */
[data-theme="dark"] .version-item {
background: var(--lora-surface);
}
[data-theme="dark"] .local-path {
background: var(--lora-surface);
border-color: var(--lora-border);
}
[data-theme="dark"] .toggle-slider:before {
background-color: #f0f0f0;
}
/* Enhance the local badge to make it more noticeable */
.version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.manual-path-selection.disabled {
opacity: 0.5;
pointer-events: none;
user-select: none;
}

View File

@@ -482,4 +482,99 @@ input:checked + .toggle-slider:before {
[data-theme="dark"] .base-model-select option { [data-theme="dark"] .base-model-select option {
background-color: #2d2d2d; background-color: #2d2d2d;
color: var(--text-color); color: var(--text-color);
}
/* Template Configuration Styles */
.placeholder-info {
margin-top: var(--space-1);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.placeholder-tag {
display: inline-block;
background: var(--lora-accent);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 1em;
font-weight: 500;
}
.template-custom-row {
margin-top: 8px;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.template-custom-input {
width: 96%;
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
font-family: monospace;
height: 24px;
transition: border-color 0.2s;
}
.template-custom-input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.template-custom-input::placeholder {
color: var(--text-color);
opacity: 0.5;
font-family: inherit;
}
.template-validation {
margin-top: 6px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 6px;
min-height: 20px;
}
.template-validation.valid {
color: var(--lora-success, #22c55e);
}
.template-validation.invalid {
color: var(--lora-error, #ef4444);
}
.template-validation i {
width: 12px;
}
/* Dark theme specific adjustments */
[data-theme="dark"] .template-custom-input {
background-color: rgba(30, 30, 30, 0.9);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.placeholder-info {
flex-direction: column;
align-items: flex-start;
}
} }

View File

@@ -16,7 +16,7 @@
@import 'components/modal/relink-civitai-modal.css'; @import 'components/modal/relink-civitai-modal.css';
@import 'components/modal/example-access-modal.css'; @import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css'; @import 'components/modal/support-modal.css';
@import 'components/download-modal.css'; @import 'components/modal/download-modal.css';
@import 'components/toast.css'; @import 'components/toast.css';
@import 'components/loading.css'; @import 'components/loading.css';
@import 'components/menu.css'; @import 'components/menu.css';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -55,7 +55,7 @@ export function getApiEndpoints(modelType) {
return { return {
// Base CRUD operations // Base CRUD operations
list: `/api/${modelType}`, list: `/api/${modelType}/list`,
delete: `/api/${modelType}/delete`, delete: `/api/${modelType}/delete`,
exclude: `/api/${modelType}/exclude`, exclude: `/api/${modelType}/exclude`,
rename: `/api/${modelType}/rename`, rename: `/api/${modelType}/rename`,
@@ -83,6 +83,8 @@ export function getApiEndpoints(modelType) {
baseModels: `/api/${modelType}/base-models`, baseModels: `/api/${modelType}/base-models`,
roots: `/api/${modelType}/roots`, roots: `/api/${modelType}/roots`,
folders: `/api/${modelType}/folders`, folders: `/api/${modelType}/folders`,
folderTree: `/api/${modelType}/folder-tree`,
unifiedFolderTree: `/api/${modelType}/unified-folder-tree`,
duplicates: `/api/${modelType}/find-duplicates`, duplicates: `/api/${modelType}/find-duplicates`,
conflicts: `/api/${modelType}/find-filename-conflicts`, conflicts: `/api/${modelType}/find-filename-conflicts`,
verify: `/api/${modelType}/verify-duplicates`, verify: `/api/${modelType}/verify-duplicates`,

View File

@@ -8,7 +8,7 @@ import {
DOWNLOAD_ENDPOINTS, DOWNLOAD_ENDPOINTS,
WS_ENDPOINTS WS_ENDPOINTS
} from './apiConfig.js'; } from './apiConfig.js';
import { createModelApiClient } from './modelApiFactory.js'; import { resetAndReload } from './modelApiFactory.js';
/** /**
* Abstract base class for all model API clients * Abstract base class for all model API clients
@@ -91,10 +91,7 @@ export class BaseModelApiClient {
pageState.currentPage = 1; // Reset to first page pageState.currentPage = 1; // Reset to first page
} }
const startTime = performance.now();
const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize); const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize);
const endTime = performance.now();
console.log(`fetchModelsPage耗时: ${(endTime - startTime).toFixed(2)} ms`);
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
@@ -105,8 +102,16 @@ export class BaseModelApiClient {
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = pageState.currentPage + 1; pageState.currentPage = pageState.currentPage + 1;
if (updateFolders && result.folders) { if (updateFolders) {
updateFolderTags(result.folders); const response = await fetch(this.apiConfig.endpoints.folders);
if (response.ok) {
const data = await response.json();
updateFolderTags(data.folders);
} else {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData && errorData.error ? errorData.error : response.statusText;
console.error(`Error getting folders: ${errorMsg}`);
}
} }
return result; return result;
@@ -321,6 +326,8 @@ export class BaseModelApiClient {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
} }
resetAndReload(true);
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
} catch (error) { } catch (error) {
@@ -579,7 +586,34 @@ export class BaseModelApiClient {
} }
} }
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { async fetchUnifiedFolderTree() {
try {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error(`Failed to fetch unified folder tree`);
}
return await response.json();
} catch (error) {
console.error('Error fetching unified folder tree:', error);
throw error;
}
}
async fetchFolderTree(modelRoot) {
try {
const params = new URLSearchParams({ model_root: modelRoot });
const response = await fetch(`${this.apiConfig.endpoints.folderTree}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch folder tree for root: ${modelRoot}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching folder tree:', error);
throw error;
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId) {
try { try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, { const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST', method: 'POST',
@@ -589,6 +623,7 @@ export class BaseModelApiClient {
model_version_id: versionId, model_version_id: versionId,
model_root: modelRoot, model_root: modelRoot,
relative_path: relativePath, relative_path: relativePath,
use_default_paths: useDefaultPaths,
download_id: downloadId download_id: downloadId
}) })
}); });
@@ -629,6 +664,9 @@ export class BaseModelApiClient {
if (pageState.searchOptions.tags !== undefined) { if (pageState.searchOptions.tags !== undefined) {
params.append('search_tags', pageState.searchOptions.tags.toString()); params.append('search_tags', pageState.searchOptions.tags.toString());
} }
if (pageState.searchOptions.creator !== undefined) {
params.append('search_creator', pageState.searchOptions.creator.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString()); params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
} }
} }

View File

@@ -17,16 +17,16 @@ export function createModelApiClient(modelType) {
} }
} }
let _singletonClient = null; let _singletonClients = new Map();
export function getModelApiClient() { export function getModelApiClient(modelType = null) {
const currentType = state.currentPageType; const targetType = modelType || state.currentPageType;
if (!_singletonClient || _singletonClient.modelType !== currentType) { if (!_singletonClients.has(targetType)) {
_singletonClient = createModelApiClient(currentType); _singletonClients.set(targetType, createModelApiClient(targetType));
} }
return _singletonClient; return _singletonClients.get(targetType);
} }
export function resetAndReload(updateFolders = false) { export function resetAndReload(updateFolders = false) {

View File

@@ -0,0 +1,585 @@
/**
* FolderTreeManager - Manages folder tree UI for download modal
*/
export class FolderTreeManager {
constructor() {
this.treeData = {};
this.selectedPath = '';
this.expandedNodes = new Set();
this.pathSuggestions = [];
this.onPathChangeCallback = null;
this.activeSuggestionIndex = -1;
this.elementsPrefix = '';
// Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this);
this.handlePathInput = this.handlePathInput.bind(this);
this.handlePathSuggestionClick = this.handlePathSuggestionClick.bind(this);
this.handleCreateFolder = this.handleCreateFolder.bind(this);
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handlePathKeyDown = this.handlePathKeyDown.bind(this);
}
/**
* Initialize the folder tree manager
* @param {Object} config - Configuration object
* @param {Function} config.onPathChange - Callback when path changes
* @param {string} config.elementsPrefix - Prefix for element IDs (e.g., 'move' for move modal)
*/
init(config = {}) {
this.onPathChangeCallback = config.onPathChange;
this.elementsPrefix = config.elementsPrefix || '';
this.setupEventHandlers();
}
setupEventHandlers() {
const pathInput = document.getElementById(this.getElementId('folderPath'));
const createFolderBtn = document.getElementById(this.getElementId('createFolderBtn'));
const folderTree = document.getElementById(this.getElementId('folderTree'));
const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav'));
const pathSuggestions = document.getElementById(this.getElementId('pathSuggestions'));
if (pathInput) {
pathInput.addEventListener('input', this.handlePathInput);
pathInput.addEventListener('keydown', this.handlePathKeyDown);
}
if (createFolderBtn) {
createFolderBtn.addEventListener('click', this.handleCreateFolder);
}
if (folderTree) {
folderTree.addEventListener('click', this.handleTreeClick);
}
if (breadcrumbNav) {
breadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
}
if (pathSuggestions) {
pathSuggestions.addEventListener('click', this.handlePathSuggestionClick);
}
// Hide suggestions when clicking outside
document.addEventListener('click', (e) => {
const pathInput = document.getElementById(this.getElementId('folderPath'));
const suggestions = document.getElementById(this.getElementId('pathSuggestions'));
if (pathInput && suggestions &&
!pathInput.contains(e.target) &&
!suggestions.contains(e.target)) {
suggestions.style.display = 'none';
this.activeSuggestionIndex = -1;
}
});
}
/**
* Get element ID with prefix
*/
getElementId(elementName) {
return this.elementsPrefix ? `${this.elementsPrefix}${elementName.charAt(0).toUpperCase()}${elementName.slice(1)}` : elementName;
}
/**
* Handle path input key events with enhanced keyboard navigation
*/
handlePathKeyDown(event) {
const suggestions = document.getElementById(this.getElementId('pathSuggestions'));
const isVisible = suggestions && suggestions.style.display !== 'none';
if (isVisible) {
const suggestionItems = suggestions.querySelectorAll('.path-suggestion');
const maxIndex = suggestionItems.length - 1;
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.hideSuggestions();
this.activeSuggestionIndex = -1;
break;
case 'ArrowDown':
event.preventDefault();
this.activeSuggestionIndex = Math.min(this.activeSuggestionIndex + 1, maxIndex);
this.updateActiveSuggestion(suggestionItems);
break;
case 'ArrowUp':
event.preventDefault();
this.activeSuggestionIndex = Math.max(this.activeSuggestionIndex - 1, -1);
this.updateActiveSuggestion(suggestionItems);
break;
case 'Enter':
event.preventDefault();
if (this.activeSuggestionIndex >= 0 && suggestionItems[this.activeSuggestionIndex]) {
const path = suggestionItems[this.activeSuggestionIndex].dataset.path;
this.selectPath(path);
this.hideSuggestions();
} else {
this.selectCurrentInput();
}
break;
}
} else if (event.key === 'Enter') {
event.preventDefault();
this.selectCurrentInput();
}
}
/**
* Update active suggestion highlighting
*/
updateActiveSuggestion(suggestionItems) {
suggestionItems.forEach((item, index) => {
item.classList.toggle('active', index === this.activeSuggestionIndex);
if (index === this.activeSuggestionIndex) {
item.scrollIntoView({ block: 'nearest' });
}
});
}
/**
* Load and render folder tree data
* @param {Object} treeData - Hierarchical tree data
*/
async loadTree(treeData) {
this.treeData = treeData;
this.pathSuggestions = this.extractAllPaths(treeData);
this.renderTree();
}
/**
* Extract all paths from tree data for autocomplete
*/
extractAllPaths(treeData, currentPath = '') {
const paths = [];
for (const [folderName, children] of Object.entries(treeData)) {
const newPath = currentPath ? `${currentPath}/${folderName}` : folderName;
paths.push(newPath);
if (Object.keys(children).length > 0) {
paths.push(...this.extractAllPaths(children, newPath));
}
}
return paths.sort();
}
/**
* Render the complete folder tree
*/
renderTree() {
const folderTree = document.getElementById(this.getElementId('folderTree'));
if (!folderTree) return;
// Show placeholder if treeData is empty
if (!this.treeData || Object.keys(this.treeData).length === 0) {
folderTree.innerHTML = `
<div class="folder-tree-placeholder" style="padding:24px;text-align:center;color:var(--text-color);opacity:0.7;">
<i class="fas fa-folder-open" style="font-size:2em;opacity:0.5;"></i>
<div>No folders found.<br/>You can create a new folder using the button above.</div>
</div>
`;
return;
}
folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
}
/**
* Render a single tree node
*/
renderTreeNode(nodeData, basePath) {
const entries = Object.entries(nodeData);
if (entries.length === 0) return '';
return entries.map(([folderName, children]) => {
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
const hasChildren = Object.keys(children).length > 0;
const isExpanded = this.expandedNodes.has(currentPath);
const isSelected = this.selectedPath === currentPath;
return `
<div class="tree-node ${hasChildren ? 'has-children' : ''}" data-path="${currentPath}">
<div class="tree-node-content ${isSelected ? 'selected' : ''}">
<div class="tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i>
</div>
<div class="tree-folder-icon">
<i class="fas fa-folder"></i>
</div>
<div class="tree-folder-name">${folderName}</div>
</div>
${hasChildren ? `
<div class="tree-children ${isExpanded ? 'expanded' : ''}">
${this.renderTreeNode(children, currentPath)}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* Handle tree node clicks
*/
handleTreeClick(event) {
const expandIcon = event.target.closest('.tree-expand-icon');
const nodeContent = event.target.closest('.tree-node-content');
if (expandIcon) {
// Toggle expand/collapse
const treeNode = expandIcon.closest('.tree-node');
const path = treeNode.dataset.path;
const children = treeNode.querySelector('.tree-children');
if (this.expandedNodes.has(path)) {
this.expandedNodes.delete(path);
expandIcon.classList.remove('expanded');
if (children) children.classList.remove('expanded');
} else {
this.expandedNodes.add(path);
expandIcon.classList.add('expanded');
if (children) children.classList.add('expanded');
}
} else if (nodeContent) {
// Select folder
const treeNode = nodeContent.closest('.tree-node');
const path = treeNode.dataset.path;
this.selectPath(path);
}
}
/**
* Handle path input changes
*/
handlePathInput(event) {
const input = event.target;
const query = input.value.toLowerCase();
this.activeSuggestionIndex = -1; // Reset active suggestion
if (query.length === 0) {
this.hideSuggestions();
return;
}
const matches = this.pathSuggestions.filter(path =>
path.toLowerCase().includes(query)
).slice(0, 10); // Limit to 10 suggestions
this.showSuggestions(matches, query);
}
/**
* Show path suggestions
*/
showSuggestions(suggestions, query) {
const suggestionsEl = document.getElementById(this.getElementId('pathSuggestions'));
if (!suggestionsEl) return;
if (suggestions.length === 0) {
this.hideSuggestions();
return;
}
suggestionsEl.innerHTML = suggestions.map(path => {
const highlighted = this.highlightMatch(path, query);
return `<div class="path-suggestion" data-path="${path}">${highlighted}</div>`;
}).join('');
suggestionsEl.style.display = 'block';
this.activeSuggestionIndex = -1; // Reset active index
}
/**
* Hide path suggestions
*/
hideSuggestions() {
const suggestionsEl = document.getElementById(this.getElementId('pathSuggestions'));
if (suggestionsEl) {
suggestionsEl.style.display = 'none';
}
}
/**
* Highlight matching text in suggestions
*/
highlightMatch(text, query) {
const index = text.toLowerCase().indexOf(query.toLowerCase());
if (index === -1) return text;
return text.substring(0, index) +
`<strong>${text.substring(index, index + query.length)}</strong>` +
text.substring(index + query.length);
}
/**
* Handle suggestion clicks
*/
handlePathSuggestionClick(event) {
const suggestion = event.target.closest('.path-suggestion');
if (suggestion) {
const path = suggestion.dataset.path;
this.selectPath(path);
this.hideSuggestions();
}
}
/**
* Handle create folder button click
*/
handleCreateFolder() {
const currentPath = this.selectedPath;
this.showCreateFolderForm(currentPath);
}
/**
* Show inline create folder form
*/
showCreateFolderForm(parentPath) {
// Find the parent node in the tree
const parentNode = parentPath ?
document.querySelector(`[data-path="${parentPath}"]`) :
document.getElementById(this.getElementId('folderTree'));
if (!parentNode) return;
// Check if form already exists
if (parentNode.querySelector('.create-folder-form')) return;
const form = document.createElement('div');
form.className = 'create-folder-form';
form.innerHTML = `
<input type="text" placeholder="New folder name" class="new-folder-input" />
<button type="button" class="confirm">✓</button>
<button type="button" class="cancel">✗</button>
`;
const input = form.querySelector('.new-folder-input');
const confirmBtn = form.querySelector('.confirm');
const cancelBtn = form.querySelector('.cancel');
confirmBtn.addEventListener('click', () => {
const folderName = input.value.trim();
if (folderName) {
this.createFolder(parentPath, folderName);
}
form.remove();
});
cancelBtn.addEventListener('click', () => {
form.remove();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
confirmBtn.click();
} else if (e.key === 'Escape') {
cancelBtn.click();
}
});
if (parentPath) {
// Add to children area
const childrenEl = parentNode.querySelector('.tree-children');
if (childrenEl) {
childrenEl.appendChild(form);
} else {
parentNode.appendChild(form);
}
} else {
// Add to root
parentNode.appendChild(form);
}
input.focus();
}
/**
* Create new folder
*/
createFolder(parentPath, folderName) {
const newPath = parentPath ? `${parentPath}/${folderName}` : folderName;
// Add to tree data
const pathParts = newPath.split('/');
let current = this.treeData;
for (const part of pathParts) {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Update suggestions
this.pathSuggestions = this.extractAllPaths(this.treeData);
// Expand parent if needed
if (parentPath) {
this.expandedNodes.add(parentPath);
}
// Re-render tree
this.renderTree();
// Select the new folder
this.selectPath(newPath);
}
/**
* Handle breadcrumb navigation clicks
*/
handleBreadcrumbClick(event) {
const breadcrumbItem = event.target.closest('.breadcrumb-item');
if (breadcrumbItem) {
const path = breadcrumbItem.dataset.path;
this.selectPath(path);
}
}
/**
* Select a path and update UI
*/
selectPath(path) {
this.selectedPath = path;
// Update path input
const pathInput = document.getElementById(this.getElementId('folderPath'));
if (pathInput) {
pathInput.value = path;
}
// Update tree selection
const treeContainer = document.getElementById(this.getElementId('folderTree'));
if (treeContainer) {
treeContainer.querySelectorAll('.tree-node-content').forEach(node => {
node.classList.remove('selected');
});
const selectedNode = treeContainer.querySelector(`[data-path="${path}"] .tree-node-content`);
if (selectedNode) {
selectedNode.classList.add('selected');
// Expand parents to show selection
this.expandPathParents(path);
}
}
// Update breadcrumbs
this.updateBreadcrumbs(path);
// Trigger callback
if (this.onPathChangeCallback) {
this.onPathChangeCallback(path);
}
}
/**
* Expand all parent nodes of a given path
*/
expandPathParents(path) {
const parts = path.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
this.expandedNodes.add(currentPath);
}
this.renderTree();
}
/**
* Update breadcrumb navigation
*/
updateBreadcrumbs(path) {
const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav'));
if (!breadcrumbNav) return;
const parts = path ? path.split('/') : [];
let currentPath = '';
const breadcrumbs = [`
<span class="breadcrumb-item ${!path ? 'active' : ''}" data-path="">
<i class="fas fa-home"></i> Root
</span>
`];
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLast = index === parts.length - 1;
if (index > 0) {
breadcrumbs.push(`<span class="breadcrumb-separator">/</span>`);
}
breadcrumbs.push(`
<span class="breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
${part}
</span>
`);
});
breadcrumbNav.innerHTML = breadcrumbs.join('');
}
/**
* Select current input value as path
*/
selectCurrentInput() {
const pathInput = document.getElementById(this.getElementId('folderPath'));
if (pathInput) {
const path = pathInput.value.trim();
this.selectPath(path);
}
}
/**
* Get the currently selected path
*/
getSelectedPath() {
return this.selectedPath;
}
/**
* Clear selection
*/
clearSelection() {
this.selectPath('');
}
/**
* Clean up event handlers
*/
destroy() {
const pathInput = document.getElementById(this.getElementId('folderPath'));
const createFolderBtn = document.getElementById(this.getElementId('createFolderBtn'));
const folderTree = document.getElementById(this.getElementId('folderTree'));
const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav'));
const pathSuggestions = document.getElementById(this.getElementId('pathSuggestions'));
if (pathInput) {
pathInput.removeEventListener('input', this.handlePathInput);
pathInput.removeEventListener('keydown', this.handlePathKeyDown);
}
if (createFolderBtn) {
createFolderBtn.removeEventListener('click', this.handleCreateFolder);
}
if (folderTree) {
folderTree.removeEventListener('click', this.handleTreeClick);
}
if (breadcrumbNav) {
breadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
}
if (pathSuggestions) {
pathSuggestions.removeEventListener('click', this.handlePathSuggestionClick);
}
}
}

View File

@@ -16,7 +16,9 @@ export class HeaderManager {
this.filterManager = null; this.filterManager = null;
// Initialize appropriate managers based on current page // Initialize appropriate managers based on current page
this.initializeManagers(); if (this.currentPage !== 'statistics') {
this.initializeManagers();
}
// Set up common header functionality // Set up common header functionality
this.initializeCommonElements(); this.initializeCommonElements();
@@ -37,11 +39,8 @@ export class HeaderManager {
this.searchManager = new SearchManager({ page: this.currentPage }); this.searchManager = new SearchManager({ page: this.currentPage });
window.searchManager = this.searchManager; window.searchManager = this.searchManager;
// Initialize FilterManager for all page types that have filters this.filterManager = new FilterManager({ page: this.currentPage });
if (document.getElementById('filterButton')) { window.filterManager = this.filterManager;
this.filterManager = new FilterManager({ page: this.currentPage });
window.filterManager = this.filterManager;
}
} }
initializeCommonElements() { initializeCommonElements() {

View File

@@ -2,7 +2,6 @@
import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { updateRecipeCard } from '../utils/cardUpdater.js';
import { updateRecipeMetadata } from '../api/recipeApi.js'; import { updateRecipeMetadata } from '../api/recipeApi.js';
class RecipeModal { class RecipeModal {
@@ -879,7 +878,7 @@ class RecipeModal {
// Model identifiers // Model identifiers
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash, hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
modelVersionId: civitaiInfo.id || lora.modelVersionId, id: civitaiInfo.id || lora.modelVersionId,
// Metadata // Metadata
thumbnailUrl: civitaiInfo.images?.[0]?.url || '', thumbnailUrl: civitaiInfo.images?.[0]?.url || '',

View File

@@ -241,7 +241,7 @@ function showModelModalFromCard(card, modelType) {
tags: JSON.parse(card.dataset.tags || '[]'), tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || '', modelDescription: card.dataset.modelDescription || '',
// LoRA specific fields // LoRA specific fields
...(modelType === 'lora' && { ...(modelType === MODEL_TYPES.LORA && {
usage_tips: card.dataset.usage_tips, usage_tips: card.dataset.usage_tips,
}) })
}; };

View File

@@ -183,7 +183,7 @@ export function setupBaseModelEditing(filePath) {
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1, BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM, BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.UNKNOWN BASE_MODELS.QWEN, BASE_MODELS.UNKNOWN
] ]
}; };
@@ -426,4 +426,4 @@ export function setupFileNameEditing(filePath) {
fileNameWrapper.classList.remove('editing'); fileNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible'); editBtn.classList.remove('visible');
} }
} }

View File

@@ -62,6 +62,12 @@ class BannerService {
*/ */
registerBanner(id, bannerConfig) { registerBanner(id, bannerConfig) {
this.banners.set(id, bannerConfig); this.banners.set(id, bannerConfig);
// If already initialized, render the banner immediately
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
this.renderBanner(bannerConfig);
this.updateContainerVisibility();
}
} }
/** /**
@@ -88,6 +94,12 @@ class BannerService {
// Remove banner from DOM // Remove banner from DOM
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`); const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) { if (bannerElement) {
// Call onRemove callback if provided
const banner = this.banners.get(bannerId);
if (banner && typeof banner.onRemove === 'function') {
banner.onRemove(bannerElement);
}
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards'; bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => { setTimeout(() => {
bannerElement.remove(); bannerElement.remove();
@@ -122,12 +134,16 @@ class BannerService {
bannerElement.className = 'banner-item'; bannerElement.className = 'banner-item';
bannerElement.setAttribute('data-banner-id', banner.id); bannerElement.setAttribute('data-banner-id', banner.id);
const actionsHtml = banner.actions ? banner.actions.map(action => const actionsHtml = banner.actions ? banner.actions.map(action => {
`<a href="${action.url}" target="_blank" class="banner-action banner-action-${action.type}" rel="noopener noreferrer"> const actionAttribute = action.action ? `data-action="${action.action}"` : '';
const href = action.url ? `href="${action.url}"` : '#';
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
return `<a ${href ? `href="${href}"` : ''} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
<i class="${action.icon}"></i> <i class="${action.icon}"></i>
<span>${action.text}</span> <span>${action.text}</span>
</a>` </a>`;
).join('') : ''; }).join('') : '';
const dismissButtonHtml = banner.dismissible ? const dismissButtonHtml = banner.dismissible ?
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss"> `<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss">
@@ -148,6 +164,11 @@ class BannerService {
`; `;
this.container.appendChild(bannerElement); this.container.appendChild(bannerElement);
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement);
}
} }
/** /**

View File

@@ -1,8 +1,10 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js'; import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
export class DownloadManager { export class DownloadManager {
constructor() { constructor() {
@@ -15,8 +17,10 @@ export class DownloadManager {
this.initialized = false; this.initialized = false;
this.selectedFolder = ''; this.selectedFolder = '';
this.apiClient = null; this.apiClient = null;
this.useDefaultPath = false;
this.loadingManager = new LoadingManager(); this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null; this.folderClickHandler = null;
this.updateTargetPath = this.updateTargetPath.bind(this); this.updateTargetPath = this.updateTargetPath.bind(this);
@@ -27,6 +31,7 @@ export class DownloadManager {
this.handleBackToUrl = this.backToUrl.bind(this); this.handleBackToUrl = this.backToUrl.bind(this);
this.handleBackToVersions = this.backToVersions.bind(this); this.handleBackToVersions = this.backToVersions.bind(this);
this.handleCloseModal = this.closeModal.bind(this); this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
} }
showDownloadModal() { showDownloadModal() {
@@ -71,6 +76,9 @@ export class DownloadManager {
document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl);
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
} }
updateModalLabels() { updateModalLabels() {
@@ -106,9 +114,10 @@ export class DownloadManager {
document.getElementById('modelUrl').value = ''; document.getElementById('modelUrl').value = '';
document.getElementById('urlError').textContent = ''; document.getElementById('urlError').textContent = '';
const newFolderInput = document.getElementById('newFolder'); // Clear folder path input
if (newFolderInput) { const folderPathInput = document.getElementById('folderPath');
newFolderInput.value = ''; if (folderPathInput) {
folderPathInput.value = '';
} }
this.currentVersion = null; this.currentVersion = null;
@@ -118,11 +127,14 @@ export class DownloadManager {
this.modelVersionId = null; this.modelVersionId = null;
this.selectedFolder = ''; this.selectedFolder = '';
const folderBrowser = document.getElementById('folderBrowser');
if (folderBrowser) { // Clear folder tree selection
folderBrowser.querySelectorAll('.folder-item').forEach(f => if (this.folderTreeManager) {
f.classList.remove('selected')); this.folderTreeManager.clearSelection();
} }
// Reset default path toggle
this.loadDefaultPathSetting();
} }
async validateAndFetchVersions() { async validateAndFetchVersions() {
@@ -285,8 +297,6 @@ export class DownloadManager {
document.getElementById('locationStep').style.display = 'block'; document.getElementById('locationStep').style.display = 'block';
try { try {
const config = this.apiClient.apiConfig.config;
// Fetch model roots // Fetch model roots
const rootsData = await this.apiClient.fetchModelRoots(); const rootsData = await this.apiClient.fetchModelRoots();
const modelRoot = document.getElementById('modelRoot'); const modelRoot = document.getElementById('modelRoot');
@@ -295,26 +305,96 @@ export class DownloadManager {
).join(''); ).join('');
// Set default root if available // Set default root if available
const defaultRootKey = `default_${this.apiClient.modelType}_root`; const singularType = this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
const defaultRoot = getStorageItem('settings', {})[defaultRootKey]; const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
console.log('Available roots:', rootsData.roots);
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
console.log(`Setting default root: ${defaultRoot}`);
modelRoot.value = defaultRoot; modelRoot.value = defaultRoot;
} }
// Fetch folders // Set autocomplete="off" on folderPath input
const foldersData = await this.apiClient.fetchModelFolders(); const folderPathInput = document.getElementById('folderPath');
const folderBrowser = document.getElementById('folderBrowser'); if (folderPathInput) {
folderPathInput.setAttribute('autocomplete', 'off');
folderBrowser.innerHTML = foldersData.folders.map(folder => }
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
).join('');
this.initializeFolderBrowser(); // Initialize folder tree
await this.initializeFolderTree();
// Setup folder tree manager
this.folderTreeManager.init({
onPathChange: (path) => {
this.selectedFolder = path;
this.updateTargetPath();
}
});
// Setup model root change handler
modelRoot.addEventListener('change', async () => {
await this.initializeFolderTree();
this.updateTargetPath();
});
// Load default path setting for current model type
this.loadDefaultPathSetting();
this.updateTargetPath();
} catch (error) { } catch (error) {
showToast(error.message, 'error'); showToast(error.message, 'error');
} }
} }
loadDefaultPathSetting() {
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
this.useDefaultPath = getStorageItem(storageKey, false);
const toggleInput = document.getElementById('useDefaultPath');
if (toggleInput) {
toggleInput.checked = this.useDefaultPath;
this.updatePathSelectionUI();
}
}
toggleDefaultPath(event) {
this.useDefaultPath = event.target.checked;
// Save to localStorage per model type
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
setStorageItem(storageKey, this.useDefaultPath);
this.updatePathSelectionUI();
this.updateTargetPath();
}
updatePathSelectionUI() {
const manualSelection = document.getElementById('manualPathSelection');
// Always show manual path selection, but disable/enable based on useDefaultPath
manualSelection.style.display = 'block';
if (this.useDefaultPath) {
manualSelection.classList.add('disabled');
// Disable all inputs and buttons inside manualSelection
manualSelection.querySelectorAll('input, select, button').forEach(el => {
el.disabled = true;
el.tabIndex = -1;
});
} else {
manualSelection.classList.remove('disabled');
manualSelection.querySelectorAll('input, select, button').forEach(el => {
el.disabled = false;
el.tabIndex = 0;
});
}
// Always update the main path display
this.updateTargetPath();
}
backToUrl() { backToUrl() {
document.getElementById('versionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block'; document.getElementById('urlStep').style.display = 'block';
@@ -326,12 +406,15 @@ export class DownloadManager {
} }
closeModal() { closeModal() {
// Clean up folder tree manager
if (this.folderTreeManager) {
this.folderTreeManager.destroy();
}
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
} }
async startDownload() { async startDownload() {
const modelRoot = document.getElementById('modelRoot').value; const modelRoot = document.getElementById('modelRoot').value;
const newFolder = document.getElementById('newFolder').value.trim();
const config = this.apiClient.apiConfig.config; const config = this.apiClient.apiConfig.config;
if (!modelRoot) { if (!modelRoot) {
@@ -339,14 +422,15 @@ export class DownloadManager {
return; return;
} }
// Construct relative path // Determine target folder and use_default_paths parameter
let targetFolder = ''; let targetFolder = '';
if (this.selectedFolder) { let useDefaultPaths = false;
targetFolder = this.selectedFolder;
} if (this.useDefaultPath) {
if (newFolder) { useDefaultPaths = true;
targetFolder = targetFolder ? targetFolder = ''; // Not needed when using default paths
`${targetFolder}/${newFolder}` : newFolder; } else {
targetFolder = this.folderTreeManager.getSelectedPath();
} }
try { try {
@@ -386,12 +470,13 @@ export class DownloadManager {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
// Start download // Start download with use_default_paths parameter
await this.apiClient.downloadModel( await this.apiClient.downloadModel(
this.modelId, this.modelId,
this.currentVersion.id, this.currentVersion.id,
modelRoot, modelRoot,
targetFolder, targetFolder,
useDefaultPaths,
downloadId downloadId
); );
@@ -402,19 +487,22 @@ export class DownloadManager {
// Update state and trigger reload // Update state and trigger reload
const pageState = this.apiClient.getPageState(); const pageState = this.apiClient.getPageState();
pageState.activeFolder = targetFolder;
// Save the active folder preference if (!useDefaultPaths) {
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); pageState.activeFolder = targetFolder;
// Update UI folder selection // Save the active folder preference
document.querySelectorAll('.folder-tags .tag').forEach(tag => { setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive); // Update UI folder selection
if (isActive && !tag.parentNode.classList.contains('collapsed')) { document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); const isActive = tag.dataset.folder === targetFolder;
} tag.classList.toggle('active', isActive);
}); if (isActive && !tag.parentNode.classList.contains('collapsed')) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
await resetAndReload(true); await resetAndReload(true);
@@ -425,6 +513,24 @@ export class DownloadManager {
} }
} }
async initializeFolderTree() {
try {
// Fetch unified folder tree
const treeData = await this.apiClient.fetchUnifiedFolderTree();
if (treeData.success) {
// Load tree data into folder tree manager
await this.folderTreeManager.loadTree(treeData.tree);
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
}
}
initializeFolderBrowser() { initializeFolderBrowser() {
const folderBrowser = document.getElementById('folderBrowser'); const folderBrowser = document.getElementById('folderBrowser');
if (!folderBrowser) return; if (!folderBrowser) return;
@@ -478,17 +584,28 @@ export class DownloadManager {
updateTargetPath() { updateTargetPath() {
const pathDisplay = document.getElementById('targetPathDisplay'); const pathDisplay = document.getElementById('targetPathDisplay');
const modelRoot = document.getElementById('modelRoot').value; const modelRoot = document.getElementById('modelRoot').value;
const newFolder = document.getElementById('newFolder').value.trim();
const config = this.apiClient.apiConfig.config; const config = this.apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName} root directory`; let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
if (modelRoot) { if (modelRoot) {
if (this.selectedFolder) { if (this.useDefaultPath) {
fullPath += '/' + this.selectedFolder; // Show actual template path
} try {
if (newFolder) { const singularType = this.apiClient.modelType.replace(/s$/, '');
fullPath += '/' + newFolder; const templates = state.global.settings.download_path_templates;
const template = templates[singularType];
fullPath += `/${template}`;
} catch (error) {
console.error('Failed to fetch template:', error);
fullPath += '/[Auto-organized by path template]';
}
} else {
// Show manual path selection
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
if (selectedPath) {
fullPath += '/' + selectedPath;
}
} }
} }

View File

@@ -763,22 +763,7 @@ class ExampleImagesManager {
const data = await response.json(); const data = await response.json();
if (data.success) { if (!data.success) {
// Only show progress if there are actually items to download
if (data.status && data.status.total > 0) {
this.isDownloading = true;
this.isPaused = false;
this.hasShownCompletionToast = false;
this.startTime = new Date();
this.updateUI(data.status);
this.showProgressPanel();
this.startProgressUpdates();
this.updateDownloadButtonText();
console.log(`Auto download started: ${data.status.total} items to process`);
} else {
console.log('Auto download check completed - no new items to download');
}
} else {
console.warn('Auto download check failed:', data.error); console.warn('Auto download check failed:', data.error);
} }
} catch (error) { } catch (error) {

View File

@@ -4,48 +4,31 @@ import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js'; import { bulkManager } from './BulkManager.js';
import { getStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
class MoveManager { class MoveManager {
constructor() { constructor() {
this.currentFilePath = null; this.currentFilePath = null;
this.bulkFilePaths = null; this.bulkFilePaths = null;
this.modal = document.getElementById('moveModal'); this.folderTreeManager = new FolderTreeManager();
this.modelRootSelect = document.getElementById('moveModelRoot'); this.initialized = false;
this.folderBrowser = document.getElementById('moveFolderBrowser');
this.newFolderInput = document.getElementById('moveNewFolder'); // Bind methods
this.pathDisplay = document.getElementById('moveTargetPathDisplay'); this.updateTargetPath = this.updateTargetPath.bind(this);
this.modalTitle = document.getElementById('moveModalTitle');
this.rootLabel = document.getElementById('moveRootLabel');
this.initializeEventListeners();
} }
initializeEventListeners() { initializeEventListeners() {
if (this.initialized) return;
const modelRootSelect = document.getElementById('moveModelRoot');
// Initialize model root directory selector // Initialize model root directory selector
this.modelRootSelect.addEventListener('change', () => this.updatePathPreview()); modelRootSelect.addEventListener('change', async () => {
await this.initializeFolderTree();
// Folder selection event this.updateTargetPath();
this.folderBrowser.addEventListener('click', (e) => {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return;
// If clicking already selected folder, deselect it
if (folderItem.classList.contains('selected')) {
folderItem.classList.remove('selected');
} else {
// Deselect other folders
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
item.classList.remove('selected');
});
// Select current folder
folderItem.classList.add('selected');
}
this.updatePathPreview();
}); });
// New folder input event this.initialized = true;
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
} }
async showMoveModal(filePath, modelType = null) { async showMoveModal(filePath, modelType = null) {
@@ -65,31 +48,30 @@ class MoveManager {
return; return;
} }
this.bulkFilePaths = selectedPaths; this.bulkFilePaths = selectedPaths;
this.modalTitle.textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`; document.getElementById('moveModalTitle').textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
} else { } else {
// Single file mode // Single file mode
this.currentFilePath = filePath; this.currentFilePath = filePath;
this.modalTitle.textContent = `Move ${modelConfig.displayName}`; document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`;
} }
// Update UI labels based on model type // Update UI labels based on model type
this.rootLabel.textContent = `Select ${modelConfig.displayName} Root:`; document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`;
this.pathDisplay.querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`; document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`;
// Clear previous selections // Clear folder path input
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => { const folderPathInput = document.getElementById('moveFolderPath');
item.classList.remove('selected'); if (folderPathInput) {
}); folderPathInput.value = '';
this.newFolderInput.value = ''; }
try { try {
// Fetch model roots // Fetch model roots
const modelRootSelect = document.getElementById('moveModelRoot');
let rootsData; let rootsData;
if (modelType) { if (modelType) {
// For checkpoints, use the specific API method that considers modelType
rootsData = await apiClient.fetchModelRoots(modelType); rootsData = await apiClient.fetchModelRoots(modelType);
} else { } else {
// For other model types, use the generic method
rootsData = await apiClient.fetchModelRoots(); rootsData = await apiClient.fetchModelRoots();
} }
@@ -98,27 +80,38 @@ class MoveManager {
} }
// Populate model root selector // Populate model root selector
this.modelRootSelect.innerHTML = rootsData.roots.map(root => modelRootSelect.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>` `<option value="${root}">${root}</option>`
).join(''); ).join('');
// Set default root if available // Set default root if available
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; // Remove 's' from plural const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
const defaultRoot = getStorageItem('settings', {})[settingsKey]; const defaultRoot = getStorageItem('settings', {})[settingsKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
this.modelRootSelect.value = defaultRoot; modelRootSelect.value = defaultRoot;
} }
// Fetch folders dynamically // Initialize event listeners
const foldersData = await apiClient.fetchModelFolders(); this.initializeEventListeners();
// Update folder browser with dynamic content // Setup folder tree manager
this.folderBrowser.innerHTML = foldersData.folders.map(folder => this.folderTreeManager.init({
`<div class="folder-item" data-folder="${folder}">${folder}</div>` onPathChange: (path) => {
).join(''); this.updateTargetPath();
},
elementsPrefix: 'move'
});
// Initialize folder tree
await this.initializeFolderTree();
this.updatePathPreview(); this.updateTargetPath();
modalManager.showModal('moveModal'); modalManager.showModal('moveModal', null, () => {
// Cleanup on modal close
if (this.folderTreeManager) {
this.folderTreeManager.destroy();
}
});
} catch (error) { } catch (error) {
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error); console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
@@ -126,36 +119,60 @@ class MoveManager {
} }
} }
updatePathPreview() { async initializeFolderTree() {
const selectedRoot = this.modelRootSelect.value; try {
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || ''; const apiClient = getModelApiClient();
const newFolder = this.newFolderInput.value.trim(); // Fetch unified folder tree
const treeData = await apiClient.fetchUnifiedFolderTree();
let targetPath = selectedRoot;
if (selectedFolder) { if (treeData.success) {
targetPath = `${targetPath}/${selectedFolder}`; // Load tree data into folder tree manager
await this.folderTreeManager.loadTree(treeData.tree);
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
} }
if (newFolder) { }
targetPath = `${targetPath}/${newFolder}`;
updateTargetPath() {
const pathDisplay = document.getElementById('moveTargetPathDisplay');
const modelRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient();
const config = apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
if (modelRoot) {
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
if (selectedPath) {
fullPath += '/' + selectedPath;
}
} }
this.pathDisplay.querySelector('.path-text').textContent = targetPath; pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
} }
async moveModel() { async moveModel() {
const selectedRoot = this.modelRootSelect.value; const selectedRoot = document.getElementById('moveModelRoot').value;
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
const newFolder = this.newFolderInput.value.trim();
let targetPath = selectedRoot;
if (selectedFolder) {
targetPath = `${targetPath}/${selectedFolder}`;
}
if (newFolder) {
targetPath = `${targetPath}/${newFolder}`;
}
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
const config = apiClient.apiConfig.config;
if (!selectedRoot) {
showToast(`Please select a ${config.displayName.toLowerCase()} root directory`, 'error');
return;
}
// Get selected folder path from folder tree manager
const targetFolder = this.folderTreeManager.getSelectedPath();
let targetPath = selectedRoot;
if (targetFolder) {
targetPath = `${targetPath}/${targetFolder}`;
}
try { try {
if (this.bulkFilePaths) { if (this.bulkFilePaths) {

View File

@@ -318,6 +318,7 @@ export class SearchManager {
filename: options.filename || false, filename: options.filename || false,
modelname: options.modelname || false, modelname: options.modelname || false,
tags: options.tags || false, tags: options.tags || false,
creator: options.creator || false,
recursive: recursive recursive: recursive
}; };
} else if (this.currentPage === 'checkpoints') { } else if (this.currentPage === 'checkpoints') {
@@ -325,6 +326,7 @@ export class SearchManager {
filename: options.filename || false, filename: options.filename || false,
modelname: options.modelname || false, modelname: options.modelname || false,
tags: options.tags || false, tags: options.tags || false,
creator: options.creator || false,
recursive: recursive recursive: recursive
}; };
} }

View File

@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js'; import { resetAndReload } from '../api/modelApiFactory.js';
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
@@ -73,11 +73,30 @@ export class SettingsManager {
// We can delete the old setting, but keeping it for backwards compatibility // We can delete the old setting, but keeping it for backwards compatibility
} }
// Set default for download path template if undefined // Migrate legacy download_path_template to new structure
if (state.global.settings.download_path_template === undefined) { if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) {
state.global.settings.download_path_template = DOWNLOAD_PATH_TEMPLATES.BASE_MODEL_TAG.value; const legacyTemplate = state.global.settings.download_path_template;
state.global.settings.download_path_templates = {
lora: legacyTemplate,
checkpoint: legacyTemplate,
embedding: legacyTemplate
};
delete state.global.settings.download_path_template;
setStorageItem('settings', state.global.settings);
} }
// Set default for download path templates if undefined
if (state.global.settings.download_path_templates === undefined) {
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
}
// Ensure all model types have templates
Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => {
if (typeof state.global.settings.download_path_templates[modelType] === 'undefined') {
state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType];
}
});
// Set default for base model path mappings if undefined // Set default for base model path mappings if undefined
if (state.global.settings.base_model_path_mappings === undefined) { if (state.global.settings.base_model_path_mappings === undefined) {
state.global.settings.base_model_path_mappings = {}; state.global.settings.base_model_path_mappings = {};
@@ -105,7 +124,7 @@ export class SettingsManager {
'default_checkpoint_root', 'default_checkpoint_root',
'default_embedding_root', 'default_embedding_root',
'base_model_path_mappings', 'base_model_path_mappings',
'download_path_template' 'download_path_templates'
]; ];
// Build payload for syncing // Build payload for syncing
@@ -113,7 +132,7 @@ export class SettingsManager {
fieldsToSync.forEach(key => { fieldsToSync.forEach(key => {
if (localSettings[key] !== undefined) { if (localSettings[key] !== undefined) {
if (key === 'base_model_path_mappings') { if (key === 'base_model_path_mappings' || key === 'download_path_templates') {
payload[key] = JSON.stringify(localSettings[key]); payload[key] = JSON.stringify(localSettings[key]);
} else { } else {
payload[key] = localSettings[key]; payload[key] = localSettings[key];
@@ -164,6 +183,30 @@ export class SettingsManager {
document.querySelectorAll('.toggle-visibility').forEach(button => { document.querySelectorAll('.toggle-visibility').forEach(button => {
button.addEventListener('click', () => this.toggleInputVisibility(button)); button.addEventListener('click', () => this.toggleInputVisibility(button));
}); });
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (customInput) {
customInput.addEventListener('input', (e) => {
const template = e.target.value;
settingsManager.validateTemplate(modelType, template);
settingsManager.updateTemplatePreview(modelType, template);
});
customInput.addEventListener('blur', (e) => {
const template = e.target.value;
if (settingsManager.validateTemplate(modelType, template)) {
settingsManager.updateTemplate(modelType, template);
}
});
customInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
});
}
});
this.initialized = true; this.initialized = true;
} }
@@ -211,12 +254,8 @@ export class SettingsManager {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false; autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
} }
// Set download path template setting // Load download path templates
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate'); this.loadDownloadPathTemplates();
if (downloadPathTemplateSelect) {
downloadPathTemplateSelect.value = state.global.settings.download_path_template || '';
this.updatePathTemplatePreview();
}
// Set include trigger words setting // Set include trigger words setting
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords'); const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
@@ -529,19 +568,184 @@ export class SettingsManager {
} }
} }
updatePathTemplatePreview() { loadDownloadPathTemplates() {
const templateSelect = document.getElementById('downloadPathTemplate'); const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES;
const previewElement = document.getElementById('pathTemplatePreview');
if (!templateSelect || !previewElement) return;
const template = templateSelect.value;
const templateInfo = Object.values(DOWNLOAD_PATH_TEMPLATES).find(t => t.value === template);
if (templateInfo) { Object.keys(templates).forEach(modelType => {
previewElement.textContent = templateInfo.example; this.loadTemplateForModelType(modelType, templates[modelType]);
previewElement.style.display = 'block'; });
}
loadTemplateForModelType(modelType, template) {
const presetSelect = document.getElementById(`${modelType}TemplatePreset`);
const customRow = document.getElementById(`${modelType}CustomRow`);
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (!presetSelect) return;
// Find matching preset
const matchingPreset = this.findMatchingPreset(template);
if (matchingPreset !== null) {
presetSelect.value = matchingPreset;
if (customRow) customRow.style.display = 'none';
} else { } else {
previewElement.style.display = 'none'; // Custom template
presetSelect.value = 'custom';
if (customRow) customRow.style.display = 'block';
if (customInput) {
customInput.value = template;
this.validateTemplate(modelType, template);
}
}
this.updateTemplatePreview(modelType, template);
}
findMatchingPreset(template) {
const presetValues = Object.values(DOWNLOAD_PATH_TEMPLATES)
.map(t => t.value)
.filter(v => v !== 'custom');
return presetValues.includes(template) ? template : null;
}
updateTemplatePreset(modelType, value) {
const customRow = document.getElementById(`${modelType}CustomRow`);
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (value === 'custom') {
if (customRow) customRow.style.display = 'block';
if (customInput) customInput.focus();
return;
} else {
if (customRow) customRow.style.display = 'none';
}
// Update template
this.updateTemplate(modelType, value);
}
updateTemplate(modelType, template) {
// Validate template if it's custom
if (document.getElementById(`${modelType}TemplatePreset`).value === 'custom') {
if (!this.validateTemplate(modelType, template)) {
return; // Don't save invalid templates
}
}
// Update state
if (!state.global.settings.download_path_templates) {
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
}
state.global.settings.download_path_templates[modelType] = template;
// Update preview
this.updateTemplatePreview(modelType, template);
// Save settings
this.saveDownloadPathTemplates();
}
validateTemplate(modelType, template) {
const validationElement = document.getElementById(`${modelType}Validation`);
if (!validationElement) return true;
// Reset validation state
validationElement.innerHTML = '';
validationElement.className = 'template-validation';
if (!template) {
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid (flat structure)';
validationElement.classList.add('valid');
return true;
}
// Check for invalid characters
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(template)) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
validationElement.classList.add('invalid');
return false;
}
// Check for double slashes
if (template.includes('//')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Double slashes not allowed';
validationElement.classList.add('invalid');
return false;
}
// Check if it starts or ends with slash
if (template.startsWith('/') || template.endsWith('/')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Cannot start or end with slash';
validationElement.classList.add('invalid');
return false;
}
// Extract placeholders
const placeholderRegex = /\{([^}]+)\}/g;
const matches = template.match(placeholderRegex) || [];
// Check for invalid placeholders
const invalidPlaceholders = matches.filter(match =>
!PATH_TEMPLATE_PLACEHOLDERS.includes(match)
);
if (invalidPlaceholders.length > 0) {
validationElement.innerHTML = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid template';
validationElement.classList.add('valid');
return true;
}
updateTemplatePreview(modelType, template) {
const previewElement = document.getElementById(`${modelType}Preview`);
if (!previewElement) return;
if (!template) {
previewElement.textContent = 'model-name.safetensors';
} else {
// Generate example preview
const exampleTemplate = template
.replace('{base_model}', 'Flux.1 D')
.replace('{author}', 'authorname')
.replace('{first_tag}', 'style');
previewElement.textContent = `${exampleTemplate}/model-name.safetensors`;
}
previewElement.style.display = 'block';
}
async saveDownloadPathTemplates() {
try {
// Save to localStorage
setStorageItem('settings', state.global.settings);
// Save to backend
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
download_path_templates: JSON.stringify(state.global.settings.download_path_templates)
})
});
if (!response.ok) {
throw new Error('Failed to save download path templates');
}
showToast('Download path templates updated', 'success');
} catch (error) {
console.error('Error saving download path templates:', error);
showToast('Failed to save download path templates: ' + error.message, 'error');
} }
} }
@@ -651,9 +855,6 @@ export class SettingsManager {
state.global.settings.compactMode = (value !== 'default'); state.global.settings.compactMode = (value !== 'default');
} else if (settingKey === 'card_info_display') { } else if (settingKey === 'card_info_display') {
state.global.settings.cardInfoDisplay = value; state.global.settings.cardInfoDisplay = value;
} else if (settingKey === 'download_path_template') {
state.global.settings.download_path_template = value;
this.updatePathTemplatePreview();
} else { } else {
// For any other settings that might be added in the future // For any other settings that might be added in the future
state.global.settings[settingKey] = value; state.global.settings[settingKey] = value;
@@ -664,9 +865,13 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') { if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') {
const payload = {}; const payload = {};
payload[settingKey] = value; if (settingKey === 'download_path_templates') {
payload[settingKey] = JSON.stringify(state.global.settings.download_path_templates);
} else {
payload[settingKey] = value;
}
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
method: 'POST', method: 'POST',

View File

@@ -1,5 +1,13 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import {
getStorageItem,
setStorageItem,
getStoredVersionInfo,
setStoredVersionInfo,
isVersionMatch,
resetDismissedBanner
} from '../utils/storageHelpers.js';
import { bannerService } from './BannerService.js';
export class UpdateService { export class UpdateService {
constructor() { constructor() {
@@ -17,6 +25,8 @@ export class UpdateService {
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0'); this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
this.isUpdating = false; this.isUpdating = false;
this.nightlyMode = getStorageItem('nightly_updates', false); this.nightlyMode = getStorageItem('nightly_updates', false);
this.currentVersionInfo = null;
this.versionMismatch = false;
} }
initialize() { initialize() {
@@ -59,6 +69,9 @@ export class UpdateService {
// Immediately update modal content with current values (even if from default) // Immediately update modal content with current values (even if from default)
this.updateModalContent(); this.updateModalContent();
// Check version info for mismatch after loading basic info
this.checkVersionInfo();
} }
updateNightlyWarning() { updateNightlyWarning() {
@@ -424,6 +437,110 @@ export class UpdateService {
// Ensure badge visibility is updated after manual check // Ensure badge visibility is updated after manual check
this.updateBadgeVisibility(); this.updateBadgeVisibility();
} }
async checkVersionInfo() {
try {
// Call API to get current version info
const response = await fetch('/api/version-info');
const data = await response.json();
if (data.success) {
this.currentVersionInfo = data.version;
// Check if version matches stored version
this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
if (this.versionMismatch) {
console.log('Version mismatch detected:', {
current: this.currentVersionInfo,
stored: getStoredVersionInfo()
});
// Reset dismissed status for version mismatch banner
resetDismissedBanner('version-mismatch');
// Register and show the version mismatch banner
this.registerVersionMismatchBanner();
}
}
} catch (error) {
console.error('Failed to check version info:', error);
}
}
registerVersionMismatchBanner() {
// Get stored and current version for display
const storedVersion = getStoredVersionInfo() || 'unknown';
const currentVersion = this.currentVersionInfo || 'unknown';
bannerService.registerBanner('version-mismatch', {
id: 'version-mismatch',
title: 'Application Update Detected',
content: `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`,
actions: [
{
text: 'Refresh Now',
icon: 'fas fa-sync',
action: 'hardRefresh',
type: 'primary'
}
],
dismissible: false,
priority: 10,
countdown: 15,
onRegister: (bannerElement) => {
// Add countdown element
const countdownEl = document.createElement('div');
countdownEl.className = 'banner-countdown';
countdownEl.innerHTML = `<span>Refreshing in <strong>15</strong> seconds...</span>`;
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
// Start countdown
let seconds = 15;
const countdownInterval = setInterval(() => {
seconds--;
const strongEl = countdownEl.querySelector('strong');
if (strongEl) strongEl.textContent = seconds;
if (seconds <= 0) {
clearInterval(countdownInterval);
this.performHardRefresh();
}
}, 1000);
// Store interval ID for cleanup
bannerElement.dataset.countdownInterval = countdownInterval;
// Add action button event handler
const actionBtn = bannerElement.querySelector('.banner-action[data-action="hardRefresh"]');
if (actionBtn) {
actionBtn.addEventListener('click', (e) => {
e.preventDefault();
clearInterval(countdownInterval);
this.performHardRefresh();
});
}
},
onRemove: (bannerElement) => {
// Clear any existing interval
const intervalId = bannerElement.dataset.countdownInterval;
if (intervalId) {
clearInterval(parseInt(intervalId));
}
}
});
}
performHardRefresh() {
// Update stored version info before refreshing
setStoredVersionInfo(this.currentVersionInfo);
// Force a hard refresh by adding cache-busting parameter
const cacheBuster = new Date().getTime();
window.location.href = window.location.pathname +
(window.location.search ? window.location.search + '&' : '?') +
`cache=${cacheBuster}`;
}
} }
// Create and export singleton instance // Create and export singleton instance

View File

@@ -1,4 +1,6 @@
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
export class DownloadManager { export class DownloadManager {
constructor(importManager) { constructor(importManager) {
@@ -200,38 +202,25 @@ export class DownloadManager {
try { try {
// Download the LoRA with download ID // Download the LoRA with download ID
const response = await fetch('/api/download-model', { const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
method: 'POST', lora.modelId,
headers: { 'Content-Type': 'application/json' }, lora.id,
body: JSON.stringify({ loraRoot,
model_id: lora.modelId, targetPath.replace(loraRoot + '/', ''),
model_version_id: lora.id, batchDownloadId
model_root: loraRoot, );
relative_path: targetPath.replace(loraRoot + '/', ''),
download_id: batchDownloadId
})
});
if (!response.ok) { if (!response.success) {
const errorText = await response.text(); console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
console.error(`Failed to download LoRA ${lora.name}: ${errorText}`);
// Check if this is an early access error (status 401 is the key indicator)
if (response.status === 401) {
accessFailures++;
this.importManager.loadingManager.setStatus(
`Failed to download ${lora.name}: Access restricted`
);
}
failedDownloads++; failedDownloads++;
// Continue with next download // Continue with next download
} else { } else {
completedDownloads++; completedDownloads++;
// Update progress to show completion of current LoRA // Update progress to show completion of current LoRA
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) { if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
this.importManager.loadingManager.setStatus( this.importManager.loadingManager.setStatus(
`Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...` `Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...`

View File

@@ -37,6 +37,7 @@ export const state = {
filename: true, filename: true,
modelname: true, modelname: true,
tags: false, tags: false,
creator: false,
recursive: false recursive: false
}, },
filters: { filters: {
@@ -83,6 +84,7 @@ export const state = {
searchOptions: { searchOptions: {
filename: true, filename: true,
modelname: true, modelname: true,
creator: false,
recursive: false recursive: false
}, },
filters: { filters: {
@@ -110,6 +112,7 @@ export const state = {
filename: true, filename: true,
modelname: true, modelname: true,
tags: false, tags: false,
creator: false,
recursive: false recursive: false
}, },
filters: { filters: {

View File

@@ -35,6 +35,7 @@ export const BASE_MODELS = {
ILLUSTRIOUS: "Illustrious", ILLUSTRIOUS: "Illustrious",
PONY: "Pony", PONY: "Pony",
HIDREAM: "HiDream", HIDREAM: "HiDream",
QWEN: "Qwen",
// Video models // Video models
SVD: "SVD", SVD: "SVD",
@@ -93,6 +94,7 @@ export const BASE_MODEL_CLASSES = {
[BASE_MODELS.ILLUSTRIOUS]: "il", [BASE_MODELS.ILLUSTRIOUS]: "il",
[BASE_MODELS.PONY]: "pony", [BASE_MODELS.PONY]: "pony",
[BASE_MODELS.HIDREAM]: "hidream", [BASE_MODELS.HIDREAM]: "hidream",
[BASE_MODELS.QWEN]: "qwen",
// Default // Default
[BASE_MODELS.UNKNOWN]: "unknown" [BASE_MODELS.UNKNOWN]: "unknown"
@@ -112,6 +114,12 @@ export const DOWNLOAD_PATH_TEMPLATES = {
description: 'Organize by base model type', description: 'Organize by base model type',
example: 'Flux.1 D/model-name.safetensors' example: 'Flux.1 D/model-name.safetensors'
}, },
AUTHOR: {
value: '{author}',
label: 'By Author',
description: 'Organize by model author',
example: 'authorname/model-name.safetensors'
},
FIRST_TAG: { FIRST_TAG: {
value: '{first_tag}', value: '{first_tag}',
label: 'By First Tag', label: 'By First Tag',
@@ -123,9 +131,48 @@ export const DOWNLOAD_PATH_TEMPLATES = {
label: 'Base Model + First Tag', label: 'Base Model + First Tag',
description: 'Organize by base model and primary tag', description: 'Organize by base model and primary tag',
example: 'Flux.1 D/style/model-name.safetensors' example: 'Flux.1 D/style/model-name.safetensors'
},
BASE_MODEL_AUTHOR: {
value: '{base_model}/{author}',
label: 'Base Model + Author',
description: 'Organize by base model and author',
example: 'Flux.1 D/authorname/model-name.safetensors'
},
AUTHOR_TAG: {
value: '{author}/{first_tag}',
label: 'Author + First Tag',
description: 'Organize by author and primary tag',
example: 'authorname/style/model-name.safetensors'
},
CUSTOM: {
value: 'custom',
label: 'Custom Template',
description: 'Create your own path structure',
example: 'Enter custom template...'
} }
}; };
// Valid placeholders for path templates
export const PATH_TEMPLATE_PLACEHOLDERS = [
'{base_model}',
'{author}',
'{first_tag}'
];
// Default templates for each model type
export const DEFAULT_PATH_TEMPLATES = {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}',
embedding: '{first_tag}'
};
// Model type labels for UI
export const MODEL_TYPE_LABELS = {
lora: 'LoRA Models',
checkpoint: 'Checkpoint Models',
embedding: 'Embedding Models'
};
// Base models available for path mapping (for UI selection) // Base models available for path mapping (for UI selection)
export const MAPPABLE_BASE_MODELS = Object.values(BASE_MODELS).sort(); export const MAPPABLE_BASE_MODELS = Object.values(BASE_MODELS).sort();
@@ -161,4 +208,4 @@ export const NODE_TYPE_ICONS = {
}; };
// Default ComfyUI node color when bgcolor is null // Default ComfyUI node color when bgcolor is null
export const DEFAULT_NODE_COLOR = "#353535"; export const DEFAULT_NODE_COLOR = "#353535";

View File

@@ -213,4 +213,45 @@ export function getMapFromStorage(key) {
console.error(`Error loading Map from localStorage (${key}):`, error); console.error(`Error loading Map from localStorage (${key}):`, error);
return new Map(); return new Map();
} }
}
/**
* Get stored version info from localStorage
* @returns {string|null} The stored version string or null if not found
*/
export function getStoredVersionInfo() {
return getStorageItem('version_info', null);
}
/**
* Store version info to localStorage
* @param {string} versionInfo - The version info string to store
*/
export function setStoredVersionInfo(versionInfo) {
setStorageItem('version_info', versionInfo);
}
/**
* Check if version info matches between stored and current
* @param {string} currentVersionInfo - The current version info from server
* @returns {boolean} True if versions match or no stored version exists
*/
export function isVersionMatch(currentVersionInfo) {
const storedVersion = getStoredVersionInfo();
// If we have no stored version yet, consider it a match
if (storedVersion === null) {
setStoredVersionInfo(currentVersionInfo);
return true;
}
return storedVersion === currentVersionInfo;
}
/**
* Reset the dismissed status of a specific banner
* @param {string} bannerId - The ID of the banner to un-dismiss
*/
export function resetDismissedBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
const updatedBanners = dismissedBanners.filter(id => id !== bannerId);
setStorageItem('dismissed_banners', updatedBanners);
} }

View File

@@ -9,7 +9,7 @@
{% block init_title %}Initializing Checkpoints Manager{% endblock %} {% block init_title %}Initializing Checkpoints Manager{% endblock %}
{% block init_message %}Scanning and building checkpoints cache. This may take a few moments...{% endblock %} {% block init_message %}Scanning and building checkpoints cache. This may take a few moments...{% endblock %}
{% block init_check_url %}/api/checkpoints?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/checkpoints/list?page=1&page_size=1{% endblock %}
{% block additional_components %} {% block additional_components %}

View File

@@ -86,15 +86,18 @@
<div class="search-option-tag active" data-option="filename">Filename</div> <div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="modelname">Checkpoint Name</div> <div class="search-option-tag active" data-option="modelname">Checkpoint Name</div>
<div class="search-option-tag active" data-option="tags">Tags</div> <div class="search-option-tag active" data-option="tags">Tags</div>
<div class="search-option-tag" data-option="creator">Creator</div>
{% elif request.path == '/embeddings' %} {% elif request.path == '/embeddings' %}
<div class="search-option-tag active" data-option="filename">Filename</div> <div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="modelname">Embedding Name</div> <div class="search-option-tag active" data-option="modelname">Embedding Name</div>
<div class="search-option-tag active" data-option="tags">Tags</div> <div class="search-option-tag active" data-option="tags">Tags</div>
<div class="search-option-tag" data-option="creator">Creator</div>
{% else %} {% else %}
<!-- Default options for LoRAs page --> <!-- Default options for LoRAs page -->
<div class="search-option-tag active" data-option="filename">Filename</div> <div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="modelname">Model Name</div> <div class="search-option-tag active" data-option="modelname">Model Name</div>
<div class="search-option-tag active" data-option="tags">Tags</div> <div class="search-option-tag active" data-option="tags">Tags</div>
<div class="search-option-tag" data-option="creator">Creator</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -1,8 +1,10 @@
<!-- Unified Download Modal for all model types --> <!-- Unified Download Modal for all model types -->
<div id="downloadModal" class="modal"> <div id="downloadModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<button class="close" id="closeDownloadModal">&times;</button> <div class="modal-header">
<h2 id="downloadModalTitle">Download Model from URL</h2> <button class="close" id="closeDownloadModal">&times;</button>
<h2 id="downloadModalTitle">Download Model from URL</h2>
</div>
<!-- Step 1: URL Input --> <!-- Step 1: URL Input -->
<div class="download-step" id="urlStep"> <div class="download-step" id="urlStep">
@@ -30,27 +32,59 @@
<!-- Step 3: Location Selection --> <!-- Step 3: Location Selection -->
<div class="download-step" id="locationStep" style="display: none;"> <div class="download-step" id="locationStep" style="display: none;">
<div class="location-selection"> <div class="location-selection">
<!-- Path preview --> <!-- Path preview with inline toggle -->
<div class="path-preview"> <div class="path-preview">
<label>Download Location Preview:</label> <div class="path-preview-header">
<label>Download Location Preview:</label>
<div class="inline-toggle-container" title="When enabled, files are automatically organized using configured path templates">
<span class="inline-toggle-label">Use Default Path</span>
<div class="toggle-switch">
<input type="checkbox" id="useDefaultPath">
<label for="useDefaultPath" class="toggle-slider"></label>
</div>
</div>
</div>
<div class="path-display" id="targetPathDisplay"> <div class="path-display" id="targetPathDisplay">
<span class="path-text">Select a root directory</span> <span class="path-text">Select a root directory</span>
</div> </div>
</div> </div>
<!-- Model Root Selection (always visible) -->
<div class="input-group"> <div class="input-group">
<label for="modelRoot" id="modelRootLabel">Select Model Root:</label> <label for="modelRoot" id="modelRootLabel">Select Model Root:</label>
<select id="modelRoot"></select> <select id="modelRoot"></select>
</div> </div>
<div class="input-group">
<label>Target Folder:</label> <!-- Manual Path Selection (hidden when using default path) -->
<div class="folder-browser" id="folderBrowser"> <div class="manual-path-selection" id="manualPathSelection">
<!-- Folders will be loaded dynamically --> <!-- Path input with autocomplete -->
<div class="input-group">
<label for="folderPath">Target Folder Path:</label>
<div class="path-input-container">
<input type="text" id="folderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
<button type="button" id="createFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="path-suggestions" id="pathSuggestions" style="display: none;"></div>
</div>
<!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="breadcrumbNav">
<span class="breadcrumb-item root" data-path="">
<i class="fas fa-home"></i> Root
</span>
</div>
<!-- Hierarchical folder tree -->
<div class="input-group">
<label>Browse Folders:</label>
<div class="folder-tree-container">
<div class="folder-tree" id="folderTree">
<!-- Tree will be loaded dynamically -->
</div>
</div>
</div> </div>
</div>
<div class="input-group">
<label for="newFolder">New Folder (optional):</label>
<input type="text" id="newFolder" placeholder="Enter folder name" />
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">

View File

@@ -6,6 +6,7 @@
<span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span> <span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span>
</div> </div>
<div class="location-selection"> <div class="location-selection">
<!-- Path preview -->
<div class="path-preview"> <div class="path-preview">
<label>Target Location Preview:</label> <label>Target Location Preview:</label>
<div class="path-display" id="moveTargetPathDisplay"> <div class="path-display" id="moveTargetPathDisplay">
@@ -14,18 +15,37 @@
</div> </div>
<div class="input-group"> <div class="input-group">
<label id="moveRootLabel">Select Model Root:</label> <label for="moveModelRoot" id="moveRootLabel">Select Model Root:</label>
<select id="moveModelRoot"></select> <select id="moveModelRoot"></select>
</div> </div>
<!-- Path input with autocomplete -->
<div class="input-group"> <div class="input-group">
<label>Target Folder:</label> <label for="moveFolderPath">Target Folder Path:</label>
<div class="folder-browser" id="moveFolderBrowser"> <div class="path-input-container">
<!-- Folders will be loaded dynamically --> <input type="text" id="moveFolderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
<button type="button" id="moveCreateFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div> </div>
<div class="path-suggestions" id="movePathSuggestions" style="display: none;"></div>
</div> </div>
<!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="moveBreadcrumbNav">
<span class="breadcrumb-item root" data-path="">
<i class="fas fa-home"></i> Root
</span>
</div>
<!-- Hierarchical folder tree -->
<div class="input-group"> <div class="input-group">
<label for="moveNewFolder">New Folder (optional):</label> <label>Browse Folders:</label>
<input type="text" id="moveNewFolder" placeholder="Enter folder name" /> <div class="folder-tree-container">
<div class="folder-tree" id="moveFolderTree">
<!-- Tree will be loaded dynamically -->
</div>
</div>
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">

View File

@@ -90,6 +90,57 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">Display Density</label>
</div>
<div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<option value="default">Default</option>
<option value="medium">Medium</option>
<option value="compact">Compact</option>
</select>
</div>
</div>
<div class="input-help">
Choose how many cards to display per row:
<ul class="list-description">
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
</div>
</div>
<!-- Add Card Info Display setting -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label>
</div>
<div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')">
<option value="always">Always Visible</option>
<option value="hover">Reveal on Hover</option>
</select>
</div>
</div>
<div class="input-help">
Choose when to display model information and action buttons:
<ul class="list-description">
<li><strong>Always Visible:</strong> Headers and footers are always visible</li>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li>
</ul>
</div>
</div>
</div>
<!-- Add Folder Settings Section --> <!-- Add Folder Settings Section -->
<div class="settings-section"> <div class="settings-section">
@@ -149,108 +200,121 @@
<!-- Default Path Customization Section --> <!-- Default Path Customization Section -->
<div class="settings-section"> <div class="settings-section">
<h3>Default Path Customization</h3> <h3>Download Path Templates</h3>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadPathTemplate">Download Path Template</label>
</div>
<div class="setting-control select-control">
<select id="downloadPathTemplate" onchange="settingsManager.saveSelectSetting('downloadPathTemplate', 'download_path_template')">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
</select>
</div>
</div>
<div class="input-help"> <div class="input-help">
Configure path structure for default download locations Configure folder structures for different model types when downloading from Civitai.
<ul class="list-description"> <div class="placeholder-info">
<li><strong>Flat:</strong> All models in root folder</li> <strong>Available placeholders:</strong>
<li><strong>Base Model:</strong> Organized by model type (e.g., Flux.1 D, SDXL)</li> <span class="placeholder-tag">{base_model}</span>
<li><strong>First Tag:</strong> Organized by primary tag (e.g., style, character)</li> <span class="placeholder-tag">{author}</span>
<li><strong>Base Model + Tag:</strong> Two-level organization for better structure</li> <span class="placeholder-tag">{first_tag}</span>
</ul> </div>
</div> </div>
<div id="pathTemplatePreview" class="template-preview"></div>
</div> </div>
<!-- LoRA Template Configuration -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label>Base Model Path Mappings</label> <label for="loraTemplatePreset">LoRA</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
</div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">Display Density</label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')"> <select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
<option value="default">Default</option> <option value="">Flat Structure</option>
<option value="medium">Medium</option> <option value="{base_model}">By Base Model</option>
<option value="compact">Compact</option> <option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select> </select>
</div> </div>
</div> </div>
<div class="input-help"> <div class="template-custom-row" id="loraCustomRow" style="display: none;">
Choose how many cards to display per row: <input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<ul class="list-description"> <div class="template-validation" id="loraValidation"></div>
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
</div> </div>
<div class="template-preview" id="loraPreview"></div>
</div> </div>
<!-- Add Card Info Display setting --> <!-- Checkpoint Template Configuration -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label> <label for="checkpointTemplatePreset">Checkpoint</label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')"> <select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
<option value="always">Always Visible</option> <option value="">Flat Structure</option>
<option value="hover">Reveal on Hover</option> <option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select> </select>
</div> </div>
</div> </div>
<div class="input-help"> <div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
Choose when to display model information and action buttons: <input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<ul class="list-description"> <div class="template-validation" id="checkpointValidation"></div>
<li><strong>Always Visible:</strong> Headers and footers are always visible</li> </div>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li> <div class="template-preview" id="checkpointPreview"></div>
</ul> </div>
<!-- Embedding Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">Embedding</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div> </div>
</div> </div>
</div> </div>
<!-- Add Example Images Settings Section --> <!-- Add Example Images Settings Section -->
<div class="settings-section"> <div class="settings-section">
<h3>Example Images</h3> <h3>Example Images</h3>

View File

@@ -9,7 +9,7 @@
{% block init_title %}Initializing Embeddings Manager{% endblock %} {% block init_title %}Initializing Embeddings Manager{% endblock %}
{% block init_message %}Scanning and building embeddings cache. This may take a few moments...{% endblock %} {% block init_message %}Scanning and building embeddings cache. This may take a few moments...{% endblock %}
{% block init_check_url %}/api/embeddings?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/embeddings/list?page=1&page_size=1{% endblock %}
{% block additional_components %} {% block additional_components %}

View File

@@ -11,7 +11,7 @@
{% block init_title %}Initializing LoRA Manager{% endblock %} {% block init_title %}Initializing LoRA Manager{% endblock %}
{% block init_message %}Scanning and building LoRA cache. This may take a few minutes...{% endblock %} {% block init_message %}Scanning and building LoRA cache. This may take a few minutes...{% endblock %}
{% block init_check_url %}/api/loras?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/loras/list?page=1&page_size=1{% endblock %}
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}

View File

@@ -5,9 +5,6 @@ export function addJsonDisplayWidget(node, name, opts) {
// Set initial height // Set initial height
const defaultHeight = 200; const defaultHeight = 200;
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
Object.assign(container.style, { Object.assign(container.style, {
display: "block", display: "block",
@@ -113,16 +110,6 @@ export function addJsonDisplayWidget(node, name, opts) {
widgetValue = v; widgetValue = v;
displayJson(widgetValue, widget); displayJson(widgetValue, widget);
}, },
getMinHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
},
getMaxHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
},
getHeight: function() {
// Return actual container height to reduce the gap
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
},
hideOnZoom: true hideOnZoom: true
}); });

View File

@@ -156,6 +156,7 @@ app.registerExtension({
// Update input widget callback // Update input widget callback
const inputWidget = this.widgets[0]; const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget; this.inputWidget = inputWidget;
inputWidget.callback = (value) => { inputWidget.callback = (value) => {
if (isUpdating) return; if (isUpdating) return;

View File

@@ -77,6 +77,7 @@ app.registerExtension({
// Update input widget callback // Update input widget callback
const inputWidget = this.widgets[0]; const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget; this.inputWidget = inputWidget;
inputWidget.callback = (value) => { inputWidget.callback = (value) => {
if (isUpdating) return; if (isUpdating) return;

View File

@@ -19,9 +19,6 @@ export function addLorasWidget(node, name, opts, callback) {
// Set initial height using CSS variables approach // Set initial height using CSS variables approach
const defaultHeight = 200; const defaultHeight = 200;
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
Object.assign(container.style, { Object.assign(container.style, {
display: "flex", display: "flex",
@@ -712,23 +709,8 @@ export function addLorasWidget(node, name, opts, callback) {
widgetValue = updatedValue; widgetValue = updatedValue;
renderLoras(widgetValue, widget); renderLoras(widgetValue, widget);
}, },
getMinHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
},
getMaxHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
},
getHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
},
hideOnZoom: true, hideOnZoom: true,
selectOn: ['click', 'focus'], selectOn: ['click', 'focus']
afterResize: function(node) {
// Re-render after node resize
if (this.value && this.value.length > 0) {
renderLoras(this.value, this);
}
}
}); });
widget.value = defaultValue; widget.value = defaultValue;

View File

@@ -5,9 +5,6 @@ export function addTagsWidget(node, name, opts, callback) {
// Set initial height // Set initial height
const defaultHeight = 150; const defaultHeight = 150;
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
Object.assign(container.style, { Object.assign(container.style, {
display: "flex", display: "flex",
@@ -199,23 +196,8 @@ export function addTagsWidget(node, name, opts, callback) {
widgetValue = v; widgetValue = v;
renderTags(widgetValue, widget); renderTags(widgetValue, widget);
}, },
getMinHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
},
getMaxHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
},
getHeight: function() {
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
},
hideOnZoom: true, hideOnZoom: true,
selectOn: ['click', 'focus'], selectOn: ['click', 'focus']
afterResize: function(node) {
// Re-render tags after node resize
if (this.value && this.value.length > 0) {
renderTags(this.value, this);
}
}
}); });
// Set initial value // Set initial value

View File

@@ -78,6 +78,7 @@ app.registerExtension({
// Update input widget callback // Update input widget callback
const inputWidget = this.widgets[1]; const inputWidget = this.widgets[1];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget; this.inputWidget = inputWidget;
inputWidget.callback = (value) => { inputWidget.callback = (value) => {
if (isUpdating) return; if (isUpdating) return;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 162 KiB