mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199e374318 | ||
|
|
8375c1413d | ||
|
|
9e268cf016 | ||
|
|
112b3abc26 | ||
|
|
a8331a2357 | ||
|
|
52e3ad08c1 | ||
|
|
8d01d04ef0 | ||
|
|
a141384907 | ||
|
|
b8aa7184bd | ||
|
|
e4195f874d | ||
|
|
d04deff5ca | ||
|
|
20ce0778a0 | ||
|
|
5a0b3470f1 | ||
|
|
a920921570 | ||
|
|
286f4ff384 | ||
|
|
71ddfafa98 | ||
|
|
b7e3e53697 | ||
|
|
16df548b77 | ||
|
|
425c33ae00 | ||
|
|
c9289ed2dc | ||
|
|
96517cbdef | ||
|
|
b03420faac | ||
|
|
65a1aa7ca2 | ||
|
|
3a92e8eaf9 | ||
|
|
a8dc50d64a | ||
|
|
3397cc7d8d | ||
|
|
c3e8131b24 | ||
|
|
f8ca8584ae | ||
|
|
3050bbe260 | ||
|
|
e1dda2795a | ||
|
|
6d8408e626 | ||
|
|
0906271aa9 | ||
|
|
4c33c9d256 | ||
|
|
fa9c78209f | ||
|
|
6678ec8a60 | ||
|
|
854e467c12 | ||
|
|
e6b94c7b21 | ||
|
|
2c6f9d8602 | ||
|
|
c74033b9c0 | ||
|
|
d2b21d27bb | ||
|
|
215272469f | ||
|
|
f7d05ab0f1 | ||
|
|
6f2ad2be77 | ||
|
|
66575c719a | ||
|
|
677a239d53 | ||
|
|
3b96bfe5af | ||
|
|
83be5cfa64 | ||
|
|
6b834c2362 | ||
|
|
7abfc49e08 | ||
|
|
65d5f50088 | ||
|
|
4f1f4ffe3d | ||
|
|
b0c2027a1c | ||
|
|
33c83358b0 | ||
|
|
31223f0526 | ||
|
|
92daadb92c | ||
|
|
fae2e274fd | ||
|
|
342a722991 | ||
|
|
65ec6aacb7 | ||
|
|
9387470c69 | ||
|
|
31f6edf8f0 | ||
|
|
487b062175 | ||
|
|
d8e13de096 | ||
|
|
e8a30088ef | ||
|
|
bf7b07ba74 |
79
README.md
79
README.md
@@ -34,6 +34,36 @@ 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
|
||||||
|
* **LoRA List Reordering**
|
||||||
|
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
|
||||||
|
- Keyboard Shortcuts:
|
||||||
|
- Arrow keys: Navigate between LoRAs
|
||||||
|
- Ctrl/Cmd + Arrow: Move selected LoRA up/down
|
||||||
|
- Ctrl/Cmd + Home/End: Move selected LoRA to top/bottom
|
||||||
|
- Delete/Backspace: Remove selected LoRA
|
||||||
|
- Context Menu: Right-click for quick actions like Move Up, Move Down, Move to Top, Move to Bottom.
|
||||||
|
* **Bulk Operations for Checkpoints & Embeddings**
|
||||||
|
- Bulk Mode: Select multiple checkpoints or embeddings for batch actions.
|
||||||
|
- Bulk Refresh: Update Civitai metadata for selected models.
|
||||||
|
- Bulk Delete: Remove multiple models at once.
|
||||||
|
- Bulk Move (Embeddings): Move selected embeddings to a different folder.
|
||||||
|
* **New Setting: Auto Download Example Images**
|
||||||
|
- Automatically fetch example images for models missing previews (requires download location to be set). Enabled by default.
|
||||||
|
* **General Improvements**
|
||||||
|
- Various user experience enhancements and stability fixes.
|
||||||
|
|
||||||
### v0.8.22
|
### v0.8.22
|
||||||
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
|
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
|
||||||
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
|
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
|
||||||
@@ -70,52 +100,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
|
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
|
||||||
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
|
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
|
||||||
|
|
||||||
### v0.8.16
|
|
||||||
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
|
|
||||||
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
|
|
||||||
* **Customizable Display Density** - Replaced compact mode with adjustable display density settings for personalized layout customization
|
|
||||||
* **Model Creator Information** - Added creator details to model information panels for better attribution
|
|
||||||
* **Improved WebP Support** - Enhanced Save Image node with workflow embedding capability for WebP format images
|
|
||||||
* **Direct Example Access** - Added "Open Example Images Folder" button to card interfaces for convenient browsing of downloaded model examples
|
|
||||||
* **Enhanced Compatibility** - Full ComfyUI Desktop support for "Send lora or recipe to workflow" functionality
|
|
||||||
* **Cache Management** - Added settings to clear existing cache files when needed
|
|
||||||
* **Bug Fixes & Stability** - Various improvements for overall reliability and performance
|
|
||||||
|
|
||||||
### v0.8.15
|
|
||||||
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
|
|
||||||
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
|
|
||||||
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
|
|
||||||
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
|
|
||||||
|
|
||||||
### v0.8.14
|
|
||||||
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
|
|
||||||
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
|
|
||||||
* **Enhanced LoRA Node Functionality** - Comprehensive improvements to LoRA loader/stacker nodes including real-time trigger word updates (reflecting any change anywhere in the LoRA chain for precise updates) and expanded context menu with "Copy Notes" and "Copy Trigger Words" options for faster workflow
|
|
||||||
|
|
||||||
### v0.8.13
|
|
||||||
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
|
|
||||||
* **Improved Source Tracking** - Source URLs are now saved with recipes imported via URL, allowing users to view original content with one click or manually edit links
|
|
||||||
* **Advanced LoRA Control** - Double-click LoRAs in Loader/Stacker nodes to access expanded CLIP strength controls for more precise adjustments of model and CLIP strength separately
|
|
||||||
* **Lycoris Model Support** - Added compatibility with Lycoris models for expanded creative options
|
|
||||||
* **Bug Fixes & UX Improvements** - Resolved various issues and enhanced overall user experience with numerous optimizations
|
|
||||||
|
|
||||||
### v0.8.12
|
|
||||||
* **Enhanced Model Discovery** - Added alphabetical navigation bar to LoRAs page for faster browsing through large collections
|
|
||||||
* **Optimized Example Images** - Improved download logic to automatically refresh stale metadata before fetching example images
|
|
||||||
* **Model Exclusion System** - New right-click option to exclude specific LoRAs or checkpoints from management
|
|
||||||
* **Improved Showcase Experience** - Enhanced interaction in LoRA and checkpoint showcase areas for better usability
|
|
||||||
|
|
||||||
### v0.8.11
|
|
||||||
* **Offline Image Support** - Added functionality to download and save all model example images locally, ensuring access even when offline or if images are removed from CivitAI or the site is down
|
|
||||||
* **Resilient Download System** - Implemented pause/resume capability with checkpoint recovery that persists through restarts or unexpected exits
|
|
||||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
|
||||||
|
|
||||||
### v0.8.10
|
|
||||||
* **Standalone Mode** - Run LoRA Manager independently from ComfyUI for a lightweight experience that works even with other stable diffusion interfaces
|
|
||||||
* **Portable Edition** - New one-click portable version for easy startup and updates in standalone mode
|
|
||||||
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
|
|
||||||
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
|
|
||||||
|
|
||||||
[View Update History](./update_logs.md)
|
[View Update History](./update_logs.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -173,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**
|
||||||
|
|
||||||
|
|||||||
25
py/config.py
25
py/config.py
@@ -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
|
||||||
@@ -204,16 +205,20 @@ class Config:
|
|||||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||||
unet_map[real_path] = unet_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
unet_map[real_path] = unet_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
||||||
|
|
||||||
|
# Merge both maps and deduplicate by real path
|
||||||
|
merged_map = {}
|
||||||
|
for real_path, orig_path in {**checkpoint_map, **unet_map}.items():
|
||||||
|
if real_path not in merged_map:
|
||||||
|
merged_map[real_path] = orig_path
|
||||||
|
|
||||||
# Now sort and use only the deduplicated real paths
|
# Now sort and use only the deduplicated real paths
|
||||||
unique_checkpoint_paths = sorted(checkpoint_map.values(), key=lambda p: p.lower())
|
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||||
unique_unet_paths = sorted(unet_map.values(), key=lambda p: p.lower())
|
|
||||||
|
|
||||||
# Store individual paths in class properties
|
# Split back into checkpoints and unet roots for class properties
|
||||||
self.checkpoints_roots = unique_checkpoint_paths
|
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()]
|
||||||
self.unet_roots = unique_unet_paths
|
self.unet_roots = [p for p in unique_paths if p in unet_map.values()]
|
||||||
|
|
||||||
# Combine all checkpoint-related paths for return value
|
all_paths = unique_paths
|
||||||
all_paths = unique_checkpoint_paths + unique_unet_paths
|
|
||||||
|
|
||||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
||||||
|
|
||||||
@@ -271,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 ""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -153,10 +158,6 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Process civitaiResources array
|
# Process civitaiResources array
|
||||||
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||||
for resource in metadata["civitaiResources"]:
|
for resource in metadata["civitaiResources"]:
|
||||||
# Skip resources that aren't LoRAs or LyCORIS
|
|
||||||
if resource.get("type") not in ["lora", "lycoris"] and "type" not in resource:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get unique identifier for deduplication
|
# Get unique identifier for deduplication
|
||||||
version_id = str(resource.get("modelVersionId", ""))
|
version_id = str(resource.get("modelVersionId", ""))
|
||||||
|
|
||||||
@@ -275,6 +276,66 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
|
||||||
|
lora_index = 0
|
||||||
|
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:
|
||||||
|
lora_hash = metadata[f"Lora_{lora_index} Model hash"]
|
||||||
|
lora_name = metadata[f"Lora_{lora_index} Model name"]
|
||||||
|
lora_strength_model = float(metadata.get(f"Lora_{lora_index} Strength model", 1.0))
|
||||||
|
|
||||||
|
# Skip if we've already added this LoRA by hash
|
||||||
|
if lora_hash and lora_hash in added_loras:
|
||||||
|
lora_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_entry = {
|
||||||
|
'name': lora_name,
|
||||||
|
'type': "lora",
|
||||||
|
'weight': lora_strength_model,
|
||||||
|
'hash': lora_hash,
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': lora_name,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to get info from Civitai if hash is available
|
||||||
|
if lora_entry['hash'] and civitai_client:
|
||||||
|
try:
|
||||||
|
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
lora_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
lora_index += 1
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
|
||||||
|
# If we have a version ID from Civitai, track it for deduplication
|
||||||
|
if 'id' in lora_entry and lora_entry['id']:
|
||||||
|
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
|
||||||
|
|
||||||
|
# Track by hash if we have it
|
||||||
|
if lora_hash:
|
||||||
|
added_loras[lora_hash] = len(result["loras"])
|
||||||
|
|
||||||
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
lora_index += 1
|
||||||
|
|
||||||
# If base model wasn't found earlier, use the most common one from LoRAs
|
# If base model wasn't found earlier, use the most common one from LoRAs
|
||||||
if not result["base_model"] and base_model_counts:
|
if not result["base_model"] and base_model_counts:
|
||||||
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -48,6 +51,9 @@ class BaseModelRoutes(ABC):
|
|||||||
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
|
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
|
||||||
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
||||||
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_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)
|
||||||
@@ -55,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)
|
||||||
|
|
||||||
@@ -175,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,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:
|
||||||
@@ -616,4 +662,322 @@ class BaseModelRoutes(ABC):
|
|||||||
# This will be implemented by subclasses as they need CivitAI client access
|
# This will be implemented by subclasses as they need CivitAI client access
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"error": "Not implemented in base class"
|
"error": "Not implemented in base class"
|
||||||
}, status=501)
|
}, status=501)
|
||||||
|
|
||||||
|
# Common model move handlers
|
||||||
|
async def move_model(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle model move request"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_path = data.get('file_path')
|
||||||
|
target_path = data.get('target_path')
|
||||||
|
if not file_path or not target_path:
|
||||||
|
return web.Response(text='File path and target path are required', status=400)
|
||||||
|
import os
|
||||||
|
source_dir = os.path.dirname(file_path)
|
||||||
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
|
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||||
|
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||||
|
if os.path.exists(target_file_path):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f"Target file already exists: {target_file_path}"
|
||||||
|
}, status=409)
|
||||||
|
success = await self.service.scanner.move_model(file_path, target_path)
|
||||||
|
if success:
|
||||||
|
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
||||||
|
else:
|
||||||
|
return web.Response(text='Failed to move model', status=500)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
|
return web.Response(text=str(e), status=500)
|
||||||
|
|
||||||
|
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle bulk model move request"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_paths = data.get('file_paths', [])
|
||||||
|
target_path = data.get('target_path')
|
||||||
|
if not file_paths or not target_path:
|
||||||
|
return web.Response(text='File paths and target path are required', status=400)
|
||||||
|
results = []
|
||||||
|
import os
|
||||||
|
for file_path in file_paths:
|
||||||
|
source_dir = os.path.dirname(file_path)
|
||||||
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
|
results.append({
|
||||||
|
"path": file_path,
|
||||||
|
"success": True,
|
||||||
|
"message": "Source and target directories are the same"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||||
|
if os.path.exists(target_file_path):
|
||||||
|
results.append({
|
||||||
|
"path": file_path,
|
||||||
|
"success": False,
|
||||||
|
"message": f"Target file already exists: {target_file_path}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
success = await self.service.scanner.move_model(file_path, target_path)
|
||||||
|
results.append({
|
||||||
|
"path": file_path,
|
||||||
|
"success": success,
|
||||||
|
"message": "Success" if success else "Failed to move model"
|
||||||
|
})
|
||||||
|
success_count = sum(1 for r in results if r["success"])
|
||||||
|
failure_count = len(results) - success_count
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||||
|
'results': results,
|
||||||
|
'success_count': success_count,
|
||||||
|
'failure_count': failure_count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||||
|
return web.Response(text=str(e), status=500)
|
||||||
|
|
||||||
|
async def 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)
|
||||||
@@ -4,6 +4,7 @@ from aiohttp import web
|
|||||||
from .base_model_routes import BaseModelRoutes
|
from .base_model_routes import BaseModelRoutes
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
|
|
||||||
# Checkpoint info by name
|
# Checkpoint info by name
|
||||||
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
|
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
|
||||||
|
|
||||||
|
# Checkpoint roots and Unet roots
|
||||||
|
app.router.add_get(f'/api/{prefix}/checkpoints_roots', self.get_checkpoints_roots)
|
||||||
|
app.router.add_get(f'/api/{prefix}/unet_roots', self.get_unet_roots)
|
||||||
|
|
||||||
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
||||||
"""Get detailed information for a specific checkpoint by name"""
|
"""Get detailed information for a specific checkpoint by name"""
|
||||||
@@ -102,4 +107,34 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
return web.json_response(versions)
|
return web.json_response(versions)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching checkpoint model versions: {e}")
|
logger.error(f"Error fetching checkpoint model versions: {e}")
|
||||||
return web.Response(status=500, text=str(e))
|
return web.Response(status=500, text=str(e))
|
||||||
|
|
||||||
|
async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return the list of checkpoint roots from config"""
|
||||||
|
try:
|
||||||
|
roots = config.checkpoints_roots
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"roots": roots
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting checkpoint roots: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_unet_roots(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return the list of unet roots from config"""
|
||||||
|
try:
|
||||||
|
roots = config.unet_roots
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"roots": roots
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting unet roots: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, status=500)
|
||||||
@@ -49,10 +49,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_lora_civitai_url)
|
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_lora_civitai_url)
|
||||||
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
|
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
|
||||||
|
|
||||||
# LoRA-specific management routes
|
|
||||||
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)
|
|
||||||
|
|
||||||
# CivitAI integration with LoRA-specific validation
|
# CivitAI integration with LoRA-specific validation
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora)
|
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora)
|
||||||
app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
|
app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
|
||||||
@@ -284,105 +280,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
"error": str(e)
|
"error": str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
# Model management methods
|
|
||||||
async def move_model(self, request: web.Request) -> web.Response:
|
|
||||||
"""Handle model move request"""
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
file_path = data.get('file_path') # full path of the model file
|
|
||||||
target_path = data.get('target_path') # folder path to move the model to
|
|
||||||
|
|
||||||
if not file_path or not target_path:
|
|
||||||
return web.Response(text='File path and target path are required', status=400)
|
|
||||||
|
|
||||||
# Check if source and destination are the same
|
|
||||||
import os
|
|
||||||
source_dir = os.path.dirname(file_path)
|
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
|
||||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
|
||||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
|
||||||
|
|
||||||
# Check if target file already exists
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
|
||||||
|
|
||||||
if os.path.exists(target_file_path):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"Target file already exists: {target_file_path}"
|
|
||||||
}, status=409) # 409 Conflict
|
|
||||||
|
|
||||||
# Call scanner to handle the move operation
|
|
||||||
success = await self.service.scanner.move_model(file_path, target_path)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
|
||||||
else:
|
|
||||||
return web.Response(text='Failed to move model', status=500)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
|
||||||
return web.Response(text=str(e), status=500)
|
|
||||||
|
|
||||||
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
|
||||||
"""Handle bulk model move request"""
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
file_paths = data.get('file_paths', []) # list of full paths of the model files
|
|
||||||
target_path = data.get('target_path') # folder path to move the models to
|
|
||||||
|
|
||||||
if not file_paths or not target_path:
|
|
||||||
return web.Response(text='File paths and target path are required', status=400)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
import os
|
|
||||||
for file_path in file_paths:
|
|
||||||
# Check if source and destination are the same
|
|
||||||
source_dir = os.path.dirname(file_path)
|
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
|
||||||
results.append({
|
|
||||||
"path": file_path,
|
|
||||||
"success": True,
|
|
||||||
"message": "Source and target directories are the same"
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if target file already exists
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
|
||||||
|
|
||||||
if os.path.exists(target_file_path):
|
|
||||||
results.append({
|
|
||||||
"path": file_path,
|
|
||||||
"success": False,
|
|
||||||
"message": f"Target file already exists: {target_file_path}"
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Try to move the model
|
|
||||||
success = await self.service.scanner.move_model(file_path, target_path)
|
|
||||||
results.append({
|
|
||||||
"path": file_path,
|
|
||||||
"success": success,
|
|
||||||
"message": "Success" if success else "Failed to move model"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Count successes and failures
|
|
||||||
success_count = sum(1 for r in results if r["success"])
|
|
||||||
failure_count = len(results) - success_count
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
|
||||||
'results': results,
|
|
||||||
'success_count': success_count,
|
|
||||||
'failure_count': failure_count
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
|
||||||
return web.Response(text=str(e), status=500)
|
|
||||||
|
|
||||||
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
||||||
"""Get model description for a Lora model"""
|
"""Get model description for a Lora model"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from ..config import config
|
|||||||
# 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
|
||||||
|
|
||||||
from ..utils.utils import download_civitai_image
|
|
||||||
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
|
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
|
||||||
|
|
||||||
# Only import MetadataRegistry in non-standalone mode
|
# Only import MetadataRegistry in non-standalone mode
|
||||||
@@ -376,16 +375,6 @@ class RecipeRoutes:
|
|||||||
# Use meta field from image_info as metadata
|
# Use meta field from image_info as metadata
|
||||||
if 'meta' in image_info:
|
if 'meta' in image_info:
|
||||||
metadata = image_info['meta']
|
metadata = image_info['meta']
|
||||||
|
|
||||||
else:
|
|
||||||
# Not a Civitai image URL, use the original download method
|
|
||||||
temp_path = download_civitai_image(url)
|
|
||||||
|
|
||||||
if not temp_path:
|
|
||||||
return web.json_response({
|
|
||||||
"error": "Failed to download image from URL",
|
|
||||||
"loras": []
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# If metadata wasn't obtained from Civitai API, extract it from the image
|
# If metadata wasn't obtained from Civitai API, extract it from the image
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
@@ -638,21 +627,6 @@ class RecipeRoutes:
|
|||||||
image = base64.b64decode(image_base64)
|
image = base64.b64decode(image_base64)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": f"Invalid base64 image data: {str(e)}"}, status=400)
|
return web.json_response({"error": f"Invalid base64 image data: {str(e)}"}, status=400)
|
||||||
elif image_url:
|
|
||||||
# Download image from URL
|
|
||||||
temp_path = download_civitai_image(image_url)
|
|
||||||
if not temp_path:
|
|
||||||
return web.json_response({"error": "Failed to download image from URL"}, status=400)
|
|
||||||
|
|
||||||
# Read the downloaded image
|
|
||||||
with open(temp_path, 'rb') as f:
|
|
||||||
image = f.read()
|
|
||||||
|
|
||||||
# Clean up temp file
|
|
||||||
try:
|
|
||||||
os.unlink(temp_path)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
return web.json_response({"error": "No image data provided"}, status=400)
|
return web.json_response({"error": "No image data provided"}, status=400)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -157,7 +155,7 @@ class UpdateRoutes:
|
|||||||
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Download latest release ZIP from GitHub and replace plugin files.
|
Download latest release ZIP from GitHub and replace plugin files.
|
||||||
Skips settings.json.
|
Skips settings.json. Writes extracted file list to .tracking.
|
||||||
"""
|
"""
|
||||||
repo_owner = "willmiao"
|
repo_owner = "willmiao"
|
||||||
repo_name = "ComfyUI-Lora-Manager"
|
repo_name = "ComfyUI-Lora-Manager"
|
||||||
@@ -196,7 +194,6 @@ class UpdateRoutes:
|
|||||||
src = os.path.join(extracted_root, item)
|
src = os.path.join(extracted_root, item)
|
||||||
dst = os.path.join(plugin_root, item)
|
dst = os.path.join(plugin_root, item)
|
||||||
if os.path.isdir(src):
|
if os.path.isdir(src):
|
||||||
# Remove old folder, then copy
|
|
||||||
if os.path.exists(dst):
|
if os.path.exists(dst):
|
||||||
shutil.rmtree(dst)
|
shutil.rmtree(dst)
|
||||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
|
||||||
@@ -205,6 +202,17 @@ class UpdateRoutes:
|
|||||||
continue
|
continue
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
# Write .tracking file: list all files under extracted_root, relative to extracted_root
|
||||||
|
# for ComfyUI Manager to work properly
|
||||||
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
|
tracking_files = []
|
||||||
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
|
for file in files:
|
||||||
|
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||||
|
tracking_files.append(rel_path.replace("\\", "/"))
|
||||||
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||||
|
file.write('\n'.join(tracking_files))
|
||||||
|
|
||||||
os.remove(zip_path)
|
os.remove(zip_path)
|
||||||
logger.info(f"Updated plugin via ZIP to {version}")
|
logger.info(f"Updated plugin via ZIP to {version}")
|
||||||
return True, version
|
return True, version
|
||||||
@@ -364,65 +372,28 @@ class UpdateRoutes:
|
|||||||
"""Get Git repository information"""
|
"""Get Git repository information"""
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
|
||||||
git_info = {
|
git_info = {
|
||||||
'commit_hash': 'unknown',
|
'commit_hash': 'unknown',
|
||||||
'short_hash': 'stable',
|
'short_hash': 'stable',
|
||||||
'branch': 'unknown',
|
'branch': 'unknown',
|
||||||
'commit_date': 'unknown'
|
'commit_date': 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if we're in a git repository
|
# Check if we're in a git repository
|
||||||
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
||||||
return git_info
|
return git_info
|
||||||
|
|
||||||
# Get current commit hash
|
repo = git.Repo(plugin_root)
|
||||||
result = subprocess.run(
|
commit = repo.head.commit
|
||||||
['git', 'rev-parse', 'HEAD'],
|
git_info['commit_hash'] = commit.hexsha
|
||||||
cwd=plugin_root,
|
git_info['short_hash'] = commit.hexsha[:7]
|
||||||
stdout=subprocess.PIPE,
|
git_info['branch'] = repo.active_branch.name if not repo.head.is_detached else 'detached'
|
||||||
stderr=subprocess.PIPE,
|
git_info['commit_date'] = commit.committed_datetime.strftime('%Y-%m-%d')
|
||||||
text=True,
|
|
||||||
check=False
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
git_info['commit_hash'] = result.stdout.strip()
|
|
||||||
git_info['short_hash'] = git_info['commit_hash'][:7]
|
|
||||||
|
|
||||||
# Get current branch name
|
|
||||||
result = subprocess.run(
|
|
||||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
||||||
cwd=plugin_root,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
check=False
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
git_info['branch'] = result.stdout.strip()
|
|
||||||
|
|
||||||
# Get commit date
|
|
||||||
result = subprocess.run(
|
|
||||||
['git', 'show', '-s', '--format=%ci', 'HEAD'],
|
|
||||||
cwd=plugin_root,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
check=False
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
commit_date = result.stdout.strip()
|
|
||||||
# Format the date nicely if possible
|
|
||||||
try:
|
|
||||||
date_obj = datetime.strptime(commit_date, '%Y-%m-%d %H:%M:%S %z')
|
|
||||||
git_info['commit_date'] = date_obj.strftime('%Y-%m-%d')
|
|
||||||
except:
|
|
||||||
git_info['commit_date'] = commit_date
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error getting git info: {e}")
|
logger.warning(f"Error getting git info: {e}")
|
||||||
|
|
||||||
return git_info
|
return git_info
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
@@ -21,6 +21,14 @@ class CheckpointScanner(ModelScanner):
|
|||||||
hash_index=ModelHashIndex()
|
hash_index=ModelHashIndex()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
|
if hasattr(metadata, "model_type"):
|
||||||
|
if root_path in config.checkpoints_roots:
|
||||||
|
metadata.model_type = "checkpoint"
|
||||||
|
elif root_path in config.unet_roots:
|
||||||
|
metadata.model_type = "diffusion_model"
|
||||||
|
return metadata
|
||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
"""Get checkpoint root directories"""
|
"""Get checkpoint root directories"""
|
||||||
return config.base_models_roots
|
return config.base_models_roots
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -569,12 +583,12 @@ class ModelScanner:
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
try:
|
try:
|
||||||
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
||||||
# Use original path instead of real path
|
|
||||||
file_path = entry.path.replace(os.sep, "/")
|
file_path = entry.path.replace(os.sep, "/")
|
||||||
await self._process_single_file(file_path, original_root, models)
|
result = await self._process_model_file(file_path, original_root)
|
||||||
|
if result:
|
||||||
|
models.append(result)
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
# For directories, continue scanning with original path
|
|
||||||
await scan_recursive(entry.path, visited_paths)
|
await scan_recursive(entry.path, visited_paths)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing entry {entry.path}: {e}")
|
logger.error(f"Error processing entry {entry.path}: {e}")
|
||||||
@@ -583,15 +597,6 @@ class ModelScanner:
|
|||||||
|
|
||||||
await scan_recursive(root_path, set())
|
await scan_recursive(root_path, set())
|
||||||
return models
|
return models
|
||||||
|
|
||||||
async def _process_single_file(self, file_path: str, root_path: str, models: list):
|
|
||||||
"""Process a single file and add to results list"""
|
|
||||||
try:
|
|
||||||
result = await self._process_model_file(file_path, root_path)
|
|
||||||
if result:
|
|
||||||
models.append(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {file_path}: {e}")
|
|
||||||
|
|
||||||
def is_initializing(self) -> bool:
|
def is_initializing(self) -> bool:
|
||||||
"""Check if the scanner is currently initializing"""
|
"""Check if the scanner is currently initializing"""
|
||||||
@@ -613,7 +618,10 @@ class ModelScanner:
|
|||||||
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
# Common methods shared between scanners
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
|
"""Hook for subclasses: adjust metadata during scanning"""
|
||||||
|
return metadata
|
||||||
|
|
||||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||||
"""Process a single model file and return its metadata"""
|
"""Process a single model file and return its metadata"""
|
||||||
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||||
@@ -667,12 +675,23 @@ class ModelScanner:
|
|||||||
if metadata is None:
|
if metadata is None:
|
||||||
metadata = await self._create_default_metadata(file_path)
|
metadata = await self._create_default_metadata(file_path)
|
||||||
|
|
||||||
|
# Hook: allow subclasses to adjust metadata
|
||||||
|
metadata = self.adjust_metadata(metadata, file_path, root_path)
|
||||||
|
|
||||||
model_data = metadata.to_dict()
|
model_data = metadata.to_dict()
|
||||||
|
|
||||||
# Skip excluded models
|
# Skip excluded models
|
||||||
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)
|
||||||
@@ -732,48 +751,6 @@ class ModelScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||||
|
|
||||||
async def _scan_directory(self, root_path: str) -> List[Dict]:
|
|
||||||
"""Base implementation for directory scanning"""
|
|
||||||
models = []
|
|
||||||
original_root = root_path
|
|
||||||
|
|
||||||
async def scan_recursive(path: str, visited_paths: set):
|
|
||||||
try:
|
|
||||||
real_path = os.path.realpath(path)
|
|
||||||
if real_path in visited_paths:
|
|
||||||
logger.debug(f"Skipping already visited path: {path}")
|
|
||||||
return
|
|
||||||
visited_paths.add(real_path)
|
|
||||||
|
|
||||||
with os.scandir(path) as it:
|
|
||||||
entries = list(it)
|
|
||||||
for entry in entries:
|
|
||||||
try:
|
|
||||||
if entry.is_file(follow_symlinks=True):
|
|
||||||
ext = os.path.splitext(entry.name)[1].lower()
|
|
||||||
if ext in self.file_extensions:
|
|
||||||
file_path = entry.path.replace(os.sep, "/")
|
|
||||||
await self._process_single_file(file_path, original_root, models)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
|
||||||
await scan_recursive(entry.path, visited_paths)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing entry {entry.path}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error scanning {path}: {e}")
|
|
||||||
|
|
||||||
await scan_recursive(root_path, set())
|
|
||||||
return models
|
|
||||||
|
|
||||||
async def _process_single_file(self, file_path: str, root_path: str, models_list: list):
|
|
||||||
"""Process a single file and add to results list"""
|
|
||||||
try:
|
|
||||||
result = await self._process_model_file(file_path, root_path)
|
|
||||||
if result:
|
|
||||||
models_list.append(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing {file_path}: {e}")
|
|
||||||
|
|
||||||
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
|
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
|
||||||
"""Add a model to the cache
|
"""Add a model to the cache
|
||||||
|
|
||||||
@@ -1194,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
|
||||||
"""
|
"""
|
||||||
@@ -1208,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}")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -43,7 +43,15 @@ class ExampleImagesFileManager:
|
|||||||
|
|
||||||
# Construct folder path for this model
|
# Construct folder path for this model
|
||||||
model_folder = os.path.join(example_images_path, model_hash)
|
model_folder = os.path.join(example_images_path, model_hash)
|
||||||
|
model_folder = os.path.abspath(model_folder) # Get absolute path
|
||||||
|
|
||||||
|
# Path validation: ensure model_folder is under example_images_path
|
||||||
|
if not model_folder.startswith(os.path.abspath(example_images_path)):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Invalid model folder path'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
# Check if folder exists
|
# Check if folder exists
|
||||||
if not os.path.exists(model_folder):
|
if not os.path.exists(model_folder):
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
import requests
|
|
||||||
import tempfile
|
|
||||||
import os
|
import os
|
||||||
from bs4 import BeautifulSoup
|
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):
|
||||||
@@ -50,82 +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 download_twitter_image(url):
|
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||||
"""Download image from a URL containing twitter:image meta tag
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url (str): The URL to download image from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Path to downloaded temporary image file
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Download page content
|
|
||||||
response = requests.get(url)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse HTML
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
|
|
||||||
# Find twitter:image meta tag
|
|
||||||
meta_tag = soup.find('meta', attrs={'property': 'twitter:image'})
|
|
||||||
if not meta_tag:
|
|
||||||
return None
|
|
||||||
|
|
||||||
image_url = meta_tag['content']
|
|
||||||
|
|
||||||
# Download image
|
|
||||||
image_response = requests.get(image_url)
|
|
||||||
image_response.raise_for_status()
|
|
||||||
|
|
||||||
# Save to temp file
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
|
||||||
temp_file.write(image_response.content)
|
|
||||||
return temp_file.name
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error downloading twitter image: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def download_civitai_image(url):
|
|
||||||
"""Download image from a URL containing avatar image with specific class and style attributes
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url (str): The URL to download image from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Path to downloaded temporary image file
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Download page content
|
|
||||||
response = requests.get(url)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse HTML
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
|
||||||
|
|
||||||
# Find image with specific class and style attributes
|
|
||||||
image = soup.select_one('img.EdgeImage_image__iH4_q.max-h-full.w-auto.max-w-full')
|
|
||||||
|
|
||||||
if not image or 'src' not in image.attrs:
|
|
||||||
return None
|
|
||||||
|
|
||||||
image_url = image['src']
|
|
||||||
|
|
||||||
# Download image
|
|
||||||
image_response = requests.get(image_url)
|
|
||||||
image_response.raise_for_status()
|
|
||||||
|
|
||||||
# Save to temp file
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
|
||||||
temp_file.write(image_response.content)
|
|
||||||
return temp_file.name
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error downloading civitai avatar: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> 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.
|
||||||
@@ -206,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
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
[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.24"
|
version = "0.8.27"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"safetensors",
|
"safetensors",
|
||||||
"beautifulsoup4",
|
|
||||||
"piexif",
|
"piexif",
|
||||||
"Pillow",
|
"Pillow",
|
||||||
"olefile", # for getting rid of warning message
|
"olefile", # for getting rid of warning message
|
||||||
"requests",
|
|
||||||
"toml",
|
"toml",
|
||||||
"natsort",
|
"natsort",
|
||||||
"GitPython"
|
"GitPython"
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
aiohttp
|
aiohttp
|
||||||
jinja2
|
jinja2
|
||||||
safetensors
|
safetensors
|
||||||
beautifulsoup4
|
|
||||||
piexif
|
piexif
|
||||||
Pillow
|
Pillow
|
||||||
olefile
|
olefile
|
||||||
requests
|
|
||||||
toml
|
toml
|
||||||
numpy
|
numpy
|
||||||
natsort
|
natsort
|
||||||
pyyaml
|
|
||||||
GitPython
|
GitPython
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,18 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive header container for larger screens */
|
||||||
|
@media (min-width: 2000px) {
|
||||||
|
.header-container {
|
||||||
|
max-width: 1800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
.header-container {
|
||||||
|
max-width: 2400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Logo and title styling */
|
/* Logo and title styling */
|
||||||
.header-branding {
|
.header-branding {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
514
static/css/components/modal/download-modal.css
Normal file
514
static/css/components/modal/download-modal.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 |
@@ -29,7 +29,7 @@ export const MODEL_CONFIG = {
|
|||||||
defaultPageSize: 100,
|
defaultPageSize: 100,
|
||||||
supportsLetterFilter: false,
|
supportsLetterFilter: false,
|
||||||
supportsBulkOperations: true,
|
supportsBulkOperations: true,
|
||||||
supportsMove: false,
|
supportsMove: true,
|
||||||
templateName: 'checkpoints.html'
|
templateName: 'checkpoints.html'
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
@@ -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`,
|
||||||
@@ -63,6 +63,10 @@ export function getApiEndpoints(modelType) {
|
|||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
bulkDelete: `/api/${modelType}/bulk-delete`,
|
bulkDelete: `/api/${modelType}/bulk-delete`,
|
||||||
|
|
||||||
|
// Move operations (now common for all model types that support move)
|
||||||
|
moveModel: `/api/${modelType}/move_model`,
|
||||||
|
moveBulk: `/api/${modelType}/move_models_bulk`,
|
||||||
|
|
||||||
// CivitAI integration
|
// CivitAI integration
|
||||||
fetchCivitai: `/api/${modelType}/fetch-civitai`,
|
fetchCivitai: `/api/${modelType}/fetch-civitai`,
|
||||||
@@ -79,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`,
|
||||||
@@ -99,14 +105,14 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
|||||||
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
||||||
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
||||||
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
|
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
|
||||||
moveModel: `/api/${MODEL_TYPES.LORA}/move_model`,
|
|
||||||
moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`,
|
|
||||||
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
||||||
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
||||||
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.CHECKPOINT]: {
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
||||||
|
checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
|
||||||
|
unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import {
|
|||||||
DOWNLOAD_ENDPOINTS,
|
DOWNLOAD_ENDPOINTS,
|
||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Universal API client for all model types
|
* Abstract base class for all model API clients
|
||||||
*/
|
*/
|
||||||
class ModelApiClient {
|
export class BaseModelApiClient {
|
||||||
constructor(modelType = null) {
|
constructor(modelType = null) {
|
||||||
|
if (this.constructor === BaseModelApiClient) {
|
||||||
|
throw new Error("BaseModelApiClient is abstract and cannot be instantiated directly");
|
||||||
|
}
|
||||||
this.modelType = modelType || getCurrentModelType();
|
this.modelType = modelType || getCurrentModelType();
|
||||||
this.apiConfig = getCompleteApiConfig(this.modelType);
|
this.apiConfig = getCompleteApiConfig(this.modelType);
|
||||||
}
|
}
|
||||||
@@ -42,9 +46,6 @@ class ModelApiClient {
|
|||||||
return pageState;
|
return pageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch models with pagination
|
|
||||||
*/
|
|
||||||
async fetchModelsPage(page = 1, pageSize = null) {
|
async fetchModelsPage(page = 1, pageSize = null) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||||
@@ -79,9 +80,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset and reload models with virtual scrolling
|
|
||||||
*/
|
|
||||||
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
@@ -93,26 +91,27 @@ class ModelApiClient {
|
|||||||
pageState.currentPage = 1; // Reset to first page
|
pageState.currentPage = 1; // Reset to first page
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the current 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`);
|
|
||||||
|
|
||||||
// Update the virtual scroller
|
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
result.items,
|
result.items,
|
||||||
result.totalItems,
|
result.totalItems,
|
||||||
result.hasMore
|
result.hasMore
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state
|
|
||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = pageState.currentPage + 1;
|
pageState.currentPage = pageState.currentPage + 1;
|
||||||
|
|
||||||
// Update folders if needed
|
if (updateFolders) {
|
||||||
if (updateFolders && result.folders) {
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
updateFolderTags(result.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;
|
||||||
@@ -126,9 +125,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a model
|
|
||||||
*/
|
|
||||||
async deleteModel(filePath) {
|
async deleteModel(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`);
|
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`);
|
||||||
@@ -163,9 +159,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exclude a model
|
|
||||||
*/
|
|
||||||
async excludeModel(filePath) {
|
async excludeModel(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`);
|
state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`);
|
||||||
@@ -200,9 +193,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rename a model file
|
|
||||||
*/
|
|
||||||
async renameModelFile(filePath, newFileName) {
|
async renameModelFile(filePath, newFileName) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||||
@@ -239,9 +229,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace model preview
|
|
||||||
*/
|
|
||||||
replaceModelPreview(filePath) {
|
replaceModelPreview(filePath) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
@@ -257,9 +244,6 @@ class ModelApiClient {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload preview image
|
|
||||||
*/
|
|
||||||
async uploadPreview(filePath, file, nsfwLevel = 0) {
|
async uploadPreview(filePath, file, nsfwLevel = 0) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Uploading preview...');
|
state.loadingManager.showSimpleLoading('Uploading preview...');
|
||||||
@@ -281,7 +265,6 @@ class ModelApiClient {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
// Update the version timestamp
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
if (pageState.previewVersions) {
|
if (pageState.previewVersions) {
|
||||||
pageState.previewVersions.set(filePath, timestamp);
|
pageState.previewVersions.set(filePath, timestamp);
|
||||||
@@ -305,9 +288,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save model metadata
|
|
||||||
*/
|
|
||||||
async saveModelMetadata(filePath, data) {
|
async saveModelMetadata(filePath, data) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||||
@@ -332,9 +312,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh models (scan)
|
|
||||||
*/
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(
|
state.loadingManager.showSimpleLoading(
|
||||||
@@ -349,6 +326,8 @@ class ModelApiClient {
|
|||||||
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) {
|
||||||
@@ -360,9 +339,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CivitAI metadata for single model
|
|
||||||
*/
|
|
||||||
async refreshSingleModelMetadata(filePath) {
|
async refreshSingleModelMetadata(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
||||||
@@ -399,9 +375,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CivitAI metadata for all models
|
|
||||||
*/
|
|
||||||
async fetchCivitaiMetadata() {
|
async fetchCivitaiMetadata() {
|
||||||
let ws = null;
|
let ws = null;
|
||||||
|
|
||||||
@@ -477,9 +450,6 @@ class ModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CivitAI metadata for multiple models with progress tracking
|
|
||||||
*/
|
|
||||||
async refreshBulkModelMetadata(filePaths) {
|
async refreshBulkModelMetadata(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
@@ -493,7 +463,6 @@ class ModelApiClient {
|
|||||||
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process files sequentially to avoid overwhelming the API
|
|
||||||
for (let i = 0; i < filePaths.length; i++) {
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
const filePath = filePaths[i];
|
const filePath = filePaths[i];
|
||||||
const fileName = filePath.split('/').pop();
|
const fileName = filePath.split('/').pop();
|
||||||
@@ -535,7 +504,6 @@ class ModelApiClient {
|
|||||||
processedCount++;
|
processedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show completion message
|
|
||||||
let completionMessage;
|
let completionMessage;
|
||||||
if (successCount === totalItems) {
|
if (successCount === totalItems) {
|
||||||
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
||||||
@@ -575,113 +543,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Move a single model to target path
|
|
||||||
* @returns {string|null} - The new file path if moved, null if not moved
|
|
||||||
*/
|
|
||||||
async moveSingleModel(filePath, targetPath) {
|
|
||||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
|
||||||
showToast('Model is already in the selected folder', 'info');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.specific.moveModel, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_path: filePath,
|
|
||||||
target_path: targetPath
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (result && result.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
throw new Error('Failed to move model');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result && result.message) {
|
|
||||||
showToast(result.message, 'info');
|
|
||||||
} else {
|
|
||||||
showToast('Model moved successfully', 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return new file path if move succeeded
|
|
||||||
if (result.success) {
|
|
||||||
return result.new_file_path;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move multiple models to target path
|
|
||||||
* @returns {Array<string>} - Array of new file paths that were moved successfully
|
|
||||||
*/
|
|
||||||
async moveBulkModels(filePaths, targetPath) {
|
|
||||||
const movedPaths = filePaths.filter(path => {
|
|
||||||
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (movedPaths.length === 0) {
|
|
||||||
showToast('All selected models are already in the target folder', 'info');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.specific.moveBulk, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_paths: movedPaths,
|
|
||||||
target_path: targetPath
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to move models');
|
|
||||||
}
|
|
||||||
|
|
||||||
let successFilePaths = [];
|
|
||||||
if (result.success) {
|
|
||||||
if (result.failure_count > 0) {
|
|
||||||
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
|
|
||||||
console.log('Move operation results:', result.results);
|
|
||||||
const failedFiles = result.results
|
|
||||||
.filter(r => !r.success)
|
|
||||||
.map(r => {
|
|
||||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
|
||||||
return `${fileName}: ${r.message}`;
|
|
||||||
});
|
|
||||||
if (failedFiles.length > 0) {
|
|
||||||
const failureMessage = failedFiles.length <= 3
|
|
||||||
? failedFiles.join('\n')
|
|
||||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
|
||||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showToast(`Successfully moved ${result.success_count} models`, 'success');
|
|
||||||
}
|
|
||||||
// Collect new file paths for successful moves
|
|
||||||
successFilePaths = result.results
|
|
||||||
.filter(r => r.success)
|
|
||||||
.map(r => r.path);
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message || 'Failed to move models');
|
|
||||||
}
|
|
||||||
return successFilePaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch Civitai model versions
|
|
||||||
*/
|
|
||||||
async fetchCivitaiVersions(modelId) {
|
async fetchCivitaiVersions(modelId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`);
|
const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`);
|
||||||
@@ -699,9 +560,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch model roots
|
|
||||||
*/
|
|
||||||
async fetchModelRoots() {
|
async fetchModelRoots() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.apiConfig.endpoints.roots);
|
const response = await fetch(this.apiConfig.endpoints.roots);
|
||||||
@@ -715,9 +573,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch model folders
|
|
||||||
*/
|
|
||||||
async fetchModelFolders() {
|
async fetchModelFolders() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.apiConfig.endpoints.folders);
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
@@ -731,10 +586,34 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async fetchUnifiedFolderTree() {
|
||||||
* Download a model
|
try {
|
||||||
*/
|
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||||
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
|
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',
|
||||||
@@ -744,6 +623,7 @@ class ModelApiClient {
|
|||||||
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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -759,13 +639,9 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build query parameters for API requests
|
|
||||||
*/
|
|
||||||
_buildQueryParams(baseParams, pageState) {
|
_buildQueryParams(baseParams, pageState) {
|
||||||
const params = new URLSearchParams(baseParams);
|
const params = new URLSearchParams(baseParams);
|
||||||
|
|
||||||
// Add common parameters
|
|
||||||
if (pageState.activeFolder !== null) {
|
if (pageState.activeFolder !== null) {
|
||||||
params.append('folder', pageState.activeFolder);
|
params.append('folder', pageState.activeFolder);
|
||||||
}
|
}
|
||||||
@@ -774,12 +650,10 @@ class ModelApiClient {
|
|||||||
params.append('favorites_only', 'true');
|
params.append('favorites_only', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add letter filter for supported model types
|
|
||||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||||
params.append('first_letter', pageState.activeLetterFilter);
|
params.append('first_letter', pageState.activeLetterFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add search parameters
|
|
||||||
if (pageState.filters?.search) {
|
if (pageState.filters?.search) {
|
||||||
params.append('search', pageState.filters.search);
|
params.append('search', pageState.filters.search);
|
||||||
params.append('fuzzy', 'true');
|
params.append('fuzzy', 'true');
|
||||||
@@ -790,11 +664,13 @@ class ModelApiClient {
|
|||||||
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add filter parameters
|
|
||||||
if (pageState.filters) {
|
if (pageState.filters) {
|
||||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||||
pageState.filters.tags.forEach(tag => {
|
pageState.filters.tags.forEach(tag => {
|
||||||
@@ -809,17 +685,12 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add model-specific parameters
|
|
||||||
this._addModelSpecificParams(params, pageState);
|
this._addModelSpecificParams(params, pageState);
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add model-specific parameters to query
|
|
||||||
*/
|
|
||||||
_addModelSpecificParams(params, pageState) {
|
_addModelSpecificParams(params, pageState) {
|
||||||
// Override in specific implementations or handle via configuration
|
|
||||||
if (this.modelType === 'loras') {
|
if (this.modelType === 'loras') {
|
||||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
@@ -837,23 +708,149 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Export factory functions and utilities
|
async moveSingleModel(filePath, targetPath) {
|
||||||
export function createModelApiClient(modelType = null) {
|
// Only allow move if supported
|
||||||
return new ModelApiClient(modelType);
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
}
|
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
||||||
|
showToast(`${this.apiConfig.config.displayName} is already in the selected folder`, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let _singletonClient = null;
|
const response = await fetch(this.apiConfig.endpoints.moveModel, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_path: filePath,
|
||||||
|
target_path: targetPath
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
export function getModelApiClient() {
|
const result = await response.json();
|
||||||
if (!_singletonClient) {
|
|
||||||
_singletonClient = new ModelApiClient();
|
if (!response.ok) {
|
||||||
|
if (result && result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to move ${this.apiConfig.config.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && result.message) {
|
||||||
|
showToast(result.message, 'info');
|
||||||
|
} else {
|
||||||
|
showToast(`${this.apiConfig.config.displayName} moved successfully`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result.new_file_path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
_singletonClient.setModelType(state.currentPageType);
|
|
||||||
return _singletonClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetAndReload(updateFolders = false) {
|
async moveBulkModels(filePaths, targetPath) {
|
||||||
return getModelApiClient().loadMoreWithVirtualScroll(true, updateFolders);
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
|
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const movedPaths = filePaths.filter(path => {
|
||||||
|
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (movedPaths.length === 0) {
|
||||||
|
showToast(`All selected ${this.apiConfig.config.displayName}s are already in the target folder`, 'info');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_paths: movedPaths,
|
||||||
|
target_path: targetPath
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let successFilePaths = [];
|
||||||
|
if (result.success) {
|
||||||
|
if (result.failure_count > 0) {
|
||||||
|
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
|
||||||
|
console.log('Move operation results:', result.results);
|
||||||
|
const failedFiles = result.results
|
||||||
|
.filter(r => !r.success)
|
||||||
|
.map(r => {
|
||||||
|
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||||
|
return `${fileName}: ${r.message}`;
|
||||||
|
});
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
const failureMessage = failedFiles.length <= 3
|
||||||
|
? failedFiles.join('\n')
|
||||||
|
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||||
|
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
|
||||||
|
}
|
||||||
|
successFilePaths = result.results
|
||||||
|
.filter(r => r.success)
|
||||||
|
.map(r => r.path);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
|
}
|
||||||
|
return successFilePaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDeleteModels(filePaths) {
|
||||||
|
if (!filePaths || filePaths.length === 0) {
|
||||||
|
throw new Error('No file paths provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_paths: filePaths
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deleted_count: result.deleted_count,
|
||||||
|
failed_count: result.failed_count || 0,
|
||||||
|
errors: result.errors || []
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
93
static/js/api/checkpointApi.js
Normal file
93
static/js/api/checkpointApi.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkpoint-specific API client
|
||||||
|
*/
|
||||||
|
export class CheckpointApiClient extends BaseModelApiClient {
|
||||||
|
/**
|
||||||
|
* Get checkpoint information
|
||||||
|
*/
|
||||||
|
async getCheckpointInfo(filePath) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.info, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ file_path: filePath })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch checkpoint info');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching checkpoint info:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get checkpoint roots
|
||||||
|
*/
|
||||||
|
async getCheckpointsRoots() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.checkpoints_roots, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch checkpoints roots');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching checkpoints roots:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unet roots
|
||||||
|
*/
|
||||||
|
async getUnetRoots() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.unet_roots, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch unet roots');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unet roots:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate roots based on model type
|
||||||
|
*/
|
||||||
|
async fetchModelRoots(modelType = 'checkpoint') {
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (modelType === 'diffusion_model') {
|
||||||
|
response = await fetch(this.apiConfig.endpoints.specific.unet_roots, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(this.apiConfig.endpoints.specific.checkpoints_roots, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${modelType} roots`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${modelType} roots:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
static/js/api/embeddingApi.js
Normal file
8
static/js/api/embeddingApi.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedding-specific API client
|
||||||
|
*/
|
||||||
|
export class EmbeddingApiClient extends BaseModelApiClient {
|
||||||
|
}
|
||||||
94
static/js/api/loraApi.js
Normal file
94
static/js/api/loraApi.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { getSessionItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoRA-specific API client
|
||||||
|
*/
|
||||||
|
export class LoraApiClient extends BaseModelApiClient {
|
||||||
|
/**
|
||||||
|
* Add LoRA-specific parameters to query
|
||||||
|
*/
|
||||||
|
_addModelSpecificParams(params, pageState) {
|
||||||
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
|
|
||||||
|
if (filterLoraHash) {
|
||||||
|
params.append('lora_hash', filterLoraHash);
|
||||||
|
} else if (filterLoraHashes) {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
|
||||||
|
params.append('lora_hashes', filterLoraHashes.join(','));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing lora hashes from session storage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LoRA notes
|
||||||
|
*/
|
||||||
|
async getLoraNote(filePath) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.notes,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ file_path: filePath })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch LoRA notes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching LoRA notes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LoRA trigger words
|
||||||
|
*/
|
||||||
|
async getLoraTriggerWords(filePath) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.triggerWords, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ file_path: filePath })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch trigger words');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching trigger words:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get letter counts for LoRAs
|
||||||
|
*/
|
||||||
|
async getLetterCounts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.letterCounts);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch letter counts');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching letter counts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
static/js/api/modelApiFactory.js
Normal file
35
static/js/api/modelApiFactory.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { LoraApiClient } from './loraApi.js';
|
||||||
|
import { CheckpointApiClient } from './checkpointApi.js';
|
||||||
|
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||||
|
import { MODEL_TYPES } from './apiConfig.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
|
export function createModelApiClient(modelType) {
|
||||||
|
switch (modelType) {
|
||||||
|
case MODEL_TYPES.LORA:
|
||||||
|
return new LoraApiClient();
|
||||||
|
case MODEL_TYPES.CHECKPOINT:
|
||||||
|
return new CheckpointApiClient();
|
||||||
|
case MODEL_TYPES.EMBEDDING:
|
||||||
|
return new EmbeddingApiClient();
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported model type: ${modelType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _singletonClients = new Map();
|
||||||
|
|
||||||
|
export function getModelApiClient(modelType = null) {
|
||||||
|
const targetType = modelType || state.currentPageType;
|
||||||
|
|
||||||
|
if (!_singletonClients.has(targetType)) {
|
||||||
|
_singletonClients.set(targetType, createModelApiClient(targetType));
|
||||||
|
}
|
||||||
|
|
||||||
|
return _singletonClients.get(targetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAndReload(updateFolders = false) {
|
||||||
|
const client = getModelApiClient();
|
||||||
|
return client.loadMoreWithVirtualScroll(true, updateFolders);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
|
||||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class CheckpointContextMenu extends BaseContextMenu {
|
export class CheckpointContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -54,8 +54,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
case 'move':
|
case 'move':
|
||||||
// Move to folder (placeholder)
|
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.model_type);
|
||||||
showToast('Move to folder feature coming soon', 'info');
|
|
||||||
break;
|
break;
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
|
|
||||||
export class EmbeddingContextMenu extends BaseContextMenu {
|
export class EmbeddingContextMenu extends BaseContextMenu {
|
||||||
@@ -54,8 +54,7 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
|||||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
case 'move':
|
case 'move':
|
||||||
// Move to folder (placeholder)
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
showToast('Move to folder feature coming soon', 'info');
|
|
||||||
break;
|
break;
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
import { copyLoraSyntax, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class LoraContextMenu extends BaseContextMenu {
|
export class LoraContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -36,7 +37,7 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
break;
|
break;
|
||||||
case 'copyname':
|
case 'copyname':
|
||||||
// Generate and copy LoRA syntax
|
// Generate and copy LoRA syntax
|
||||||
this.copyLoraSyntax();
|
copyLoraSyntax(this.currentCard);
|
||||||
break;
|
break;
|
||||||
case 'sendappend':
|
case 'sendappend':
|
||||||
// Send LoRA to workflow (append mode)
|
// Send LoRA to workflow (append mode)
|
||||||
@@ -66,16 +67,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specific LoRA methods
|
|
||||||
copyLoraSyntax() {
|
|
||||||
const card = this.currentCard;
|
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
|
||||||
const strength = usageTips.strength || 1;
|
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
|
||||||
|
|
||||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
sendLoraToWorkflow(replaceMode) {
|
sendLoraToWorkflow(replaceMode) {
|
||||||
const card = this.currentCard;
|
const card = this.currentCard;
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
|
|||||||
585
static/js/components/FolderTreeManager.js
Normal file
585
static/js/components/FolderTreeManager.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { formatDate } from '../utils/formatters.js';
|
import { formatDate } from '../utils/formatters.js';
|
||||||
import { resetAndReload} from '../api/baseModelApi.js';
|
import { resetAndReload} from '../api/modelApiFactory.js';
|
||||||
import { LoadingManager } from '../managers/LoadingManager.js';
|
import { LoadingManager } from '../managers/LoadingManager.js';
|
||||||
|
|
||||||
export class ModelDuplicatesManager {
|
export class ModelDuplicatesManager {
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// AlphabetBar.js - Component for alphabet filtering
|
// AlphabetBar.js - Component for alphabet filtering
|
||||||
import { getCurrentPageState } from '../../state/index.js';
|
import { getCurrentPageState } from '../../state/index.js';
|
||||||
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
|
||||||
import { resetAndReload } from '../../api/baseModelApi.js';
|
import { resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AlphabetBar class - Handles the alphabet filtering UI and interactions
|
* AlphabetBar class - Handles the alphabet filtering UI and interactions
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// CheckpointsControls.js - Specific implementation for the Checkpoints page
|
// CheckpointsControls.js - Specific implementation for the Checkpoints page
|
||||||
import { PageControls } from './PageControls.js';
|
import { PageControls } from './PageControls.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// EmbeddingsControls.js - Specific implementation for the Embeddings page
|
// EmbeddingsControls.js - Specific implementation for the Embeddings page
|
||||||
import { PageControls } from './PageControls.js';
|
import { PageControls } from './PageControls.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// LorasControls.js - Specific implementation for the LoRAs page
|
// LorasControls.js - Specific implementation for the LoRAs page
|
||||||
import { PageControls } from './PageControls.js';
|
import { PageControls } from './PageControls.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { createAlphabetBar } from '../alphabet/index.js';
|
import { createAlphabetBar } from '../alphabet/index.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
|
|
||||||
// Add global event delegation handlers
|
// Add global event delegation handlers
|
||||||
@@ -152,7 +153,7 @@ async function toggleFavorite(card) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||||
if (modelType === 'loras') {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||||
@@ -164,16 +165,13 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCopyAction(card, modelType) {
|
function handleCopyAction(card, modelType) {
|
||||||
if (modelType === 'loras') {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
copyLoraSyntax(card);
|
||||||
const strength = usageTips.strength || 1;
|
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
|
||||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
|
||||||
} else if (modelType === 'checkpoints') {
|
|
||||||
// Checkpoint copy functionality - copy checkpoint name
|
// Checkpoint copy functionality - copy checkpoint name
|
||||||
const checkpointName = card.dataset.file_name;
|
const checkpointName = card.dataset.file_name;
|
||||||
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
||||||
} else if (modelType === 'embeddings') {
|
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||||
const embeddingName = card.dataset.file_name;
|
const embeddingName = card.dataset.file_name;
|
||||||
copyToClipboard(embeddingName, 'Embedding name copied');
|
copyToClipboard(embeddingName, 'Embedding name copied');
|
||||||
}
|
}
|
||||||
@@ -243,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,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -377,10 +375,15 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||||
|
|
||||||
// LoRA specific data
|
// LoRA specific data
|
||||||
if (modelType === 'loras') {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
card.dataset.usage_tips = model.usage_tips;
|
card.dataset.usage_tips = model.usage_tips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkpoint specific data
|
||||||
|
if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||||
|
card.dataset.model_type = model.model_type; // checkpoint or diffusion_model
|
||||||
|
}
|
||||||
|
|
||||||
// Store metadata if available
|
// Store metadata if available
|
||||||
if (model.civitai) {
|
if (model.civitai) {
|
||||||
card.dataset.meta = JSON.stringify(model.civitai || {});
|
card.dataset.meta = JSON.stringify(model.civitai || {});
|
||||||
@@ -406,7 +409,7 @@ export function createModelCard(model, modelType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||||
if (modelType === 'loras' && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function setupModelDescriptionEditing(filePath) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Save to backend
|
// Save to backend
|
||||||
const { getModelApiClient } = await import('../../api/baseModelApi.js');
|
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
||||||
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
|
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
|
||||||
showToast('Model description updated', 'success');
|
showToast('Model description updated', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { BASE_MODELS } from '../../utils/constants.js';
|
import { BASE_MODELS } from '../../utils/constants.js';
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up model name editing functionality
|
* Set up model name editing functionality
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
setupFileNameEditing
|
setupFileNameEditing
|
||||||
} from './ModelMetadata.js';
|
} from './ModelMetadata.js';
|
||||||
import { setupTagEditMode } from './ModelTags.js';
|
import { setupTagEditMode } from './ModelTags.js';
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Module for handling model tag editing functionality - 共享版本
|
* Module for handling model tag editing functionality - 共享版本
|
||||||
*/
|
*/
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
// Preset tag suggestions
|
// Preset tag suggestions
|
||||||
const PRESET_TAGS = [
|
const PRESET_TAGS = [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* PresetTags.js
|
* PresetTags.js
|
||||||
* Handles LoRA model preset parameter tags - Shared version
|
* Handles LoRA model preset parameter tags - Shared version
|
||||||
*/
|
*/
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse preset parameters
|
* Parse preset parameters
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Moved to shared directory for consistency
|
* Moved to shared directory for consistency
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch trained words for a model
|
* Fetch trained words for a model
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
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 { getModelApiClient } from '../../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to load local image first, fall back to remote if local fails
|
* Try to load local image first, fall back to remote if local fails
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { modalManager } from './managers/ModalManager.js';
|
|||||||
import { updateService } from './managers/UpdateService.js';
|
import { updateService } from './managers/UpdateService.js';
|
||||||
import { HeaderManager } from './components/Header.js';
|
import { HeaderManager } from './components/Header.js';
|
||||||
import { settingsManager } from './managers/SettingsManager.js';
|
import { settingsManager } from './managers/SettingsManager.js';
|
||||||
|
import { moveManager } from './managers/MoveManager.js';
|
||||||
|
import { bulkManager } from './managers/BulkManager.js';
|
||||||
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from './managers/HelpManager.js';
|
import { helpManager } from './managers/HelpManager.js';
|
||||||
import { bannerService } from './managers/BannerService.js';
|
import { bannerService } from './managers/BannerService.js';
|
||||||
@@ -33,11 +35,16 @@ export class AppCore {
|
|||||||
window.settingsManager = settingsManager;
|
window.settingsManager = settingsManager;
|
||||||
window.exampleImagesManager = exampleImagesManager;
|
window.exampleImagesManager = exampleImagesManager;
|
||||||
window.helpManager = helpManager;
|
window.helpManager = helpManager;
|
||||||
|
window.moveManager = moveManager;
|
||||||
|
window.bulkManager = bulkManager;
|
||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
|
// Initialize the bulk manager
|
||||||
|
bulkManager.initialize();
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { state } from './state/index.js';
|
import { state } from './state/index.js';
|
||||||
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
||||||
import { bulkManager } from './managers/BulkManager.js';
|
|
||||||
import { moveManager } from './managers/MoveManager.js';
|
|
||||||
import { LoraContextMenu } from './components/ContextMenu/index.js';
|
import { LoraContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
@@ -33,15 +31,6 @@ class LoraPageManager {
|
|||||||
window.closeDeleteModal = closeDeleteModal;
|
window.closeDeleteModal = closeDeleteModal;
|
||||||
window.confirmExclude = confirmExclude;
|
window.confirmExclude = confirmExclude;
|
||||||
window.closeExcludeModal = closeExcludeModal;
|
window.closeExcludeModal = closeExcludeModal;
|
||||||
window.moveManager = moveManager;
|
|
||||||
|
|
||||||
// Bulk operations
|
|
||||||
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
|
|
||||||
window.clearSelection = () => bulkManager.clearSelection();
|
|
||||||
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
|
|
||||||
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
|
|
||||||
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
|
|
||||||
window.bulkManager = bulkManager;
|
|
||||||
|
|
||||||
// Expose duplicates manager
|
// Expose duplicates manager
|
||||||
window.modelDuplicatesManager = this.duplicatesManager;
|
window.modelDuplicatesManager = this.duplicatesManager;
|
||||||
@@ -56,9 +45,6 @@ class LoraPageManager {
|
|||||||
// Initialize cards for current bulk mode state (should be false initially)
|
// Initialize cards for current bulk mode state (should be false initially)
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
// Initialize the bulk manager
|
|
||||||
bulkManager.initialize();
|
|
||||||
|
|
||||||
// Initialize common page features (virtual scroll)
|
// Initialize common page features (virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,147 +1,201 @@
|
|||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
import { moveManager } from './MoveManager.js';
|
||||||
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
|
|
||||||
export class BulkManager {
|
export class BulkManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.bulkBtn = document.getElementById('bulkOperationsBtn');
|
this.bulkBtn = document.getElementById('bulkOperationsBtn');
|
||||||
this.bulkPanel = document.getElementById('bulkOperationsPanel');
|
this.bulkPanel = document.getElementById('bulkOperationsPanel');
|
||||||
this.isStripVisible = false; // Track strip visibility state
|
this.isStripVisible = false;
|
||||||
|
|
||||||
// Initialize selected loras set in state if not already there
|
this.stripMaxThumbnails = 50;
|
||||||
if (!state.selectedLoras) {
|
|
||||||
state.selectedLoras = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for lora metadata to handle non-visible selected loras
|
// Model type specific action configurations
|
||||||
if (!state.loraMetadataCache) {
|
this.actionConfig = {
|
||||||
state.loraMetadataCache = new Map();
|
[MODEL_TYPES.LORA]: {
|
||||||
}
|
sendToWorkflow: true,
|
||||||
|
copyAll: true,
|
||||||
this.stripMaxThumbnails = 50; // Maximum thumbnails to show in strip
|
refreshAll: true,
|
||||||
|
moveAll: true,
|
||||||
|
deleteAll: true
|
||||||
|
},
|
||||||
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
|
sendToWorkflow: false,
|
||||||
|
copyAll: false,
|
||||||
|
refreshAll: true,
|
||||||
|
moveAll: true,
|
||||||
|
deleteAll: true
|
||||||
|
},
|
||||||
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
|
sendToWorkflow: false,
|
||||||
|
copyAll: false,
|
||||||
|
refreshAll: true,
|
||||||
|
moveAll: false,
|
||||||
|
deleteAll: true
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Add event listeners if needed
|
this.setupEventListeners();
|
||||||
// (Already handled via onclick attributes in HTML, but could be moved here)
|
this.setupGlobalKeyboardListeners();
|
||||||
|
}
|
||||||
// Add event listeners for the selected count to toggle thumbnail strip
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Bulk operations button listeners
|
||||||
|
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
|
||||||
|
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
|
||||||
|
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
|
||||||
|
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
|
||||||
|
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
|
||||||
|
const clearBtn = this.bulkPanel?.querySelector('[data-action="clear"]');
|
||||||
|
|
||||||
|
if (sendToWorkflowBtn) {
|
||||||
|
sendToWorkflowBtn.addEventListener('click', () => this.sendAllModelsToWorkflow());
|
||||||
|
}
|
||||||
|
if (copyAllBtn) {
|
||||||
|
copyAllBtn.addEventListener('click', () => this.copyAllModelsSyntax());
|
||||||
|
}
|
||||||
|
if (refreshAllBtn) {
|
||||||
|
refreshAllBtn.addEventListener('click', () => this.refreshAllMetadata());
|
||||||
|
}
|
||||||
|
if (moveAllBtn) {
|
||||||
|
moveAllBtn.addEventListener('click', () => {
|
||||||
|
moveManager.showMoveModal('bulk');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (deleteAllBtn) {
|
||||||
|
deleteAllBtn.addEventListener('click', () => this.showBulkDeleteModal());
|
||||||
|
}
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => this.clearSelection());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected count click listener
|
||||||
const selectedCount = document.getElementById('selectedCount');
|
const selectedCount = document.getElementById('selectedCount');
|
||||||
if (selectedCount) {
|
if (selectedCount) {
|
||||||
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
|
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add global keyboard event listener for Ctrl+A
|
setupGlobalKeyboardListeners() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// First check if any modal is currently open - if so, don't handle Ctrl+A
|
|
||||||
if (modalManager.isAnyModalOpen()) {
|
if (modalManager.isAnyModalOpen()) {
|
||||||
return; // Exit early - let the browser handle Ctrl+A within the modal
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if search input is currently focused - if so, don't handle Ctrl+A
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
if (searchInput && document.activeElement === searchInput) {
|
if (searchInput && document.activeElement === searchInput) {
|
||||||
return; // Exit early - let the browser handle Ctrl+A within the search input
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's Ctrl+A (or Cmd+A on Mac)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||||
|
|
||||||
// Prevent default browser "Select All" behavior
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// If not in bulk mode, enable it first
|
|
||||||
if (!state.bulkMode) {
|
if (!state.bulkMode) {
|
||||||
this.toggleBulkMode();
|
this.toggleBulkMode();
|
||||||
// Small delay to ensure DOM is updated
|
setTimeout(() => this.selectAllVisibleModels(), 50);
|
||||||
setTimeout(() => this.selectAllVisibleLoras(), 50);
|
|
||||||
} else {
|
} else {
|
||||||
this.selectAllVisibleLoras();
|
this.selectAllVisibleModels();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape' && state.bulkMode) {
|
} else if (e.key === 'Escape' && state.bulkMode) {
|
||||||
// If in bulk mode, exit it on Escape
|
|
||||||
this.toggleBulkMode();
|
this.toggleBulkMode();
|
||||||
} else if (e.key.toLowerCase() === 'b') {
|
} else if (e.key.toLowerCase() === 'b') {
|
||||||
// If 'b' is pressed, toggle bulk mode
|
|
||||||
this.toggleBulkMode();
|
this.toggleBulkMode();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBulkMode() {
|
toggleBulkMode() {
|
||||||
// Toggle the state
|
|
||||||
state.bulkMode = !state.bulkMode;
|
state.bulkMode = !state.bulkMode;
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||||
|
|
||||||
// Important: Remove the hidden class when entering bulk mode
|
|
||||||
if (state.bulkMode) {
|
if (state.bulkMode) {
|
||||||
this.bulkPanel.classList.remove('hidden');
|
this.bulkPanel.classList.remove('hidden');
|
||||||
// Use setTimeout to ensure the DOM updates before adding visible class
|
this.updateActionButtonsVisibility();
|
||||||
// This helps with the transition animation
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.bulkPanel.classList.add('visible');
|
this.bulkPanel.classList.add('visible');
|
||||||
}, 10);
|
}, 10);
|
||||||
} else {
|
} else {
|
||||||
this.bulkPanel.classList.remove('visible');
|
this.bulkPanel.classList.remove('visible');
|
||||||
// Add hidden class back after transition completes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.bulkPanel.classList.add('hidden');
|
this.bulkPanel.classList.add('hidden');
|
||||||
}, 400); // Match this with the transition duration in CSS
|
}, 400);
|
||||||
|
|
||||||
// Hide thumbnail strip if it's visible
|
|
||||||
this.hideThumbnailStrip();
|
this.hideThumbnailStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
// First update all cards' visual state before clearing selection
|
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
// Clear selection if exiting bulk mode - do this after updating cards
|
|
||||||
if (!state.bulkMode) {
|
if (!state.bulkMode) {
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
|
||||||
// TODO: fix this, no DOM manipulation should be done here
|
// TODO:
|
||||||
// Force a lightweight refresh of the cards to ensure proper display
|
|
||||||
// This is less disruptive than a full resetAndReload()
|
|
||||||
document.querySelectorAll('.model-card').forEach(card => {
|
document.querySelectorAll('.model-card').forEach(card => {
|
||||||
// Re-apply normal display mode to all card actions
|
|
||||||
const actions = card.querySelectorAll('.card-actions, .card-button');
|
const actions = card.querySelectorAll('.card-actions, .card-button');
|
||||||
actions.forEach(action => action.style.display = 'flex');
|
actions.forEach(action => action.style.display = 'flex');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateActionButtonsVisibility() {
|
||||||
|
const currentModelType = state.currentPageType;
|
||||||
|
const config = this.actionConfig[currentModelType];
|
||||||
|
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
// Update button visibility based on model type
|
||||||
|
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
|
||||||
|
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
|
||||||
|
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
|
||||||
|
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
|
||||||
|
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
|
||||||
|
|
||||||
|
if (sendToWorkflowBtn) {
|
||||||
|
sendToWorkflowBtn.style.display = config.sendToWorkflow ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
if (copyAllBtn) {
|
||||||
|
copyAllBtn.style.display = config.copyAll ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
if (refreshAllBtn) {
|
||||||
|
refreshAllBtn.style.display = config.refreshAll ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
if (moveAllBtn) {
|
||||||
|
moveAllBtn.style.display = config.moveAll ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
if (deleteAllBtn) {
|
||||||
|
deleteAllBtn.style.display = config.deleteAll ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
document.querySelectorAll('.model-card.selected').forEach(card => {
|
document.querySelectorAll('.model-card.selected').forEach(card => {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
});
|
});
|
||||||
state.selectedLoras.clear();
|
state.selectedModels.clear();
|
||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Hide thumbnail strip if it's visible
|
|
||||||
this.hideThumbnailStrip();
|
this.hideThumbnailStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedCount() {
|
updateSelectedCount() {
|
||||||
const countElement = document.getElementById('selectedCount');
|
const countElement = document.getElementById('selectedCount');
|
||||||
|
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||||
|
const displayName = currentConfig?.displayName || 'Models';
|
||||||
|
|
||||||
if (countElement) {
|
if (countElement) {
|
||||||
// Set text content without the icon
|
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `;
|
||||||
countElement.textContent = `${state.selectedLoras.size} selected `;
|
|
||||||
|
|
||||||
// Update caret icon if it exists
|
|
||||||
const existingCaret = countElement.querySelector('.dropdown-caret');
|
const existingCaret = countElement.querySelector('.dropdown-caret');
|
||||||
if (existingCaret) {
|
if (existingCaret) {
|
||||||
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||||
existingCaret.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
existingCaret.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
|
||||||
} else {
|
} else {
|
||||||
// Create new caret icon if it doesn't exist
|
|
||||||
const caretIcon = document.createElement('i');
|
const caretIcon = document.createElement('i');
|
||||||
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||||
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
caretIcon.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
|
||||||
countElement.appendChild(caretIcon);
|
countElement.appendChild(caretIcon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,16 +203,18 @@ export class BulkManager {
|
|||||||
|
|
||||||
toggleCardSelection(card) {
|
toggleCardSelection(card) {
|
||||||
const filepath = card.dataset.filepath;
|
const filepath = card.dataset.filepath;
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
if (card.classList.contains('selected')) {
|
if (card.classList.contains('selected')) {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
state.selectedLoras.delete(filepath);
|
state.selectedModels.delete(filepath);
|
||||||
} else {
|
} else {
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
state.selectedLoras.add(filepath);
|
state.selectedModels.add(filepath);
|
||||||
|
|
||||||
// Cache the metadata for this lora
|
// Cache the metadata for this model
|
||||||
state.loraMetadataCache.set(filepath, {
|
const metadataCache = this.getMetadataCache();
|
||||||
|
metadataCache.set(filepath, {
|
||||||
fileName: card.dataset.file_name,
|
fileName: card.dataset.file_name,
|
||||||
usageTips: card.dataset.usage_tips,
|
usageTips: card.dataset.usage_tips,
|
||||||
previewUrl: this.getCardPreviewUrl(card),
|
previewUrl: this.getCardPreviewUrl(card),
|
||||||
@@ -169,35 +225,49 @@ export class BulkManager {
|
|||||||
|
|
||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Update thumbnail strip if it's visible
|
|
||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMetadataCache() {
|
||||||
|
const currentType = state.currentPageType;
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
// Initialize metadata cache if it doesn't exist
|
||||||
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
|
if (!state.loraMetadataCache) {
|
||||||
|
state.loraMetadataCache = new Map();
|
||||||
|
}
|
||||||
|
return state.loraMetadataCache;
|
||||||
|
} else {
|
||||||
|
if (!pageState.metadataCache) {
|
||||||
|
pageState.metadataCache = new Map();
|
||||||
|
}
|
||||||
|
return pageState.metadataCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper method to get preview URL from a card
|
|
||||||
getCardPreviewUrl(card) {
|
getCardPreviewUrl(card) {
|
||||||
const img = card.querySelector('img');
|
const img = card.querySelector('img');
|
||||||
const video = card.querySelector('video source');
|
const video = card.querySelector('video source');
|
||||||
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
|
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to check if preview is a video
|
|
||||||
isCardPreviewVideo(card) {
|
isCardPreviewVideo(card) {
|
||||||
return card.querySelector('video') !== null;
|
return card.querySelector('video') !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply selection state to cards after they are refreshed
|
|
||||||
applySelectionState() {
|
applySelectionState() {
|
||||||
if (!state.bulkMode) return;
|
if (!state.bulkMode) return;
|
||||||
|
|
||||||
document.querySelectorAll('.model-card').forEach(card => {
|
document.querySelectorAll('.model-card').forEach(card => {
|
||||||
const filepath = card.dataset.filepath;
|
const filepath = card.dataset.filepath;
|
||||||
if (state.selectedLoras.has(filepath)) {
|
if (state.selectedModels.has(filepath)) {
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
|
|
||||||
// Update the cache with latest data
|
const metadataCache = this.getMetadataCache();
|
||||||
state.loraMetadataCache.set(filepath, {
|
metadataCache.set(filepath, {
|
||||||
fileName: card.dataset.file_name,
|
fileName: card.dataset.file_name,
|
||||||
usageTips: card.dataset.usage_tips,
|
usageTips: card.dataset.usage_tips,
|
||||||
previewUrl: this.getCardPreviewUrl(card),
|
previewUrl: this.getCardPreviewUrl(card),
|
||||||
@@ -212,30 +282,33 @@ export class BulkManager {
|
|||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyAllLorasSyntax() {
|
async copyAllModelsSyntax() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||||
|
showToast('Copy syntax is only available for LoRAs', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No LoRAs selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loraSyntaxes = [];
|
const loraSyntaxes = [];
|
||||||
const missingLoras = [];
|
const missingLoras = [];
|
||||||
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Process all selected loras using our metadata cache
|
for (const filepath of state.selectedModels) {
|
||||||
for (const filepath of state.selectedLoras) {
|
const metadata = metadataCache.get(filepath);
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
|
||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||||
} else {
|
} else {
|
||||||
// If we don't have metadata, this is an error case
|
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle any loras with missing metadata
|
|
||||||
if (missingLoras.length > 0) {
|
if (missingLoras.length > 0) {
|
||||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||||
@@ -249,31 +322,33 @@ export class BulkManager {
|
|||||||
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
|
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add method to send all selected loras to workflow
|
async sendAllModelsToWorkflow() {
|
||||||
async sendAllLorasToWorkflow() {
|
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||||
if (state.selectedLoras.size === 0) {
|
showToast('Send to workflow is only available for LoRAs', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No LoRAs selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loraSyntaxes = [];
|
const loraSyntaxes = [];
|
||||||
const missingLoras = [];
|
const missingLoras = [];
|
||||||
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Process all selected loras using our metadata cache
|
for (const filepath of state.selectedModels) {
|
||||||
for (const filepath of state.selectedLoras) {
|
const metadata = metadataCache.get(filepath);
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
|
||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||||
} else {
|
} else {
|
||||||
// If we don't have metadata, this is an error case
|
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle any loras with missing metadata
|
|
||||||
if (missingLoras.length > 0) {
|
if (missingLoras.length > 0) {
|
||||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||||
@@ -284,82 +359,48 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the loras to the workflow
|
|
||||||
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
|
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the bulk delete confirmation modal
|
|
||||||
showBulkDeleteModal() {
|
showBulkDeleteModal() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the count in the modal
|
|
||||||
const countElement = document.getElementById('bulkDeleteCount');
|
const countElement = document.getElementById('bulkDeleteCount');
|
||||||
if (countElement) {
|
if (countElement) {
|
||||||
countElement.textContent = state.selectedLoras.size;
|
countElement.textContent = state.selectedModels.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the modal
|
|
||||||
modalManager.showModal('bulkDeleteModal');
|
modalManager.showModal('bulkDeleteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm bulk delete action
|
|
||||||
async confirmBulkDelete() {
|
async confirmBulkDelete() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal first before showing loading indicator
|
|
||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show loading indicator
|
const apiClient = getModelApiClient();
|
||||||
state.loadingManager.showSimpleLoading('Deleting models...');
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
// Gather all file paths for deletion
|
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||||
const filePaths = Array.from(state.selectedLoras);
|
|
||||||
|
|
||||||
// Call the backend API
|
|
||||||
const response = await fetch('/api/loras/bulk-delete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_paths: filePaths
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast(`Successfully deleted ${result.deleted_count} models`, 'success');
|
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||||
|
showToast(`Successfully deleted ${result.deleted_count} ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||||
|
|
||||||
// If virtual scroller exists, update the UI without page reload
|
filePaths.forEach(path => {
|
||||||
if (state.virtualScroller) {
|
state.virtualScroller.removeItemByFilePath(path);
|
||||||
// Remove each deleted item from the virtual scroller
|
});
|
||||||
filePaths.forEach(path => {
|
this.clearSelection();
|
||||||
state.virtualScroller.removeItemByFilePath(path);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the selection
|
|
||||||
this.clearSelection();
|
|
||||||
} else {
|
|
||||||
// Clear the selection
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
// Fall back to page reload for non-virtual scroll mode
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.modelDuplicatesManager) {
|
if (window.modelDuplicatesManager) {
|
||||||
// Update duplicates badge after refresh
|
|
||||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -368,16 +409,11 @@ export class BulkManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during bulk delete:', error);
|
console.error('Error during bulk delete:', error);
|
||||||
showToast('Failed to delete models', 'error');
|
showToast('Failed to delete models', 'error');
|
||||||
} finally {
|
|
||||||
// Hide loading indicator
|
|
||||||
state.loadingManager.hide();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and show the thumbnail strip of selected LoRAs
|
|
||||||
toggleThumbnailStrip() {
|
toggleThumbnailStrip() {
|
||||||
// If no items are selected, do nothing
|
if (state.selectedModels.size === 0) return;
|
||||||
if (state.selectedLoras.size === 0) return;
|
|
||||||
|
|
||||||
const existing = document.querySelector('.selected-thumbnails-strip');
|
const existing = document.querySelector('.selected-thumbnails-strip');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -388,38 +424,30 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showThumbnailStrip() {
|
showThumbnailStrip() {
|
||||||
// Create the thumbnail strip container
|
|
||||||
const strip = document.createElement('div');
|
const strip = document.createElement('div');
|
||||||
strip.className = 'selected-thumbnails-strip';
|
strip.className = 'selected-thumbnails-strip';
|
||||||
|
|
||||||
// Create a container for the thumbnails (for scrolling)
|
|
||||||
const thumbnailContainer = document.createElement('div');
|
const thumbnailContainer = document.createElement('div');
|
||||||
thumbnailContainer.className = 'thumbnails-container';
|
thumbnailContainer.className = 'thumbnails-container';
|
||||||
strip.appendChild(thumbnailContainer);
|
strip.appendChild(thumbnailContainer);
|
||||||
|
|
||||||
// Position the strip above the bulk operations panel
|
|
||||||
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
|
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
|
||||||
|
|
||||||
// Populate the thumbnails
|
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
|
|
||||||
// Update strip visibility state and caret direction
|
|
||||||
this.isStripVisible = true;
|
this.isStripVisible = true;
|
||||||
this.updateSelectedCount(); // Update caret
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Add animation class after a short delay to trigger transition
|
|
||||||
setTimeout(() => strip.classList.add('visible'), 10);
|
setTimeout(() => strip.classList.add('visible'), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
hideThumbnailStrip() {
|
hideThumbnailStrip() {
|
||||||
const strip = document.querySelector('.selected-thumbnails-strip');
|
const strip = document.querySelector('.selected-thumbnails-strip');
|
||||||
if (strip && this.isStripVisible) { // Only hide if actually visible
|
if (strip && this.isStripVisible) {
|
||||||
strip.classList.remove('visible');
|
strip.classList.remove('visible');
|
||||||
|
|
||||||
// Update strip visibility state
|
|
||||||
this.isStripVisible = false;
|
this.isStripVisible = false;
|
||||||
|
|
||||||
// Update caret without triggering another hide
|
|
||||||
const countElement = document.getElementById('selectedCount');
|
const countElement = document.getElementById('selectedCount');
|
||||||
if (countElement) {
|
if (countElement) {
|
||||||
const caret = countElement.querySelector('.dropdown-caret');
|
const caret = countElement.querySelector('.dropdown-caret');
|
||||||
@@ -428,7 +456,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for animation to complete before removing
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (strip.parentNode) {
|
if (strip.parentNode) {
|
||||||
strip.parentNode.removeChild(strip);
|
strip.parentNode.removeChild(strip);
|
||||||
@@ -441,33 +468,28 @@ export class BulkManager {
|
|||||||
const container = document.querySelector('.thumbnails-container');
|
const container = document.querySelector('.thumbnails-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Clear existing thumbnails
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Get all selected loras
|
const selectedModels = Array.from(state.selectedModels);
|
||||||
const selectedLoras = Array.from(state.selectedLoras);
|
|
||||||
|
|
||||||
// Create counter if we have more thumbnails than we'll show
|
if (selectedModels.length > this.stripMaxThumbnails) {
|
||||||
if (selectedLoras.length > this.stripMaxThumbnails) {
|
|
||||||
const counter = document.createElement('div');
|
const counter = document.createElement('div');
|
||||||
counter.className = 'strip-counter';
|
counter.className = 'strip-counter';
|
||||||
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedLoras.length} selected`;
|
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedModels.length} selected`;
|
||||||
container.appendChild(counter);
|
container.appendChild(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit the number of thumbnails to display
|
const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails);
|
||||||
const thumbnailsToShow = selectedLoras.slice(0, this.stripMaxThumbnails);
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Add a thumbnail for each selected LoRA (limited to max)
|
|
||||||
thumbnailsToShow.forEach(filepath => {
|
thumbnailsToShow.forEach(filepath => {
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
const metadata = metadataCache.get(filepath);
|
||||||
if (!metadata) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
const thumbnail = document.createElement('div');
|
const thumbnail = document.createElement('div');
|
||||||
thumbnail.className = 'selected-thumbnail';
|
thumbnail.className = 'selected-thumbnail';
|
||||||
thumbnail.dataset.filepath = filepath;
|
thumbnail.dataset.filepath = filepath;
|
||||||
|
|
||||||
// Create the visual element (image or video)
|
|
||||||
if (metadata.isVideo) {
|
if (metadata.isVideo) {
|
||||||
thumbnail.innerHTML = `
|
thumbnail.innerHTML = `
|
||||||
<video autoplay loop muted playsinline>
|
<video autoplay loop muted playsinline>
|
||||||
@@ -484,14 +506,12 @@ export class BulkManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler for deselection
|
|
||||||
thumbnail.addEventListener('click', (e) => {
|
thumbnail.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.thumbnail-remove')) {
|
if (!e.target.closest('.thumbnail-remove')) {
|
||||||
this.deselectItem(filepath);
|
this.deselectItem(filepath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add click handler for the remove button
|
|
||||||
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
|
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.deselectItem(filepath);
|
this.deselectItem(filepath);
|
||||||
@@ -502,43 +522,36 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deselectItem(filepath) {
|
deselectItem(filepath) {
|
||||||
// Find and deselect the corresponding card if it's in the DOM
|
|
||||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||||
if (card) {
|
if (card) {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from the selection set
|
state.selectedModels.delete(filepath);
|
||||||
state.selectedLoras.delete(filepath);
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
|
|
||||||
// Hide the strip if no more selections
|
if (state.selectedModels.size === 0) {
|
||||||
if (state.selectedLoras.size === 0) {
|
|
||||||
this.hideThumbnailStrip();
|
this.hideThumbnailStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add method to select all visible loras
|
selectAllVisibleModels() {
|
||||||
selectAllVisibleLoras() {
|
|
||||||
// Only select loras already in the VirtualScroller's data model
|
|
||||||
if (!state.virtualScroller || !state.virtualScroller.items) {
|
if (!state.virtualScroller || !state.virtualScroller.items) {
|
||||||
showToast('Unable to select all items', 'error');
|
showToast('Unable to select all items', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldCount = state.selectedLoras.size;
|
const oldCount = state.selectedModels.size;
|
||||||
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Add all loaded loras to the selection set
|
|
||||||
state.virtualScroller.items.forEach(item => {
|
state.virtualScroller.items.forEach(item => {
|
||||||
if (item && item.file_path) {
|
if (item && item.file_path) {
|
||||||
state.selectedLoras.add(item.file_path);
|
state.selectedModels.add(item.file_path);
|
||||||
|
|
||||||
// Add to metadata cache if not already there
|
if (!metadataCache.has(item.file_path)) {
|
||||||
if (!state.loraMetadataCache.has(item.file_path)) {
|
metadataCache.set(item.file_path, {
|
||||||
state.loraMetadataCache.set(item.file_path, {
|
|
||||||
fileName: item.file_name,
|
fileName: item.file_name,
|
||||||
usageTips: item.usage_tips || '{}',
|
usageTips: item.usage_tips || '{}',
|
||||||
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
|
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
|
||||||
@@ -549,45 +562,37 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update visual state
|
|
||||||
this.applySelectionState();
|
this.applySelectionState();
|
||||||
|
|
||||||
// Show success message
|
const newlySelected = state.selectedModels.size - oldCount;
|
||||||
const newlySelected = state.selectedLoras.size - oldCount;
|
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||||
showToast(`Selected ${newlySelected} additional LoRAs`, 'success');
|
showToast(`Selected ${newlySelected} additional ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||||
|
|
||||||
// Update thumbnail strip if visible
|
|
||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add method to refresh metadata for all selected models
|
|
||||||
async refreshAllMetadata() {
|
async refreshAllMetadata() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No models selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the API client for the current model type
|
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = getModelApiClient();
|
||||||
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
// Convert Set to Array for processing
|
|
||||||
const filePaths = Array.from(state.selectedLoras);
|
|
||||||
|
|
||||||
// Call the bulk refresh method
|
|
||||||
const result = await apiClient.refreshBulkModelMetadata(filePaths);
|
const result = await apiClient.refreshBulkModelMetadata(filePaths);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update the metadata cache for successfully refreshed items
|
const metadataCache = this.getMetadataCache();
|
||||||
for (const filepath of state.selectedLoras) {
|
for (const filepath of state.selectedModels) {
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
const metadata = metadataCache.get(filepath);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
// Find the corresponding card to get updated data
|
|
||||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||||
if (card) {
|
if (card) {
|
||||||
state.loraMetadataCache.set(filepath, {
|
metadataCache.set(filepath, {
|
||||||
...metadata,
|
...metadata,
|
||||||
fileName: card.dataset.file_name,
|
fileName: card.dataset.file_name,
|
||||||
usageTips: card.dataset.usage_tips,
|
usageTips: card.dataset.usage_tips,
|
||||||
@@ -599,7 +604,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update thumbnail strip if visible
|
|
||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
@@ -612,5 +616,4 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a singleton instance
|
|
||||||
export const bulkManager = new BulkManager();
|
export const bulkManager = new BulkManager();
|
||||||
|
|||||||
@@ -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/baseModelApi.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
// ExampleImagesManager.js
|
// ExampleImagesManager.js
|
||||||
@@ -14,6 +15,12 @@ class ExampleImagesManager {
|
|||||||
this.isMigrating = false; // Track migration state separately from downloading
|
this.isMigrating = false; // Track migration state separately from downloading
|
||||||
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
|
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
|
||||||
|
|
||||||
|
// Auto download properties
|
||||||
|
this.autoDownloadInterval = null;
|
||||||
|
this.lastAutoDownloadCheck = 0;
|
||||||
|
this.autoDownloadCheckInterval = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||||
|
this.pageInitTime = Date.now(); // Track when page was initialized
|
||||||
|
|
||||||
// Initialize download path field and check download status
|
// Initialize download path field and check download status
|
||||||
this.initializePathOptions();
|
this.initializePathOptions();
|
||||||
this.checkDownloadStatus();
|
this.checkDownloadStatus();
|
||||||
@@ -48,6 +55,14 @@ class ExampleImagesManager {
|
|||||||
if (collapseBtn) {
|
if (collapseBtn) {
|
||||||
collapseBtn.onclick = () => this.toggleProgressPanel();
|
collapseBtn.onclick = () => this.toggleProgressPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup auto download if enabled
|
||||||
|
if (state.global.settings.autoDownloadExampleImages) {
|
||||||
|
this.setupAutoDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make this instance globally accessible
|
||||||
|
window.exampleImagesManager = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize event listeners for buttons
|
// Initialize event listeners for buttons
|
||||||
@@ -133,6 +148,15 @@ class ExampleImagesManager {
|
|||||||
console.error('Failed to update example images path:', error);
|
console.error('Failed to update example images path:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup or clear auto download based on path availability
|
||||||
|
if (state.global.settings.autoDownloadExampleImages) {
|
||||||
|
if (hasPath) {
|
||||||
|
this.setupAutoDownload();
|
||||||
|
} else {
|
||||||
|
this.clearAutoDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize path options:', error);
|
console.error('Failed to initialize path options:', error);
|
||||||
@@ -646,6 +670,106 @@ class ExampleImagesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupAutoDownload() {
|
||||||
|
// Only setup if conditions are met
|
||||||
|
if (!this.canAutoDownload()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing interval
|
||||||
|
this.clearAutoDownload();
|
||||||
|
|
||||||
|
// Wait at least 30 seconds after page initialization before first check
|
||||||
|
const timeSinceInit = Date.now() - this.pageInitTime;
|
||||||
|
const initialDelay = Math.max(60000 - timeSinceInit, 5000); // At least 5 seconds, up to 60 seconds
|
||||||
|
|
||||||
|
console.log(`Setting up auto download with initial delay of ${initialDelay}ms`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// Do initial check
|
||||||
|
this.performAutoDownloadCheck();
|
||||||
|
|
||||||
|
// Set up recurring interval
|
||||||
|
this.autoDownloadInterval = setInterval(() => {
|
||||||
|
this.performAutoDownloadCheck();
|
||||||
|
}, this.autoDownloadCheckInterval);
|
||||||
|
|
||||||
|
}, initialDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAutoDownload() {
|
||||||
|
if (this.autoDownloadInterval) {
|
||||||
|
clearInterval(this.autoDownloadInterval);
|
||||||
|
this.autoDownloadInterval = null;
|
||||||
|
console.log('Auto download interval cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canAutoDownload() {
|
||||||
|
// Check if auto download is enabled
|
||||||
|
if (!state.global.settings.autoDownloadExampleImages) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if download path is set
|
||||||
|
const pathInput = document.getElementById('exampleImagesPath');
|
||||||
|
if (!pathInput || !pathInput.value.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already downloading
|
||||||
|
if (this.isDownloading) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async performAutoDownloadCheck() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Prevent too frequent checks (minimum 2 minutes between checks)
|
||||||
|
if (now - this.lastAutoDownloadCheck < 2 * 60 * 1000) {
|
||||||
|
console.log('Skipping auto download check - too soon since last check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastAutoDownloadCheck = now;
|
||||||
|
|
||||||
|
if (!this.canAutoDownload()) {
|
||||||
|
console.log('Auto download conditions not met, skipping check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Performing auto download check...');
|
||||||
|
|
||||||
|
const outputDir = document.getElementById('exampleImagesPath').value;
|
||||||
|
const optimize = document.getElementById('optimizeExampleImages').checked;
|
||||||
|
|
||||||
|
const response = await fetch('/api/download-example-images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
output_dir: outputDir,
|
||||||
|
optimize: optimize,
|
||||||
|
model_types: ['lora', 'checkpoint', 'embedding'],
|
||||||
|
auto_mode: true // Flag to indicate this is an automatic download
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
console.warn('Auto download check failed:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto download check error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BASE_MODEL_CLASSES } from '../utils/constants.js';
|
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
@@ -67,14 +66,7 @@ export class FilterManager {
|
|||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
|
||||||
// Determine the API endpoint based on the page type
|
// Determine the API endpoint based on the page type
|
||||||
let tagsEndpoint = '/api/loras/top-tags?limit=20';
|
const tagsEndpoint = `/api/${this.currentPage}/top-tags?limit=20`;
|
||||||
if (this.currentPage === 'recipes') {
|
|
||||||
tagsEndpoint = '/api/recipes/top-tags?limit=20';
|
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
|
||||||
tagsEndpoint = '/api/checkpoints/top-tags?limit=20';
|
|
||||||
} else if (this.currentPage === 'embeddings') {
|
|
||||||
tagsEndpoint = '/api/embeddings/top-tags?limit=20';
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(tagsEndpoint);
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
@@ -141,19 +133,8 @@ export class FilterManager {
|
|||||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
// Set the appropriate API endpoint based on current page
|
// Set the API endpoint based on current page
|
||||||
let apiEndpoint = '';
|
const apiEndpoint = `/api/${this.currentPage}/base-models`;
|
||||||
if (this.currentPage === 'loras') {
|
|
||||||
apiEndpoint = '/api/loras/base-models';
|
|
||||||
} else if (this.currentPage === 'recipes') {
|
|
||||||
apiEndpoint = '/api/recipes/base-models';
|
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
|
||||||
apiEndpoint = '/api/checkpoints/base-models';
|
|
||||||
} else if (this.currentPage === 'embeddings') {
|
|
||||||
apiEndpoint = '/api/embeddings/base-models';
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch base models
|
// Fetch base models
|
||||||
fetch(apiEndpoint)
|
fetch(apiEndpoint)
|
||||||
|
|||||||
@@ -1,152 +1,178 @@
|
|||||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { bulkManager } from './BulkManager.js';
|
||||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.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.loraRootSelect = document.getElementById('moveLoraRoot');
|
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.initializeEventListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeEventListeners() {
|
initializeEventListeners() {
|
||||||
// 初始化LoRA根目录选择器
|
if (this.initialized) return;
|
||||||
this.loraRootSelect.addEventListener('change', () => this.updatePathPreview());
|
|
||||||
|
const modelRootSelect = document.getElementById('moveModelRoot');
|
||||||
// 文件夹选择事件
|
|
||||||
this.folderBrowser.addEventListener('click', (e) => {
|
// Initialize model root directory selector
|
||||||
const folderItem = e.target.closest('.folder-item');
|
modelRootSelect.addEventListener('change', async () => {
|
||||||
if (!folderItem) return;
|
await this.initializeFolderTree();
|
||||||
|
this.updateTargetPath();
|
||||||
// 如果点击已选中的文件夹,则取消选择
|
|
||||||
if (folderItem.classList.contains('selected')) {
|
|
||||||
folderItem.classList.remove('selected');
|
|
||||||
} else {
|
|
||||||
// 取消其他选中状态
|
|
||||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
|
||||||
item.classList.remove('selected');
|
|
||||||
});
|
|
||||||
// 设置当前选中状态
|
|
||||||
folderItem.classList.add('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePathPreview();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 新文件夹输入事件
|
this.initialized = true;
|
||||||
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async showMoveModal(filePath) {
|
async showMoveModal(filePath, modelType = null) {
|
||||||
// Reset state
|
// Reset state
|
||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
const currentPageType = state.currentPageType;
|
||||||
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
// Handle bulk mode
|
// Handle bulk mode
|
||||||
if (filePath === 'bulk') {
|
if (filePath === 'bulk') {
|
||||||
const selectedPaths = Array.from(state.selectedLoras);
|
const selectedPaths = Array.from(state.selectedModels);
|
||||||
if (selectedPaths.length === 0) {
|
if (selectedPaths.length === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.bulkFilePaths = selectedPaths;
|
this.bulkFilePaths = selectedPaths;
|
||||||
this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
|
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 Model";
|
document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除之前的选择
|
// Update UI labels based on model type
|
||||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`;
|
||||||
item.classList.remove('selected');
|
document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`;
|
||||||
});
|
|
||||||
this.newFolderInput.value = '';
|
// Clear folder path input
|
||||||
|
const folderPathInput = document.getElementById('moveFolderPath');
|
||||||
|
if (folderPathInput) {
|
||||||
|
folderPathInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch LoRA roots
|
// Fetch model roots
|
||||||
const rootsResponse = await fetch('/api/loras/roots');
|
const modelRootSelect = document.getElementById('moveModelRoot');
|
||||||
if (!rootsResponse.ok) {
|
let rootsData;
|
||||||
throw new Error('Failed to fetch LoRA roots');
|
if (modelType) {
|
||||||
|
rootsData = await apiClient.fetchModelRoots(modelType);
|
||||||
|
} else {
|
||||||
|
rootsData = await apiClient.fetchModelRoots();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootsData = await rootsResponse.json();
|
|
||||||
if (!rootsData.roots || rootsData.roots.length === 0) {
|
if (!rootsData.roots || rootsData.roots.length === 0) {
|
||||||
throw new Error('No LoRA roots found');
|
throw new Error(`No ${modelConfig.displayName.toLowerCase()} roots found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充LoRA根目录选择器
|
// Populate model root selector
|
||||||
this.loraRootSelect.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 lora root if available
|
// Set default root if available
|
||||||
const defaultRoot = getStorageItem('settings', {}).default_lora_root;
|
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
|
||||||
|
const defaultRoot = getStorageItem('settings', {})[settingsKey];
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
this.loraRootSelect.value = defaultRoot;
|
modelRootSelect.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders dynamically
|
// Initialize event listeners
|
||||||
const foldersResponse = await fetch('/api/loras/folders');
|
this.initializeEventListeners();
|
||||||
if (!foldersResponse.ok) {
|
|
||||||
throw new Error('Failed to fetch folders');
|
|
||||||
}
|
|
||||||
|
|
||||||
const foldersData = await foldersResponse.json();
|
// Setup folder tree manager
|
||||||
|
this.folderTreeManager.init({
|
||||||
|
onPathChange: (path) => {
|
||||||
|
this.updateTargetPath();
|
||||||
|
},
|
||||||
|
elementsPrefix: 'move'
|
||||||
|
});
|
||||||
|
|
||||||
// Update folder browser with dynamic content
|
// Initialize folder tree
|
||||||
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
await this.initializeFolderTree();
|
||||||
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
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 LoRA roots or folders:', error);
|
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
|
||||||
showToast(error.message, 'error');
|
showToast(error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePathPreview() {
|
async initializeFolderTree() {
|
||||||
const selectedRoot = this.loraRootSelect.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.loraRootSelect.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) {
|
||||||
@@ -191,11 +217,8 @@ class MoveManager {
|
|||||||
|
|
||||||
// Refresh folder tags after successful move
|
// Refresh folder tags after successful move
|
||||||
try {
|
try {
|
||||||
const foldersResponse = await fetch('/api/loras/folders');
|
const foldersData = await apiClient.fetchModelFolders();
|
||||||
if (foldersResponse.ok) {
|
updateFolderTags(foldersData.folders);
|
||||||
const foldersData = await foldersResponse.json();
|
|
||||||
updateFolderTags(foldersData.folders);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing folder tags:', error);
|
console.error('Error refreshing folder tags:', error);
|
||||||
}
|
}
|
||||||
@@ -204,7 +227,7 @@ class MoveManager {
|
|||||||
|
|
||||||
// If we were in bulk mode, exit it after successful move
|
// If we were in bulk mode, exit it after successful move
|
||||||
if (this.bulkFilePaths && state.bulkMode) {
|
if (this.bulkFilePaths && state.bulkMode) {
|
||||||
toggleBulkMode();
|
bulkManager.toggleBulkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
||||||
import { getCurrentPageState } from "../state/index.js";
|
import { getCurrentPageState } from "../state/index.js";
|
||||||
import { getModelApiClient } from "../api/baseModelApi.js";
|
import { getModelApiClient } from "../api/modelApiFactory.js";
|
||||||
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
|
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
|
||||||
/**
|
/**
|
||||||
* SearchManager - Handles search functionality across different pages
|
* SearchManager - Handles search functionality across different pages
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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 { state } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/baseModelApi.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() {
|
||||||
@@ -48,6 +48,11 @@ export class SettingsManager {
|
|||||||
state.global.settings.optimizeExampleImages = true;
|
state.global.settings.optimizeExampleImages = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set default for autoDownloadExampleImages if undefined
|
||||||
|
if (state.global.settings.autoDownloadExampleImages === undefined) {
|
||||||
|
state.global.settings.autoDownloadExampleImages = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Set default for cardInfoDisplay if undefined
|
// Set default for cardInfoDisplay if undefined
|
||||||
if (state.global.settings.cardInfoDisplay === undefined) {
|
if (state.global.settings.cardInfoDisplay === undefined) {
|
||||||
state.global.settings.cardInfoDisplay = 'always';
|
state.global.settings.cardInfoDisplay = 'always';
|
||||||
@@ -68,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 = {};
|
||||||
@@ -82,6 +106,11 @@ export class SettingsManager {
|
|||||||
if (state.global.settings.default_embedding_root === undefined) {
|
if (state.global.settings.default_embedding_root === undefined) {
|
||||||
state.global.settings.default_embedding_root = '';
|
state.global.settings.default_embedding_root = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set default for includeTriggerWords if undefined
|
||||||
|
if (state.global.settings.includeTriggerWords === undefined) {
|
||||||
|
state.global.settings.includeTriggerWords = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncSettingsToBackendIfNeeded() {
|
async syncSettingsToBackendIfNeeded() {
|
||||||
@@ -95,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
|
||||||
@@ -103,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];
|
||||||
@@ -154,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;
|
||||||
}
|
}
|
||||||
@@ -195,11 +248,19 @@ export class SettingsManager {
|
|||||||
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
|
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set download path template setting
|
// Set auto download example images setting
|
||||||
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
|
const autoDownloadExampleImagesCheckbox = document.getElementById('autoDownloadExampleImages');
|
||||||
if (downloadPathTemplateSelect) {
|
if (autoDownloadExampleImagesCheckbox) {
|
||||||
downloadPathTemplateSelect.value = state.global.settings.download_path_template || '';
|
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
|
||||||
this.updatePathTemplatePreview();
|
}
|
||||||
|
|
||||||
|
// Load download path templates
|
||||||
|
this.loadDownloadPathTemplates();
|
||||||
|
|
||||||
|
// Set include trigger words setting
|
||||||
|
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
|
||||||
|
if (includeTriggerWordsCheckbox) {
|
||||||
|
includeTriggerWordsCheckbox.checked = state.global.settings.includeTriggerWords || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load base model path mappings
|
// Load base model path mappings
|
||||||
@@ -507,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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,8 +773,12 @@ export class SettingsManager {
|
|||||||
state.global.settings.autoplayOnHover = value;
|
state.global.settings.autoplayOnHover = value;
|
||||||
} else if (settingKey === 'optimize_example_images') {
|
} else if (settingKey === 'optimize_example_images') {
|
||||||
state.global.settings.optimizeExampleImages = value;
|
state.global.settings.optimizeExampleImages = value;
|
||||||
|
} else if (settingKey === 'auto_download_example_images') {
|
||||||
|
state.global.settings.autoDownloadExampleImages = value;
|
||||||
} else if (settingKey === 'compact_mode') {
|
} else if (settingKey === 'compact_mode') {
|
||||||
state.global.settings.compactMode = value;
|
state.global.settings.compactMode = value;
|
||||||
|
} else if (settingKey === 'include_trigger_words') {
|
||||||
|
state.global.settings.includeTriggerWords = value;
|
||||||
} 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;
|
||||||
@@ -574,14 +804,23 @@ export class SettingsManager {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to save setting');
|
throw new Error('Failed to save setting');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||||
|
|
||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
this.applyFrontendSettings();
|
this.applyFrontendSettings();
|
||||||
|
|
||||||
if (settingKey === 'show_only_sfw') {
|
// Trigger auto download setup/teardown when setting changes
|
||||||
|
if (settingKey === 'auto_download_example_images' && window.exampleImagesManager) {
|
||||||
|
if (value) {
|
||||||
|
window.exampleImagesManager.setupAutoDownload();
|
||||||
|
} else {
|
||||||
|
window.exampleImagesManager.clearAutoDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
|
||||||
this.reloadContent();
|
this.reloadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,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;
|
||||||
@@ -629,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',
|
||||||
@@ -763,20 +1003,13 @@ export class SettingsManager {
|
|||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
} else if (this.currentPage === 'embeddings') {
|
||||||
|
// Reload the embeddings without updating folders
|
||||||
|
await resetAndReload(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFrontendSettings() {
|
applyFrontendSettings() {
|
||||||
// Apply blur setting to existing content
|
|
||||||
const blurSetting = state.global.settings.blurMatureContent;
|
|
||||||
document.querySelectorAll('.model-card[data-nsfw="true"] .card-image').forEach(img => {
|
|
||||||
if (blurSetting) {
|
|
||||||
img.classList.add('nsfw-blur');
|
|
||||||
} else {
|
|
||||||
img.classList.remove('nsfw-blur');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply autoplay setting to existing videos in card previews
|
// Apply autoplay setting to existing videos in card previews
|
||||||
const autoplayOnHover = state.global.settings.autoplayOnHover;
|
const autoplayOnHover = state.global.settings.autoplayOnHover;
|
||||||
document.querySelectorAll('.card-preview video').forEach(video => {
|
document.querySelectorAll('.card-preview video').forEach(video => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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...`
|
||||||
|
|||||||
@@ -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,12 +84,17 @@ export const state = {
|
|||||||
searchOptions: {
|
searchOptions: {
|
||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
metadataCache: new Map(),
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
},
|
},
|
||||||
@@ -106,12 +112,16 @@ export const state = {
|
|||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
metadataCache: new Map(),
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
}
|
}
|
||||||
@@ -154,12 +164,43 @@ export const state = {
|
|||||||
get filters() { return this.pages[this.currentPageType].filters; },
|
get filters() { return this.pages[this.currentPageType].filters; },
|
||||||
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
||||||
|
|
||||||
get bulkMode() { return this.pages.loras.bulkMode; },
|
get bulkMode() {
|
||||||
set bulkMode(value) { this.pages.loras.bulkMode = value; },
|
const currentType = this.currentPageType;
|
||||||
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
|
return this.pages.loras.bulkMode;
|
||||||
|
} else {
|
||||||
|
return this.pages[currentType].bulkMode;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set bulkMode(value) {
|
||||||
|
const currentType = this.currentPageType;
|
||||||
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
|
this.pages.loras.bulkMode = value;
|
||||||
|
} else {
|
||||||
|
this.pages[currentType].bulkMode = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
||||||
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
||||||
|
|
||||||
|
get selectedModels() {
|
||||||
|
const currentType = this.currentPageType;
|
||||||
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
|
return this.pages.loras.selectedLoras;
|
||||||
|
} else {
|
||||||
|
return this.pages[currentType].selectedModels;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set selectedModels(value) {
|
||||||
|
const currentType = this.currentPageType;
|
||||||
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
|
this.pages.loras.selectedLoras = value;
|
||||||
|
} else {
|
||||||
|
this.pages[currentType].selectedModels = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
||||||
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { VirtualScroller } from './VirtualScroller.js';
|
import { VirtualScroller } from './VirtualScroller.js';
|
||||||
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { showToast } from './uiHelpers.js';
|
import { showToast } from './uiHelpers.js';
|
||||||
|
|
||||||
// Function to dynamically import the appropriate card creator based on page type
|
// Function to dynamically import the appropriate card creator based on page type
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = getModelApiClient();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||||
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
||||||
|
|
||||||
@@ -285,6 +285,76 @@ export function getNSFWLevelName(level) {
|
|||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function copyLoraSyntax(card) {
|
||||||
|
const usageTips = JSON.parse(card.dataset.usage_tips || "{}");
|
||||||
|
const strength = usageTips.strength || 1;
|
||||||
|
const baseSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||||
|
|
||||||
|
// Check if trigger words should be included
|
||||||
|
const includeTriggerWords = state.global.settings.includeTriggerWords;
|
||||||
|
|
||||||
|
if (!includeTriggerWords) {
|
||||||
|
copyToClipboard(baseSyntax, "LoRA syntax copied to clipboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trigger words from metadata
|
||||||
|
const meta = card.dataset.meta ? JSON.parse(card.dataset.meta) : null;
|
||||||
|
const trainedWords = meta?.trainedWords;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!trainedWords ||
|
||||||
|
!Array.isArray(trainedWords) ||
|
||||||
|
trainedWords.length === 0
|
||||||
|
) {
|
||||||
|
copyToClipboard(
|
||||||
|
baseSyntax,
|
||||||
|
"LoRA syntax copied to clipboard (no trigger words found)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalSyntax = baseSyntax;
|
||||||
|
|
||||||
|
if (trainedWords.length === 1) {
|
||||||
|
// Single group: append trigger words to the same line
|
||||||
|
const triggers = trainedWords[0]
|
||||||
|
.split(",")
|
||||||
|
.map((word) => word.trim())
|
||||||
|
.filter((word) => word);
|
||||||
|
if (triggers.length > 0) {
|
||||||
|
finalSyntax = `${baseSyntax}, ${triggers.join(", ")}`;
|
||||||
|
}
|
||||||
|
copyToClipboard(
|
||||||
|
finalSyntax,
|
||||||
|
"LoRA syntax with trigger words copied to clipboard"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Multiple groups: format with separators
|
||||||
|
const groups = trainedWords
|
||||||
|
.map((group) => {
|
||||||
|
const triggers = group
|
||||||
|
.split(",")
|
||||||
|
.map((word) => word.trim())
|
||||||
|
.filter((word) => word);
|
||||||
|
return triggers.join(", ");
|
||||||
|
})
|
||||||
|
.filter((group) => group);
|
||||||
|
|
||||||
|
if (groups.length > 0) {
|
||||||
|
// Use separator between all groups except the first
|
||||||
|
finalSyntax = baseSyntax + ", " + groups[0];
|
||||||
|
for (let i = 1; i < groups.length; i++) {
|
||||||
|
finalSyntax += `\n${"-".repeat(17)}\n${groups[i]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copyToClipboard(
|
||||||
|
finalSyntax,
|
||||||
|
"LoRA syntax with trigger word groups copied to clipboard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends LoRA syntax to the active ComfyUI workflow
|
* Sends LoRA syntax to the active ComfyUI workflow
|
||||||
* @param {string} loraSyntax - The LoRA syntax to send
|
* @param {string} loraSyntax - The LoRA syntax to send
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,11 @@
|
|||||||
<i class="fas fa-cloud-download-alt"></i> Download
|
<i class="fas fa-cloud-download-alt"></i> Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Conditional buttons based on page -->
|
|
||||||
{% if request.path == '/loras' %}
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="bulkOperationsBtn" data-action="bulk" title="Bulk Operations (Press B)">
|
<button id="bulkOperationsBtn" data-action="bulk" title="Bulk Operations (Press B)">
|
||||||
<i class="fas fa-th-large"></i> <span>Bulk <div class="shortcut-key">B</div></span>
|
<i class="fas fa-th-large"></i> <span>Bulk <div class="shortcut-key">B</div></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
|
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
|
||||||
<i class="fas fa-clone"></i> Duplicates
|
<i class="fas fa-clone"></i> Duplicates
|
||||||
@@ -119,22 +116,22 @@
|
|||||||
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="bulk-operations-actions">
|
<div class="bulk-operations-actions">
|
||||||
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
|
<button data-action="send-to-workflow" title="Send all selected LoRAs to workflow">
|
||||||
<i class="fas fa-arrow-right"></i> Send to Workflow
|
<i class="fas fa-arrow-right"></i> Send to Workflow
|
||||||
</button>
|
</button>
|
||||||
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
|
<button data-action="copy-all" title="Copy all selected LoRAs syntax">
|
||||||
<i class="fas fa-copy"></i> Copy All
|
<i class="fas fa-copy"></i> Copy All
|
||||||
</button>
|
</button>
|
||||||
<button onclick="bulkManager.refreshAllMetadata()" title="Refresh CivitAI metadata for selected models">
|
<button data-action="refresh-all" title="Refresh CivitAI metadata for selected models">
|
||||||
<i class="fas fa-sync-alt"></i> Refresh All
|
<i class="fas fa-sync-alt"></i> Refresh All
|
||||||
</button>
|
</button>
|
||||||
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
|
<button data-action="move-all" title="Move selected models to folder">
|
||||||
<i class="fas fa-folder-open"></i> Move All
|
<i class="fas fa-folder-open"></i> Move All
|
||||||
</button>
|
</button>
|
||||||
<button onclick="bulkManager.showBulkDeleteModal()" title="Delete selected LoRAs" class="danger-btn">
|
<button data-action="delete-all" title="Delete selected models" class="danger-btn">
|
||||||
<i class="fas fa-trash"></i> Delete All
|
<i class="fas fa-trash"></i> Delete All
|
||||||
</button>
|
</button>
|
||||||
<button onclick="bulkManager.clearSelection()" title="Clear selection">
|
<button data-action="clear" title="Clear selection">
|
||||||
<i class="fas fa-times"></i> Clear
|
<i class="fas fa-times"></i> Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">×</button>
|
<div class="modal-header">
|
||||||
<h2 id="downloadModalTitle">Download Model from URL</h2>
|
<button class="close" id="closeDownloadModal">×</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">
|
||||||
|
|||||||
@@ -6,26 +6,46 @@
|
|||||||
<span class="close" onclick="modalManager.closeModal('moveModal')">×</span>
|
<span class="close" onclick="modalManager.closeModal('moveModal')">×</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">
|
||||||
<span class="path-text">Select a LoRA root directory</span>
|
<span class="path-text">Select a model root directory</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Select LoRA Root:</label>
|
<label for="moveModelRoot" id="moveRootLabel">Select Model Root:</label>
|
||||||
<select id="moveLoraRoot"></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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -272,6 +336,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="autoDownloadExampleImages">Auto Download Example Images</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="autoDownloadExampleImages" checked
|
||||||
|
onchange="settingsManager.saveToggleSetting('autoDownloadExampleImages', 'auto_download_example_images')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-help">
|
||||||
|
Automatically download example images for models that don't have them (requires download location to be set)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -290,6 +372,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Misc. Section -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Misc.</h3>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="includeTriggerWords">Include Trigger Words in LoRA Syntax</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="includeTriggerWords"
|
||||||
|
onchange="settingsManager.saveToggleSetting('includeTriggerWords', 'include_trigger_words')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-help">
|
||||||
|
Include trained trigger words when copying LoRA syntax to clipboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
@@ -2,6 +2,52 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### v0.8.16
|
||||||
|
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
|
||||||
|
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
|
||||||
|
* **Customizable Display Density** - Replaced compact mode with adjustable display density settings for personalized layout customization
|
||||||
|
* **Model Creator Information** - Added creator details to model information panels for better attribution
|
||||||
|
* **Improved WebP Support** - Enhanced Save Image node with workflow embedding capability for WebP format images
|
||||||
|
* **Direct Example Access** - Added "Open Example Images Folder" button to card interfaces for convenient browsing of downloaded model examples
|
||||||
|
* **Enhanced Compatibility** - Full ComfyUI Desktop support for "Send lora or recipe to workflow" functionality
|
||||||
|
* **Cache Management** - Added settings to clear existing cache files when needed
|
||||||
|
* **Bug Fixes & Stability** - Various improvements for overall reliability and performance
|
||||||
|
|
||||||
|
### v0.8.15
|
||||||
|
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
|
||||||
|
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
|
||||||
|
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
|
||||||
|
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
|
||||||
|
|
||||||
|
### v0.8.14
|
||||||
|
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
|
||||||
|
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
|
||||||
|
* **Enhanced LoRA Node Functionality** - Comprehensive improvements to LoRA loader/stacker nodes including real-time trigger word updates (reflecting any change anywhere in the LoRA chain for precise updates) and expanded context menu with "Copy Notes" and "Copy Trigger Words" options for faster workflow
|
||||||
|
|
||||||
|
### v0.8.13
|
||||||
|
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
|
||||||
|
* **Improved Source Tracking** - Source URLs are now saved with recipes imported via URL, allowing users to view original content with one click or manually edit links
|
||||||
|
* **Advanced LoRA Control** - Double-click LoRAs in Loader/Stacker nodes to access expanded CLIP strength controls for more precise adjustments of model and CLIP strength separately
|
||||||
|
* **Lycoris Model Support** - Added compatibility with Lycoris models for expanded creative options
|
||||||
|
* **Bug Fixes & UX Improvements** - Resolved various issues and enhanced overall user experience with numerous optimizations
|
||||||
|
|
||||||
|
### v0.8.12
|
||||||
|
* **Enhanced Model Discovery** - Added alphabetical navigation bar to LoRAs page for faster browsing through large collections
|
||||||
|
* **Optimized Example Images** - Improved download logic to automatically refresh stale metadata before fetching example images
|
||||||
|
* **Model Exclusion System** - New right-click option to exclude specific LoRAs or checkpoints from management
|
||||||
|
* **Improved Showcase Experience** - Enhanced interaction in LoRA and checkpoint showcase areas for better usability
|
||||||
|
|
||||||
|
### v0.8.11
|
||||||
|
* **Offline Image Support** - Added functionality to download and save all model example images locally, ensuring access even when offline or if images are removed from CivitAI or the site is down
|
||||||
|
* **Resilient Download System** - Implemented pause/resume capability with checkpoint recovery that persists through restarts or unexpected exits
|
||||||
|
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||||
|
|
||||||
|
### v0.8.10
|
||||||
|
* **Standalone Mode** - Run LoRA Manager independently from ComfyUI for a lightweight experience that works even with other stable diffusion interfaces
|
||||||
|
* **Portable Edition** - New one-click portable version for easy startup and updates in standalone mode
|
||||||
|
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
|
||||||
|
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
|
||||||
|
|
||||||
### v0.8.9
|
### v0.8.9
|
||||||
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
|
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
|
||||||
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
|
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
BIN
wiki-images/civitai-image-page.jpg
Normal file
BIN
wiki-images/civitai-image-page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 597 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 162 KiB |
Reference in New Issue
Block a user