mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca692ed0f2 | ||
|
|
af499565d3 | ||
|
|
fe2d7e3a9e | ||
|
|
9f69822221 | ||
|
|
bb43f047c2 | ||
|
|
2356662492 | ||
|
|
1624a45093 | ||
|
|
dcb9983786 | ||
|
|
83d1828905 | ||
|
|
6a281cf3ee | ||
|
|
ed1cd39a6c | ||
|
|
dda19b3920 | ||
|
|
25139ca922 | ||
|
|
3cd57a582c | ||
|
|
d3903ac655 | ||
|
|
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 |
19
README.md
19
README.md
@@ -34,6 +34,22 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.8.28
|
||||||
|
* **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI.
|
||||||
|
* **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience.
|
||||||
|
* **Download Example Images from Context Menu** - Introduced a new context menu option to download example images for individual models.
|
||||||
|
|
||||||
|
### v0.8.27
|
||||||
|
* **User Experience Enhancements** - Improved the model download target folder selection with path input autocomplete and interactive folder tree navigation, making it easier and faster to choose where models are saved.
|
||||||
|
* **Default Path Option for Downloads** - Added a "Use Default Path" option when downloading models. When enabled, models are automatically organized and stored according to your configured path template settings.
|
||||||
|
* **Advanced Download Path Templates** - Expanded path template settings, allowing users to set individual templates for LoRA, checkpoint, and embedding models for greater flexibility. Introduced the `{author}` placeholder, enabling automatic organization of model files by creator name.
|
||||||
|
* **Bug Fixes & Stability Improvements** - Addressed various bugs and improved overall stability for a smoother experience.
|
||||||
|
|
||||||
|
### v0.8.26
|
||||||
|
* **Creator Search Option** - Added ability to search models by creator name, making it easier to find models from specific authors.
|
||||||
|
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
|
||||||
|
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
|
||||||
|
|
||||||
### v0.8.25
|
### v0.8.25
|
||||||
* **LoRA List Reordering**
|
* **LoRA List Reordering**
|
||||||
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
|
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
|
||||||
@@ -146,10 +162,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**
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import List
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
standalone_mode = 'nodes' not in sys.modules
|
standalone_mode = 'nodes' not in sys.modules
|
||||||
@@ -275,8 +276,10 @@ class Config:
|
|||||||
|
|
||||||
for path, route in self._route_mappings.items():
|
for path, route in self._route_mappings.items():
|
||||||
if real_path.startswith(path):
|
if real_path.startswith(path):
|
||||||
relative_path = os.path.relpath(real_path, path)
|
relative_path = os.path.relpath(real_path, path).replace(os.sep, '/')
|
||||||
return f'{route}/{relative_path.replace(os.sep, "/")}'
|
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
||||||
|
safe_path = '/'.join(safe_parts)
|
||||||
|
return f'{route}/{safe_path}'
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -339,44 +339,8 @@ class MetadataProcessor:
|
|||||||
is_custom_advanced = prompt.original_prompt[primary_sampler_id].get("class_type") == "SamplerCustomAdvanced"
|
is_custom_advanced = prompt.original_prompt[primary_sampler_id].get("class_type") == "SamplerCustomAdvanced"
|
||||||
|
|
||||||
if is_custom_advanced:
|
if is_custom_advanced:
|
||||||
# For SamplerCustomAdvanced, trace specific inputs
|
# For SamplerCustomAdvanced, use the new handler method
|
||||||
|
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
||||||
# 1. Trace sigmas input to find BasicScheduler
|
|
||||||
scheduler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sigmas", "BasicScheduler", max_depth=5)
|
|
||||||
if scheduler_node_id and scheduler_node_id in metadata.get(SAMPLING, {}):
|
|
||||||
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
|
||||||
params["steps"] = scheduler_params.get("steps")
|
|
||||||
params["scheduler"] = scheduler_params.get("scheduler")
|
|
||||||
|
|
||||||
# 2. Trace sampler input to find KSamplerSelect
|
|
||||||
sampler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sampler", "KSamplerSelect", max_depth=5)
|
|
||||||
if sampler_node_id and sampler_node_id in metadata.get(SAMPLING, {}):
|
|
||||||
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
|
|
||||||
params["sampler"] = sampler_params.get("sampler_name")
|
|
||||||
|
|
||||||
# 3. Trace guider input for CFGGuider and CLIPTextEncode
|
|
||||||
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
|
|
||||||
if guider_node_id and guider_node_id in prompt.original_prompt:
|
|
||||||
# Check if the guider node is a CFGGuider
|
|
||||||
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
|
|
||||||
# Extract cfg value from the CFGGuider
|
|
||||||
if guider_node_id in metadata.get(SAMPLING, {}):
|
|
||||||
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
|
|
||||||
params["cfg_scale"] = cfg_params.get("cfg")
|
|
||||||
|
|
||||||
# Find CLIPTextEncode for positive prompt
|
|
||||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
|
|
||||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
|
||||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
|
||||||
|
|
||||||
# Find CLIPTextEncode for negative prompt
|
|
||||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", "CLIPTextEncode", max_depth=10)
|
|
||||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
|
||||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
|
||||||
else:
|
|
||||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
|
||||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
|
||||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# For standard samplers, match conditioning objects to prompts
|
# For standard samplers, match conditioning objects to prompts
|
||||||
@@ -402,6 +366,9 @@ class MetadataProcessor:
|
|||||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||||
|
|
||||||
|
# For SamplerCustom, handle any additional parameters
|
||||||
|
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
||||||
|
|
||||||
# Size extraction is same for all sampler types
|
# Size extraction is same for all sampler types
|
||||||
# Check if the sampler itself has size information (from latent_image)
|
# Check if the sampler itself has size information (from latent_image)
|
||||||
if primary_sampler_id in metadata.get(SIZE, {}):
|
if primary_sampler_id in metadata.get(SIZE, {}):
|
||||||
@@ -454,3 +421,59 @@ class MetadataProcessor:
|
|||||||
"""Convert metadata to JSON string"""
|
"""Convert metadata to JSON string"""
|
||||||
params = MetadataProcessor.to_dict(metadata, id)
|
params = MetadataProcessor.to_dict(metadata, id)
|
||||||
return json.dumps(params, indent=4)
|
return json.dumps(params, indent=4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params):
|
||||||
|
"""
|
||||||
|
Handle parameter extraction for SamplerCustomAdvanced nodes
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- metadata: The workflow metadata
|
||||||
|
- prompt: The prompt object containing node connections
|
||||||
|
- primary_sampler_id: ID of the SamplerCustomAdvanced node
|
||||||
|
- params: Parameters dictionary to update
|
||||||
|
"""
|
||||||
|
if not prompt.original_prompt or primary_sampler_id not in prompt.original_prompt:
|
||||||
|
return
|
||||||
|
|
||||||
|
sampler_inputs = prompt.original_prompt[primary_sampler_id].get("inputs", {})
|
||||||
|
|
||||||
|
# 1. Trace sigmas input to find BasicScheduler (only if sigmas input exists)
|
||||||
|
if "sigmas" in sampler_inputs:
|
||||||
|
scheduler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sigmas", None, max_depth=5)
|
||||||
|
if scheduler_node_id and scheduler_node_id in metadata.get(SAMPLING, {}):
|
||||||
|
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
||||||
|
params["steps"] = scheduler_params.get("steps")
|
||||||
|
params["scheduler"] = scheduler_params.get("scheduler")
|
||||||
|
|
||||||
|
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
|
||||||
|
if "sampler" in sampler_inputs:
|
||||||
|
sampler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sampler", "KSamplerSelect", max_depth=5)
|
||||||
|
if sampler_node_id and sampler_node_id in metadata.get(SAMPLING, {}):
|
||||||
|
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
|
||||||
|
params["sampler"] = sampler_params.get("sampler_name")
|
||||||
|
|
||||||
|
# 3. Trace guider input for CFGGuider and CLIPTextEncode
|
||||||
|
if "guider" in sampler_inputs:
|
||||||
|
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
|
||||||
|
if guider_node_id and guider_node_id in prompt.original_prompt:
|
||||||
|
# Check if the guider node is a CFGGuider
|
||||||
|
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
|
||||||
|
# Extract cfg value from the CFGGuider
|
||||||
|
if guider_node_id in metadata.get(SAMPLING, {}):
|
||||||
|
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
|
||||||
|
params["cfg_scale"] = cfg_params.get("cfg")
|
||||||
|
|
||||||
|
# Find CLIPTextEncode for positive prompt
|
||||||
|
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
|
||||||
|
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||||
|
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||||
|
|
||||||
|
# Find CLIPTextEncode for negative prompt
|
||||||
|
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", "CLIPTextEncode", max_depth=10)
|
||||||
|
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||||
|
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||||
|
else:
|
||||||
|
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||||
|
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||||
|
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ NODE_EXTRACTORS = {
|
|||||||
# Sampling
|
# Sampling
|
||||||
"KSampler": SamplerExtractor,
|
"KSampler": SamplerExtractor,
|
||||||
"KSamplerAdvanced": KSamplerAdvancedExtractor,
|
"KSamplerAdvanced": KSamplerAdvancedExtractor,
|
||||||
|
"SamplerCustom": KSamplerAdvancedExtractor,
|
||||||
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
|
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
|
||||||
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
||||||
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
|
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
|
||||||
@@ -652,9 +653,11 @@ NODE_EXTRACTORS = {
|
|||||||
# Sampling Selectors
|
# Sampling Selectors
|
||||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||||
|
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||||
# Loaders
|
# Loaders
|
||||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||||
|
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -10,6 +11,8 @@ import jinja2
|
|||||||
from ..utils.routes_common import ModelRouteUtils
|
from ..utils.routes_common import ModelRouteUtils
|
||||||
from ..services.websocket_manager import ws_manager
|
from ..services.websocket_manager import ws_manager
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
|
from ..utils.utils import calculate_relative_path_for_model
|
||||||
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,7 +41,7 @@ class BaseModelRoutes(ABC):
|
|||||||
prefix: URL prefix (e.g., 'loras', 'checkpoints')
|
prefix: URL prefix (e.g., 'loras', 'checkpoints')
|
||||||
"""
|
"""
|
||||||
# Common model management routes
|
# Common model management routes
|
||||||
app.router.add_get(f'/api/{prefix}', self.get_models)
|
app.router.add_get(f'/api/{prefix}/list', self.get_models)
|
||||||
app.router.add_post(f'/api/{prefix}/delete', self.delete_model)
|
app.router.add_post(f'/api/{prefix}/delete', self.delete_model)
|
||||||
app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model)
|
app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model)
|
||||||
app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai)
|
app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai)
|
||||||
@@ -50,6 +53,8 @@ class BaseModelRoutes(ABC):
|
|||||||
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
|
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
|
||||||
app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
|
app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
|
||||||
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
|
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
|
||||||
|
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
|
||||||
|
app.router.add_get(f'/api/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
|
||||||
|
|
||||||
# Common query routes
|
# Common query routes
|
||||||
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
|
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
|
||||||
@@ -57,8 +62,16 @@ 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)
|
||||||
|
app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
|
||||||
|
app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
|
||||||
|
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
|
||||||
|
|
||||||
|
# Autocomplete route
|
||||||
|
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
|
||||||
|
|
||||||
# Common Download management
|
# Common Download management
|
||||||
app.router.add_post(f'/api/download-model', self.download_model)
|
app.router.add_post(f'/api/download-model', self.download_model)
|
||||||
@@ -177,6 +190,7 @@ class BaseModelRoutes(ABC):
|
|||||||
'filename': request.query.get('search_filename', 'true').lower() == 'true',
|
'filename': request.query.get('search_filename', 'true').lower() == 'true',
|
||||||
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
||||||
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
||||||
|
'creator': request.query.get('search_creator', 'false').lower() == 'true',
|
||||||
'recursive': request.query.get('recursive', 'false').lower() == 'true',
|
'recursive': request.query.get('recursive', 'false').lower() == 'true',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +359,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:
|
||||||
@@ -696,3 +747,403 @@ class BaseModelRoutes(ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||||
return web.Response(text=str(e), status=500)
|
return web.Response(text=str(e), status=500)
|
||||||
|
|
||||||
|
async def auto_organize_models(self, request: web.Request) -> web.Response:
|
||||||
|
"""Auto-organize all models based on current settings"""
|
||||||
|
try:
|
||||||
|
# Check if auto-organize is already running
|
||||||
|
if ws_manager.is_auto_organize_running():
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Auto-organize is already running. Please wait for it to complete.'
|
||||||
|
}, status=409)
|
||||||
|
|
||||||
|
# Acquire lock to prevent concurrent auto-organize operations
|
||||||
|
auto_organize_lock = await ws_manager.get_auto_organize_lock()
|
||||||
|
|
||||||
|
if auto_organize_lock.locked():
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Auto-organize is already running. Please wait for it to complete.'
|
||||||
|
}, status=409)
|
||||||
|
|
||||||
|
async with auto_organize_lock:
|
||||||
|
return await self._perform_auto_organize()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Send error message via WebSocket and cleanup
|
||||||
|
await ws_manager.broadcast_auto_organize_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def _perform_auto_organize(self) -> web.Response:
|
||||||
|
"""Perform the actual auto-organize operation"""
|
||||||
|
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:
|
||||||
|
await ws_manager.broadcast_auto_organize_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'error',
|
||||||
|
'error': 'No model roots configured'
|
||||||
|
})
|
||||||
|
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_auto_organize_progress({
|
||||||
|
'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_auto_organize_progress({
|
||||||
|
'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_auto_organize_progress({
|
||||||
|
'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_auto_organize_progress({
|
||||||
|
'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 _perform_auto_organize: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Send error message via WebSocket
|
||||||
|
await ws_manager.broadcast_auto_organize_progress({
|
||||||
|
'type': 'auto_organize_progress',
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def get_auto_organize_progress(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get current auto-organize progress for polling"""
|
||||||
|
try:
|
||||||
|
progress_data = ws_manager.get_auto_organize_progress()
|
||||||
|
|
||||||
|
if progress_data is None:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'No auto-organize operation in progress'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'progress': progress_data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting auto-organize progress: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_model_notes(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get notes for a specific model file"""
|
||||||
|
try:
|
||||||
|
model_name = request.query.get('name')
|
||||||
|
if not model_name:
|
||||||
|
return web.Response(text=f'{self.model_type.capitalize()} file name is required', status=400)
|
||||||
|
|
||||||
|
notes = await self.service.get_model_notes(model_name)
|
||||||
|
if notes is not None:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'notes': notes
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'{self.model_type.capitalize()} not found in cache'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting {self.model_type} notes: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_model_preview_url(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get the static preview URL for a model file"""
|
||||||
|
try:
|
||||||
|
model_name = request.query.get('name')
|
||||||
|
if not model_name:
|
||||||
|
return web.Response(text=f'{self.model_type.capitalize()} file name is required', status=400)
|
||||||
|
|
||||||
|
preview_url = await self.service.get_model_preview_url(model_name)
|
||||||
|
if preview_url:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'preview_url': preview_url
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'No preview URL found for the specified {self.model_type}'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting {self.model_type} preview URL: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_model_civitai_url(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get the Civitai URL for a model file"""
|
||||||
|
try:
|
||||||
|
model_name = request.query.get('name')
|
||||||
|
if not model_name:
|
||||||
|
return web.Response(text=f'{self.model_type.capitalize()} file name is required', status=400)
|
||||||
|
|
||||||
|
result = await self.service.get_model_civitai_url(model_name)
|
||||||
|
if result['civitai_url']:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
**result
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'No Civitai data found for the specified {self.model_type}'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting {self.model_type} Civitai URL: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get model relative file paths for autocomplete functionality"""
|
||||||
|
try:
|
||||||
|
search = request.query.get('search', '').strip()
|
||||||
|
limit = min(int(request.query.get('limit', '15')), 50) # Max 50 items
|
||||||
|
|
||||||
|
matching_paths = await self.service.search_relative_paths(search, limit)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'relative_paths': matching_paths
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting relative paths for autocomplete: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from ..utils.example_images_download_manager import DownloadManager
|
from ..utils.example_images_download_manager import DownloadManager
|
||||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||||
from ..utils.example_images_file_manager import ExampleImagesFileManager
|
from ..utils.example_images_file_manager import ExampleImagesFileManager
|
||||||
|
from ..services.websocket_manager import ws_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class ExampleImagesRoutes:
|
|||||||
app.router.add_get('/api/example-image-files', ExampleImagesRoutes.get_example_image_files)
|
app.router.add_get('/api/example-image-files', ExampleImagesRoutes.get_example_image_files)
|
||||||
app.router.add_get('/api/has-example-images', ExampleImagesRoutes.has_example_images)
|
app.router.add_get('/api/has-example-images', ExampleImagesRoutes.has_example_images)
|
||||||
app.router.add_post('/api/delete-example-image', ExampleImagesRoutes.delete_example_image)
|
app.router.add_post('/api/delete-example-image', ExampleImagesRoutes.delete_example_image)
|
||||||
|
app.router.add_post('/api/force-download-example-images', ExampleImagesRoutes.force_download_example_images)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def download_example_images(request):
|
async def download_example_images(request):
|
||||||
@@ -65,3 +67,8 @@ class ExampleImagesRoutes:
|
|||||||
async def delete_example_image(request):
|
async def delete_example_image(request):
|
||||||
"""Delete a custom example image for a model"""
|
"""Delete a custom example image for a model"""
|
||||||
return await ExampleImagesProcessor.delete_custom_image(request)
|
return await ExampleImagesProcessor.delete_custom_image(request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def force_download_example_images(request):
|
||||||
|
"""Force download example images for specific models"""
|
||||||
|
return await DownloadManager.start_force_download(request)
|
||||||
@@ -43,11 +43,9 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
"""Setup LoRA-specific routes"""
|
"""Setup LoRA-specific routes"""
|
||||||
# LoRA-specific query routes
|
# LoRA-specific query routes
|
||||||
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
|
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
|
||||||
app.router.add_get(f'/api/{prefix}/get-notes', self.get_lora_notes)
|
|
||||||
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
|
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
|
||||||
app.router.add_get(f'/api/{prefix}/preview-url', self.get_lora_preview_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)
|
||||||
|
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -143,6 +141,26 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get usage tips for a LoRA by its relative path"""
|
||||||
|
try:
|
||||||
|
relative_path = request.query.get('relative_path')
|
||||||
|
if not relative_path:
|
||||||
|
return web.Response(text='Relative path is required', status=400)
|
||||||
|
|
||||||
|
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(relative_path)
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'usage_tips': usage_tips or ''
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
|
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
|
||||||
"""Get the static preview URL for a LoRA file"""
|
"""Get the static preview URL for a LoRA file"""
|
||||||
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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Optional, Type
|
from typing import Dict, List, Optional, Type
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from ..utils.constants import NSFW_LEVELS
|
||||||
@@ -200,6 +201,22 @@ class BaseModelService(ABC):
|
|||||||
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
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
@@ -257,3 +274,149 @@ 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
|
||||||
|
|
||||||
|
async def get_model_notes(self, model_name: str) -> Optional[str]:
|
||||||
|
"""Get notes for a specific model file"""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for model in cache.raw_data:
|
||||||
|
if model['file_name'] == model_name:
|
||||||
|
return model.get('notes', '')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_preview_url(self, model_name: str) -> Optional[str]:
|
||||||
|
"""Get the static preview URL for a model file"""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for model in cache.raw_data:
|
||||||
|
if model['file_name'] == model_name:
|
||||||
|
preview_url = model.get('preview_url')
|
||||||
|
if preview_url:
|
||||||
|
from ..config import config
|
||||||
|
return config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||||
|
"""Get the Civitai URL for a model file"""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for model in cache.raw_data:
|
||||||
|
if model['file_name'] == model_name:
|
||||||
|
civitai_data = model.get('civitai', {})
|
||||||
|
model_id = civitai_data.get('modelId')
|
||||||
|
version_id = civitai_data.get('id')
|
||||||
|
|
||||||
|
if model_id:
|
||||||
|
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||||
|
if version_id:
|
||||||
|
civitai_url += f"?modelVersionId={version_id}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
'civitai_url': civitai_url,
|
||||||
|
'model_id': str(model_id),
|
||||||
|
'version_id': str(version_id) if version_id else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
||||||
|
|
||||||
|
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
||||||
|
"""Search model relative file paths for autocomplete functionality"""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
matching_paths = []
|
||||||
|
search_lower = search_term.lower()
|
||||||
|
|
||||||
|
# Get model roots for path calculation
|
||||||
|
model_roots = self.scanner.get_model_roots()
|
||||||
|
|
||||||
|
for model in cache.raw_data:
|
||||||
|
file_path = model.get('file_path', '')
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate relative path from model root
|
||||||
|
relative_path = 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):
|
||||||
|
# Remove root and leading slash to get relative path
|
||||||
|
relative_path = normalized_file[len(normalized_root):].lstrip('/')
|
||||||
|
break
|
||||||
|
|
||||||
|
if relative_path and search_lower in relative_path.lower():
|
||||||
|
matching_paths.append(relative_path)
|
||||||
|
|
||||||
|
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
||||||
|
break
|
||||||
|
|
||||||
|
# Sort by relevance (exact matches first, then by length)
|
||||||
|
matching_paths.sort(key=lambda x: (
|
||||||
|
not x.lower().startswith(search_lower), # Exact prefix matches first
|
||||||
|
len(x), # Then by length (shorter first)
|
||||||
|
x.lower() # Then alphabetically
|
||||||
|
))
|
||||||
|
|
||||||
|
return matching_paths[:limit]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
|
||||||
return None
|
|
||||||
|
|
||||||
version = await response.json()
|
# Case 1: Only version_id is provided
|
||||||
|
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
|
||||||
|
|
||||||
# Step 4: Enrich version_info with model data
|
version = await response.json()
|
||||||
# Add description and tags from model data
|
model_id = version.get('modelId')
|
||||||
version['model']['description'] = data.get("description")
|
|
||||||
version['model']['tags'] = data.get("tags", [])
|
|
||||||
|
|
||||||
# Add creator from model data
|
if not model_id:
|
||||||
version['creator'] = data.get("creator")
|
logger.error(f"No modelId found in version {version_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
return version
|
# 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 3: Get detailed version info using the version_id
|
||||||
|
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = await response.json()
|
||||||
|
|
||||||
|
# Step 4: Enrich version_info with model data
|
||||||
|
# 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,15 +185,20 @@ 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,13 @@ 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
|
||||||
|
creator_info = version_info.get('creator')
|
||||||
|
if creator_info and isinstance(creator_info, dict):
|
||||||
|
author = creator_info.get('username') or 'Anonymous'
|
||||||
|
else:
|
||||||
|
author = '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 +380,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
|
||||||
|
|
||||||
|
|||||||
@@ -147,16 +147,6 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
return letters
|
return letters
|
||||||
|
|
||||||
async def get_lora_notes(self, lora_name: str) -> Optional[str]:
|
|
||||||
"""Get notes for a specific LoRA file"""
|
|
||||||
cache = await self.scanner.get_cached_data()
|
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
|
||||||
if lora['file_name'] == lora_name:
|
|
||||||
return lora.get('notes', '')
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
||||||
"""Get trigger words for a specific LoRA file"""
|
"""Get trigger words for a specific LoRA file"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
@@ -168,41 +158,21 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_lora_preview_url(self, lora_name: str) -> Optional[str]:
|
async def get_lora_usage_tips_by_relative_path(self, relative_path: str) -> Optional[str]:
|
||||||
"""Get the static preview URL for a LoRA file"""
|
"""Get usage tips for a LoRA by its relative path"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
for lora in cache.raw_data:
|
||||||
if lora['file_name'] == lora_name:
|
file_path = lora.get('file_path', '')
|
||||||
preview_url = lora.get('preview_url')
|
if file_path:
|
||||||
if preview_url:
|
# Convert to forward slashes and extract relative path
|
||||||
return config.get_preview_static_url(preview_url)
|
file_path_normalized = file_path.replace('\\', '/')
|
||||||
|
# Find the relative path part by looking for the relative_path in the full path
|
||||||
|
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
|
||||||
|
return lora.get('usage_tips', '')
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_lora_civitai_url(self, lora_name: str) -> Dict[str, Optional[str]]:
|
|
||||||
"""Get the Civitai URL for a LoRA file"""
|
|
||||||
cache = await self.scanner.get_cached_data()
|
|
||||||
|
|
||||||
for lora in cache.raw_data:
|
|
||||||
if lora['file_name'] == lora_name:
|
|
||||||
civitai_data = lora.get('civitai', {})
|
|
||||||
model_id = civitai_data.get('modelId')
|
|
||||||
version_id = civitai_data.get('id')
|
|
||||||
|
|
||||||
if model_id:
|
|
||||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
|
||||||
if version_id:
|
|
||||||
civitai_url += f"?modelVersionId={version_id}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
'civitai_url': civitai_url,
|
|
||||||
'model_id': str(model_id),
|
|
||||||
'version_id': str(version_id) if version_id else None
|
|
||||||
}
|
|
||||||
|
|
||||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
"""Find LoRAs with duplicate SHA256 hashes"""
|
"""Find LoRAs with duplicate SHA256 hashes"""
|
||||||
return self.scanner._hash_index.get_duplicate_hashes()
|
return self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -671,6 +685,14 @@ class ModelScanner:
|
|||||||
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)
|
||||||
folder = os.path.dirname(rel_path)
|
folder = os.path.dirname(rel_path)
|
||||||
@@ -1149,11 +1171,10 @@ 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:
|
||||||
@@ -1165,9 +1186,7 @@ class ModelScanner:
|
|||||||
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class WebSocketManager:
|
|||||||
self._download_websockets: Dict[str, web.WebSocketResponse] = {} # New dict for download-specific clients
|
self._download_websockets: Dict[str, web.WebSocketResponse] = {} # New dict for download-specific clients
|
||||||
# Add progress tracking dictionary
|
# Add progress tracking dictionary
|
||||||
self._download_progress: Dict[str, Dict] = {}
|
self._download_progress: Dict[str, Dict] = {}
|
||||||
|
# Add auto-organize progress tracking
|
||||||
|
self._auto_organize_progress: Optional[Dict] = None
|
||||||
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
"""Handle new WebSocket connection"""
|
"""Handle new WebSocket connection"""
|
||||||
@@ -134,6 +137,33 @@ class WebSocketManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending download progress: {e}")
|
logger.error(f"Error sending download progress: {e}")
|
||||||
|
|
||||||
|
async def broadcast_auto_organize_progress(self, data: Dict):
|
||||||
|
"""Broadcast auto-organize progress to connected clients"""
|
||||||
|
# Store progress data in memory
|
||||||
|
self._auto_organize_progress = data
|
||||||
|
|
||||||
|
# Broadcast via WebSocket
|
||||||
|
await self.broadcast(data)
|
||||||
|
|
||||||
|
def get_auto_organize_progress(self) -> Optional[Dict]:
|
||||||
|
"""Get current auto-organize progress"""
|
||||||
|
return self._auto_organize_progress
|
||||||
|
|
||||||
|
def cleanup_auto_organize_progress(self):
|
||||||
|
"""Clear auto-organize progress data"""
|
||||||
|
self._auto_organize_progress = None
|
||||||
|
|
||||||
|
def is_auto_organize_running(self) -> bool:
|
||||||
|
"""Check if auto-organize is currently running"""
|
||||||
|
if not self._auto_organize_progress:
|
||||||
|
return False
|
||||||
|
status = self._auto_organize_progress.get('status')
|
||||||
|
return status in ['started', 'processing', 'cleaning']
|
||||||
|
|
||||||
|
async def get_auto_organize_lock(self):
|
||||||
|
"""Get the auto-organize lock"""
|
||||||
|
return self._auto_organize_lock
|
||||||
|
|
||||||
def get_download_progress(self, download_id: str) -> Optional[Dict]:
|
def get_download_progress(self, download_id: str) -> Optional[Dict]:
|
||||||
"""Get progress information for a specific download"""
|
"""Get progress information for a specific download"""
|
||||||
return self._download_progress.get(download_id)
|
return self._download_progress.get(download_id)
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
@@ -6,8 +6,10 @@ import time
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .example_images_processor import ExampleImagesProcessor
|
from .example_images_processor import ExampleImagesProcessor
|
||||||
from .example_images_metadata import MetadataUpdater
|
from .example_images_metadata import MetadataUpdater
|
||||||
|
from ..services.websocket_manager import ws_manager # Add this import at the top
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -24,7 +26,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 +53,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 +95,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 +120,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 +144,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 +239,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 +259,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 +308,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 +322,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)
|
||||||
@@ -352,11 +368,22 @@ class DownloadManager:
|
|||||||
model_hash, model_name, updated_images, model_dir, optimize, independent_session
|
model_hash, model_name, updated_images, model_dir, optimize, independent_session
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only mark as processed if all images were downloaded successfully
|
download_progress['refreshed_models'].add(model_hash)
|
||||||
|
|
||||||
|
# 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 +418,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()
|
||||||
@@ -406,3 +434,363 @@ class DownloadManager:
|
|||||||
json.dump(progress_data, f, indent=2)
|
json.dump(progress_data, f, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save progress file: {e}")
|
logger.error(f"Failed to save progress file: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def start_force_download(request):
|
||||||
|
"""
|
||||||
|
Force download example images for specific models
|
||||||
|
|
||||||
|
Expects a JSON body with:
|
||||||
|
{
|
||||||
|
"model_hashes": ["hash1", "hash2", ...], # List of model hashes to download
|
||||||
|
"output_dir": "path/to/output", # Base directory to save example images
|
||||||
|
"optimize": true, # Whether to optimize images (default: true)
|
||||||
|
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
|
||||||
|
"delay": 1.0 # Delay between downloads (default: 1.0)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
global download_task, is_downloading, download_progress
|
||||||
|
|
||||||
|
if is_downloading:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Download already in progress'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse the request body
|
||||||
|
data = await request.json()
|
||||||
|
model_hashes = data.get('model_hashes', [])
|
||||||
|
output_dir = data.get('output_dir')
|
||||||
|
optimize = data.get('optimize', True)
|
||||||
|
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
||||||
|
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
|
||||||
|
|
||||||
|
if not model_hashes:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Missing model_hashes parameter'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
if not output_dir:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Missing output_dir parameter'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Create the output directory
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize progress tracking
|
||||||
|
download_progress['total'] = len(model_hashes)
|
||||||
|
download_progress['completed'] = 0
|
||||||
|
download_progress['current_model'] = ''
|
||||||
|
download_progress['status'] = 'running'
|
||||||
|
download_progress['errors'] = []
|
||||||
|
download_progress['last_error'] = None
|
||||||
|
download_progress['start_time'] = time.time()
|
||||||
|
download_progress['end_time'] = None
|
||||||
|
download_progress['processed_models'] = set()
|
||||||
|
download_progress['refreshed_models'] = set()
|
||||||
|
download_progress['failed_models'] = set()
|
||||||
|
|
||||||
|
# Set download status to downloading
|
||||||
|
is_downloading = True
|
||||||
|
|
||||||
|
# Execute the download function directly instead of creating a background task
|
||||||
|
result = await DownloadManager._download_specific_models_example_images_sync(
|
||||||
|
model_hashes,
|
||||||
|
output_dir,
|
||||||
|
optimize,
|
||||||
|
model_types,
|
||||||
|
delay
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set download status to not downloading
|
||||||
|
is_downloading = False
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Force download completed',
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Set download status to not downloading
|
||||||
|
is_downloading = False
|
||||||
|
logger.error(f"Failed during forced example images download: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _download_specific_models_example_images_sync(model_hashes, output_dir, optimize, model_types, delay):
|
||||||
|
"""Download example images for specific models only - synchronous version"""
|
||||||
|
global download_progress
|
||||||
|
|
||||||
|
# Create independent download session
|
||||||
|
connector = aiohttp.TCPConnector(
|
||||||
|
ssl=True,
|
||||||
|
limit=3,
|
||||||
|
force_close=False,
|
||||||
|
enable_cleanup_closed=True
|
||||||
|
)
|
||||||
|
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=60)
|
||||||
|
independent_session = aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
trust_env=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get scanners
|
||||||
|
scanners = []
|
||||||
|
if 'lora' in model_types:
|
||||||
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
scanners.append(('lora', lora_scanner))
|
||||||
|
|
||||||
|
if 'checkpoint' in model_types:
|
||||||
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
scanners.append(('checkpoint', checkpoint_scanner))
|
||||||
|
|
||||||
|
if 'embedding' in model_types:
|
||||||
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
scanners.append(('embedding', embedding_scanner))
|
||||||
|
|
||||||
|
# Find the specified models
|
||||||
|
models_to_process = []
|
||||||
|
for scanner_type, scanner in scanners:
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache and cache.raw_data:
|
||||||
|
for model in cache.raw_data:
|
||||||
|
if model.get('sha256') in model_hashes:
|
||||||
|
models_to_process.append((scanner_type, model, scanner))
|
||||||
|
|
||||||
|
# Update total count based on found models
|
||||||
|
download_progress['total'] = len(models_to_process)
|
||||||
|
logger.debug(f"Found {download_progress['total']} models to process")
|
||||||
|
|
||||||
|
# Send initial progress via WebSocket
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
'type': 'example_images_progress',
|
||||||
|
'processed': 0,
|
||||||
|
'total': download_progress['total'],
|
||||||
|
'status': 'running',
|
||||||
|
'current_model': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process each model
|
||||||
|
success_count = 0
|
||||||
|
for i, (scanner_type, model, scanner) in enumerate(models_to_process):
|
||||||
|
# Force process this model regardless of previous status
|
||||||
|
was_successful = await DownloadManager._process_specific_model(
|
||||||
|
scanner_type, model, scanner,
|
||||||
|
output_dir, optimize, independent_session
|
||||||
|
)
|
||||||
|
|
||||||
|
if was_successful:
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
download_progress['completed'] += 1
|
||||||
|
|
||||||
|
# Send progress update via WebSocket
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
'type': 'example_images_progress',
|
||||||
|
'processed': download_progress['completed'],
|
||||||
|
'total': download_progress['total'],
|
||||||
|
'status': 'running',
|
||||||
|
'current_model': download_progress['current_model']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Only add delay after remote download, and not after processing the last model
|
||||||
|
if was_successful and i < len(models_to_process) - 1 and download_progress['status'] == 'running':
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# Mark as completed
|
||||||
|
download_progress['status'] = 'completed'
|
||||||
|
download_progress['end_time'] = time.time()
|
||||||
|
logger.debug(f"Forced example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
|
||||||
|
|
||||||
|
# Send final progress via WebSocket
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
'type': 'example_images_progress',
|
||||||
|
'processed': download_progress['completed'],
|
||||||
|
'total': download_progress['total'],
|
||||||
|
'status': 'completed',
|
||||||
|
'current_model': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': download_progress['total'],
|
||||||
|
'processed': download_progress['completed'],
|
||||||
|
'successful': success_count,
|
||||||
|
'errors': download_progress['errors']
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error during forced example images download: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
download_progress['errors'].append(error_msg)
|
||||||
|
download_progress['last_error'] = error_msg
|
||||||
|
download_progress['status'] = 'error'
|
||||||
|
download_progress['end_time'] = time.time()
|
||||||
|
|
||||||
|
# Send error status via WebSocket
|
||||||
|
await ws_manager.broadcast({
|
||||||
|
'type': 'example_images_progress',
|
||||||
|
'processed': download_progress['completed'],
|
||||||
|
'total': download_progress['total'],
|
||||||
|
'status': 'error',
|
||||||
|
'error': error_msg,
|
||||||
|
'current_model': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Close the independent session
|
||||||
|
try:
|
||||||
|
await independent_session.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error closing download session: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _process_specific_model(scanner_type, model, scanner, output_dir, optimize, independent_session):
|
||||||
|
"""Process a specific model for forced download, ignoring previous download status"""
|
||||||
|
global download_progress
|
||||||
|
|
||||||
|
# Check if download is paused
|
||||||
|
while download_progress['status'] == 'paused':
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Check if download should continue
|
||||||
|
if download_progress['status'] != 'running':
|
||||||
|
logger.info(f"Download stopped: {download_progress['status']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
model_hash = model.get('sha256', '').lower()
|
||||||
|
model_name = model.get('model_name', 'Unknown')
|
||||||
|
model_file_path = model.get('file_path', '')
|
||||||
|
model_file_name = model.get('file_name', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update current model info
|
||||||
|
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
|
||||||
|
|
||||||
|
# Create model directory
|
||||||
|
model_dir = os.path.join(output_dir, model_hash)
|
||||||
|
os.makedirs(model_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# First check for local example images - local processing doesn't need delay
|
||||||
|
local_images_processed = await ExampleImagesProcessor.process_local_examples(
|
||||||
|
model_file_path, model_file_name, model_name, model_dir, optimize
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we processed local images, update metadata
|
||||||
|
if local_images_processed:
|
||||||
|
await MetadataUpdater.update_metadata_from_local_examples(
|
||||||
|
model_hash, model, scanner_type, scanner, model_dir
|
||||||
|
)
|
||||||
|
download_progress['processed_models'].add(model_hash)
|
||||||
|
return False # Return False to indicate no remote download happened
|
||||||
|
|
||||||
|
# If no local images, try to download from remote
|
||||||
|
elif model.get('civitai') and model.get('civitai', {}).get('images'):
|
||||||
|
images = model.get('civitai', {}).get('images', [])
|
||||||
|
|
||||||
|
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||||
|
model_hash, model_name, images, model_dir, optimize, independent_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# If metadata is stale, try to refresh it
|
||||||
|
if is_stale and model_hash not in download_progress['refreshed_models']:
|
||||||
|
await MetadataUpdater.refresh_model_metadata(
|
||||||
|
model_hash, model_name, scanner_type, scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the updated model data
|
||||||
|
updated_model = await MetadataUpdater.get_updated_model(
|
||||||
|
model_hash, scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_model and updated_model.get('civitai', {}).get('images'):
|
||||||
|
# Retry download with updated metadata
|
||||||
|
updated_images = updated_model.get('civitai', {}).get('images', [])
|
||||||
|
success, _, additional_failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||||
|
model_hash, model_name, updated_images, model_dir, optimize, independent_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combine failed images from both attempts
|
||||||
|
failed_images.extend(additional_failed_images)
|
||||||
|
|
||||||
|
download_progress['refreshed_models'].add(model_hash)
|
||||||
|
|
||||||
|
# For forced downloads, remove failed images from metadata
|
||||||
|
if failed_images:
|
||||||
|
# Create a copy of images excluding failed ones
|
||||||
|
await DownloadManager._remove_failed_images_from_metadata(
|
||||||
|
model_hash, model_name, failed_images, scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as processed
|
||||||
|
if success or failed_images: # Mark as processed if we successfully downloaded some images or removed failed ones
|
||||||
|
download_progress['processed_models'].add(model_hash)
|
||||||
|
|
||||||
|
return True # Return True to indicate a remote download happened
|
||||||
|
else:
|
||||||
|
logger.debug(f"No civitai images available for model {model_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error processing model {model.get('model_name')}: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
download_progress['errors'].append(error_msg)
|
||||||
|
download_progress['last_error'] = error_msg
|
||||||
|
return False # Return False on exception
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _remove_failed_images_from_metadata(model_hash, model_name, failed_images, scanner):
|
||||||
|
"""Remove failed images from model metadata"""
|
||||||
|
try:
|
||||||
|
# Get current model data
|
||||||
|
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
|
||||||
|
if not model_data:
|
||||||
|
logger.warning(f"Could not find model data for {model_name} to remove failed images")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not model_data.get('civitai', {}).get('images'):
|
||||||
|
logger.warning(f"No images in metadata for {model_name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get current images
|
||||||
|
current_images = model_data['civitai']['images']
|
||||||
|
|
||||||
|
# Filter out failed images
|
||||||
|
updated_images = [img for img in current_images if img.get('url') not in failed_images]
|
||||||
|
|
||||||
|
# If images were removed, update metadata
|
||||||
|
if len(updated_images) < len(current_images):
|
||||||
|
removed_count = len(current_images) - len(updated_images)
|
||||||
|
logger.info(f"Removing {removed_count} failed images from metadata for {model_name}")
|
||||||
|
|
||||||
|
# Update the images list
|
||||||
|
model_data['civitai']['images'] = updated_images
|
||||||
|
|
||||||
|
# Save metadata to file
|
||||||
|
file_path = model_data.get('file_path')
|
||||||
|
if file_path:
|
||||||
|
# Create a copy of model data without 'folder' field
|
||||||
|
model_copy = model_data.copy()
|
||||||
|
model_copy.pop('folder', None)
|
||||||
|
|
||||||
|
# Write metadata to file
|
||||||
|
await MetadataManager.save_metadata(file_path, model_copy)
|
||||||
|
logger.info(f"Saved updated metadata for {model_name} after removing failed images")
|
||||||
|
|
||||||
|
# Update the scanner cache
|
||||||
|
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing failed images from metadata for {model_name}: {e}", exc_info=True)
|
||||||
@@ -102,6 +102,78 @@ class ExampleImagesProcessor:
|
|||||||
|
|
||||||
return model_success, False # (success, is_metadata_stale)
|
return model_success, False # (success, is_metadata_stale)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def download_model_images_with_tracking(model_hash, model_name, model_images, model_dir, optimize, independent_session):
|
||||||
|
"""Download images for a single model with tracking of failed image URLs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, is_stale_metadata, failed_images) - whether download was successful, whether metadata is stale, list of failed image URLs
|
||||||
|
"""
|
||||||
|
model_success = True
|
||||||
|
failed_images = []
|
||||||
|
|
||||||
|
for i, image in enumerate(model_images):
|
||||||
|
image_url = image.get('url')
|
||||||
|
if not image_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get image filename from URL
|
||||||
|
image_filename = os.path.basename(image_url.split('?')[0])
|
||||||
|
image_ext = os.path.splitext(image_filename)[1].lower()
|
||||||
|
|
||||||
|
# Handle images and videos
|
||||||
|
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
|
||||||
|
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||||
|
|
||||||
|
if not (is_image or is_video):
|
||||||
|
logger.debug(f"Skipping unsupported file type: {image_filename}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use 0-based indexing instead of 1-based indexing
|
||||||
|
save_filename = f"image_{i}{image_ext}"
|
||||||
|
|
||||||
|
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
|
||||||
|
if is_image and optimize and 'civitai.com' in image_url:
|
||||||
|
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||||
|
save_filename = f"image_{i}.webp"
|
||||||
|
|
||||||
|
# Check if already downloaded
|
||||||
|
save_path = os.path.join(model_dir, save_filename)
|
||||||
|
if os.path.exists(save_path):
|
||||||
|
logger.debug(f"File already exists: {save_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Download the file
|
||||||
|
try:
|
||||||
|
logger.debug(f"Downloading {save_filename} for {model_name}")
|
||||||
|
|
||||||
|
# Download directly using the independent session
|
||||||
|
async with independent_session.get(image_url, timeout=60) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
with open(save_path, 'wb') as f:
|
||||||
|
async for chunk in response.content.iter_chunked(8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
elif response.status == 404:
|
||||||
|
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
model_success = False # Mark the model as failed due to 404 error
|
||||||
|
failed_images.append(image_url) # Track failed URL
|
||||||
|
# Return early to trigger metadata refresh attempt
|
||||||
|
return False, True, failed_images # (success, is_metadata_stale, failed_images)
|
||||||
|
else:
|
||||||
|
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
model_success = False # Mark the model as failed
|
||||||
|
failed_images.append(image_url) # Track failed URL
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error downloading file {image_url}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
model_success = False # Mark the model as failed
|
||||||
|
failed_images.append(image_url) # Track failed URL
|
||||||
|
|
||||||
|
return model_success, False, failed_images # (success, is_metadata_stale, failed_images)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
|
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
|
||||||
"""Process local example images
|
"""Process local example images
|
||||||
|
|||||||
@@ -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,7 +1,10 @@
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
import os
|
import os
|
||||||
|
from typing import Dict
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
from ..services.settings_manager import settings
|
||||||
|
from .constants import CIVITAI_MODEL_TAGS
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
def get_lora_info(lora_name):
|
def get_lora_info(lora_name):
|
||||||
@@ -47,7 +50,7 @@ def get_lora_info(lora_name):
|
|||||||
# No event loop is running, we can use asyncio.run()
|
# No event loop is running, we can use asyncio.run()
|
||||||
return asyncio.run(_get_lora_info_async())
|
return asyncio.run(_get_lora_info_async())
|
||||||
|
|
||||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool:
|
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if text matches pattern using fuzzy matching.
|
Check if text matches pattern using fuzzy matching.
|
||||||
Returns True if similarity ratio is above threshold.
|
Returns True if similarity ratio is above threshold.
|
||||||
@@ -128,3 +131,94 @@ def calculate_recipe_fingerprint(loras):
|
|||||||
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
|
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
|
||||||
|
|
||||||
return fingerprint
|
return fingerprint
|
||||||
|
|
||||||
|
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
|
||||||
|
"""Calculate relative path for existing model using template from settings
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_data: Model data from scanner cache
|
||||||
|
model_type: Type of model ('lora', 'checkpoint', 'embedding')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path string (empty string for flat structure)
|
||||||
|
"""
|
||||||
|
# Get path template from settings for specific model type
|
||||||
|
path_template = settings.get_download_path_template(model_type)
|
||||||
|
|
||||||
|
# If template is empty, return empty path (flat structure)
|
||||||
|
if not path_template:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# Get base model name from model metadata
|
||||||
|
civitai_data = model_data.get('civitai', {})
|
||||||
|
|
||||||
|
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
|
||||||
|
if civitai_data and civitai_data.get('id') is not None:
|
||||||
|
base_model = civitai_data.get('baseModel', '')
|
||||||
|
# Get author from civitai creator data
|
||||||
|
author = civitai_data.get('creator', {}).get('username') or '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,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "0.8.25"
|
version = "0.8.28"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -7,5 +7,4 @@ olefile
|
|||||||
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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 300px;
|
min-width: 420px;
|
||||||
|
max-width: 900px;
|
||||||
|
width: auto;
|
||||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,8 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -105,6 +109,8 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.bulk-operations-panel {
|
.bulk-operations-panel {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
|
min-width: unset;
|
||||||
|
max-width: unset;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
transform: none;
|
transform: none;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -483,3 +483,98 @@ input:checked + .toggle-slider:before {
|
|||||||
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 |
@@ -55,7 +55,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// Base CRUD operations
|
// Base CRUD operations
|
||||||
list: `/api/${modelType}`,
|
list: `/api/${modelType}/list`,
|
||||||
delete: `/api/${modelType}/delete`,
|
delete: `/api/${modelType}/delete`,
|
||||||
exclude: `/api/${modelType}/exclude`,
|
exclude: `/api/${modelType}/exclude`,
|
||||||
rename: `/api/${modelType}/rename`,
|
rename: `/api/${modelType}/rename`,
|
||||||
@@ -83,6 +83,8 @@ export function getApiEndpoints(modelType) {
|
|||||||
baseModels: `/api/${modelType}/base-models`,
|
baseModels: `/api/${modelType}/base-models`,
|
||||||
roots: `/api/${modelType}/roots`,
|
roots: `/api/${modelType}/roots`,
|
||||||
folders: `/api/${modelType}/folders`,
|
folders: `/api/${modelType}/folders`,
|
||||||
|
folderTree: `/api/${modelType}/folder-tree`,
|
||||||
|
unifiedFolderTree: `/api/${modelType}/unified-folder-tree`,
|
||||||
duplicates: `/api/${modelType}/find-duplicates`,
|
duplicates: `/api/${modelType}/find-duplicates`,
|
||||||
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
||||||
verify: `/api/${modelType}/verify-duplicates`,
|
verify: `/api/${modelType}/verify-duplicates`,
|
||||||
@@ -163,7 +165,8 @@ export const DOWNLOAD_ENDPOINTS = {
|
|||||||
download: '/api/download-model',
|
download: '/api/download-model',
|
||||||
downloadGet: '/api/download-model-get',
|
downloadGet: '/api/download-model-get',
|
||||||
cancelGet: '/api/cancel-download-get',
|
cancelGet: '/api/cancel-download-get',
|
||||||
progress: '/api/download-progress'
|
progress: '/api/download-progress',
|
||||||
|
exampleImages: '/api/force-download-example-images' // New endpoint for downloading example images
|
||||||
};
|
};
|
||||||
|
|
||||||
// WebSocket endpoints
|
// WebSocket endpoints
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
||||||
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||||
import {
|
import {
|
||||||
getCompleteApiConfig,
|
getCompleteApiConfig,
|
||||||
getCurrentModelType,
|
getCurrentModelType,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DOWNLOAD_ENDPOINTS,
|
DOWNLOAD_ENDPOINTS,
|
||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
import { createModelApiClient } from './modelApiFactory.js';
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for all model API clients
|
* Abstract base class for all model API clients
|
||||||
@@ -91,10 +91,7 @@ export class BaseModelApiClient {
|
|||||||
pageState.currentPage = 1; // Reset to first page
|
pageState.currentPage = 1; // Reset to first page
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize);
|
const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize);
|
||||||
const endTime = performance.now();
|
|
||||||
console.log(`fetchModelsPage耗时: ${(endTime - startTime).toFixed(2)} ms`);
|
|
||||||
|
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
result.items,
|
result.items,
|
||||||
@@ -105,8 +102,16 @@ export class BaseModelApiClient {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = pageState.currentPage + 1;
|
pageState.currentPage = pageState.currentPage + 1;
|
||||||
|
|
||||||
if (updateFolders && result.folders) {
|
if (updateFolders) {
|
||||||
updateFolderTags(result.folders);
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
updateFolderTags(data.folders);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMsg = errorData && errorData.error ? errorData.error : response.statusText;
|
||||||
|
console.error(`Error getting folders: ${errorMsg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -322,6 +327,8 @@ export class BaseModelApiClient {
|
|||||||
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) {
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
@@ -429,6 +436,8 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
await operationComplete;
|
await operationComplete;
|
||||||
|
|
||||||
|
resetAndReload(false);
|
||||||
|
showToast('Metadata update complete', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
showToast('Failed to fetch metadata: ' + error.message, 'error');
|
showToast('Failed to fetch metadata: ' + error.message, 'error');
|
||||||
@@ -579,7 +588,34 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
|
async fetchUnifiedFolderTree() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch unified folder tree`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unified folder tree:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchFolderTree(modelRoot) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ model_root: modelRoot });
|
||||||
|
const response = await fetch(`${this.apiConfig.endpoints.folderTree}?${params}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch folder tree for root: ${modelRoot}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching folder tree:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -589,6 +625,7 @@ export class BaseModelApiClient {
|
|||||||
model_version_id: versionId,
|
model_version_id: versionId,
|
||||||
model_root: modelRoot,
|
model_root: modelRoot,
|
||||||
relative_path: relativePath,
|
relative_path: relativePath,
|
||||||
|
use_default_paths: useDefaultPaths,
|
||||||
download_id: downloadId
|
download_id: downloadId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -629,6 +666,9 @@ export class BaseModelApiClient {
|
|||||||
if (pageState.searchOptions.tags !== undefined) {
|
if (pageState.searchOptions.tags !== undefined) {
|
||||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||||
}
|
}
|
||||||
|
if (pageState.searchOptions.creator !== undefined) {
|
||||||
|
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||||
|
}
|
||||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,4 +855,102 @@ export class BaseModelApiClient {
|
|||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadExampleImages(modelHashes, modelTypes = null) {
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
|
try {
|
||||||
|
// Connect to WebSocket for progress updates
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type !== 'example_images_progress') return;
|
||||||
|
|
||||||
|
switch(data.status) {
|
||||||
|
case 'running':
|
||||||
|
const percent = ((data.processed / data.total) * 100).toFixed(1);
|
||||||
|
loading.setProgress(percent);
|
||||||
|
loading.setStatus(
|
||||||
|
`Processing (${data.processed}/${data.total}) ${data.current_model || ''}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'completed':
|
||||||
|
loading.setProgress(100);
|
||||||
|
loading.setStatus(
|
||||||
|
`Completed: Downloaded example images for ${data.processed} models`
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
reject(new Error(data.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
reject(new Error('WebSocket error: ' + error.message));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for WebSocket connection to establish
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the output directory from storage
|
||||||
|
const outputDir = getStorageItem('example_images_path', '');
|
||||||
|
if (!outputDir) {
|
||||||
|
throw new Error('Please set the example images path in the settings first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine optimize setting
|
||||||
|
const optimize = state.global?.settings?.optimizeExampleImages ?? true;
|
||||||
|
|
||||||
|
// Make the API request to start the download process
|
||||||
|
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_hashes: modelHashes,
|
||||||
|
output_dir: outputDir,
|
||||||
|
optimize: optimize,
|
||||||
|
model_types: modelTypes || [this.apiConfig.config.singularName]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to download example images');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the operation to complete via WebSocket
|
||||||
|
await operationComplete;
|
||||||
|
|
||||||
|
showToast('Successfully downloaded example images!', 'success');
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading example images:', error);
|
||||||
|
showToast(`Failed to download example images: ${error.message}`, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
initialMessage: 'Starting example images download...',
|
||||||
|
completionMessage: 'Example images download complete'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,16 @@ export function createModelApiClient(modelType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _singletonClient = null;
|
let _singletonClients = new Map();
|
||||||
|
|
||||||
export function getModelApiClient() {
|
export function getModelApiClient(modelType = null) {
|
||||||
const currentType = state.currentPageType;
|
const targetType = modelType || state.currentPageType;
|
||||||
|
|
||||||
if (!_singletonClient || _singletonClient.modelType !== currentType) {
|
if (!_singletonClients.has(targetType)) {
|
||||||
_singletonClient = createModelApiClient(currentType);
|
_singletonClients.set(targetType, createModelApiClient(targetType));
|
||||||
}
|
}
|
||||||
|
|
||||||
return _singletonClient;
|
return _singletonClients.get(targetType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetAndReload(updateFolders = false) {
|
export function resetAndReload(updateFolders = false) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||||
export const ModelContextMenuMixin = {
|
export const ModelContextMenuMixin = {
|
||||||
@@ -202,6 +203,9 @@ export const ModelContextMenuMixin = {
|
|||||||
case 'preview':
|
case 'preview':
|
||||||
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
||||||
return true;
|
return true;
|
||||||
|
case 'download-examples':
|
||||||
|
this.downloadExampleImages();
|
||||||
|
return true;
|
||||||
case 'civitai':
|
case 'civitai':
|
||||||
if (this.currentCard.dataset.from_civitai === 'true') {
|
if (this.currentCard.dataset.from_civitai === 'true') {
|
||||||
if (this.currentCard.querySelector('.fa-globe')) {
|
if (this.currentCard.querySelector('.fa-globe')) {
|
||||||
@@ -222,5 +226,21 @@ export const ModelContextMenuMixin = {
|
|||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Download example images method
|
||||||
|
async downloadExampleImages() {
|
||||||
|
const modelHash = this.currentCard.dataset.sha256;
|
||||||
|
if (!modelHash) {
|
||||||
|
showToast('Model hash not available', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
await apiClient.downloadExampleImages([modelHash]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading example images:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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/modelApiFactory.js';
|
import { resetAndReload} from '../api/modelApiFactory.js';
|
||||||
import { LoadingManager } from '../managers/LoadingManager.js';
|
import { getShowDuplicatesNotification, setShowDuplicatesNotification } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class ModelDuplicatesManager {
|
export class ModelDuplicatesManager {
|
||||||
constructor(pageManager, modelType = 'loras') {
|
constructor(pageManager, modelType = 'loras') {
|
||||||
@@ -17,8 +17,16 @@ export class ModelDuplicatesManager {
|
|||||||
this.verifiedGroups = new Set(); // Track which groups have been verified
|
this.verifiedGroups = new Set(); // Track which groups have been verified
|
||||||
this.mismatchedFiles = new Map(); // Map file paths to actual hashes for mismatched files
|
this.mismatchedFiles = new Map(); // Map file paths to actual hashes for mismatched files
|
||||||
|
|
||||||
// Loading manager for verification process
|
// Badge visibility preference
|
||||||
this.loadingManager = new LoadingManager();
|
this.showBadge = getShowDuplicatesNotification(); // Default to true (show badge)
|
||||||
|
|
||||||
|
// Event handler references for cleanup
|
||||||
|
this.badgeToggleHandler = null;
|
||||||
|
this.helpTooltipHandlers = {
|
||||||
|
mouseenter: null,
|
||||||
|
mouseleave: null,
|
||||||
|
click: null
|
||||||
|
};
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.renderModelCard = this.renderModelCard.bind(this);
|
this.renderModelCard = this.renderModelCard.bind(this);
|
||||||
@@ -66,7 +74,16 @@ export class ModelDuplicatesManager {
|
|||||||
const badge = document.getElementById('duplicatesBadge');
|
const badge = document.getElementById('duplicatesBadge');
|
||||||
if (!badge) return;
|
if (!badge) return;
|
||||||
|
|
||||||
|
// Check if badge should be hidden based on user preference
|
||||||
|
if (!this.showBadge && !this.inDuplicateMode) {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
badge.textContent = '';
|
||||||
|
badge.classList.remove('pulse');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
|
badge.style.display = 'inline-flex';
|
||||||
badge.textContent = count;
|
badge.textContent = count;
|
||||||
badge.classList.add('pulse');
|
badge.classList.add('pulse');
|
||||||
} else {
|
} else {
|
||||||
@@ -136,6 +153,9 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
// Setup help tooltip behavior
|
// Setup help tooltip behavior
|
||||||
this.setupHelpTooltip();
|
this.setupHelpTooltip();
|
||||||
|
|
||||||
|
// Setup badge toggle control
|
||||||
|
this.setupBadgeToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable virtual scrolling if active
|
// Disable virtual scrolling if active
|
||||||
@@ -173,6 +193,9 @@ export class ModelDuplicatesManager {
|
|||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.duplicatesMode = false;
|
pageState.duplicatesMode = false;
|
||||||
|
|
||||||
|
// Clean up event handlers before hiding banner
|
||||||
|
this.cleanupEventHandlers();
|
||||||
|
|
||||||
// Hide duplicates banner
|
// Hide duplicates banner
|
||||||
const banner = document.getElementById('duplicatesBanner');
|
const banner = document.getElementById('duplicatesBanner');
|
||||||
if (banner) {
|
if (banner) {
|
||||||
@@ -672,7 +695,11 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
if (!helpIcon || !helpTooltip) return;
|
if (!helpIcon || !helpTooltip) return;
|
||||||
|
|
||||||
helpIcon.addEventListener('mouseenter', (e) => {
|
// Clean up existing handlers first
|
||||||
|
this.cleanupHelpTooltipHandlers();
|
||||||
|
|
||||||
|
// Create new handler functions and store references
|
||||||
|
this.helpTooltipHandlers.mouseenter = (e) => {
|
||||||
// Get the container's positioning context
|
// Get the container's positioning context
|
||||||
const bannerContent = helpIcon.closest('.banner-content');
|
const bannerContent = helpIcon.closest('.banner-content');
|
||||||
|
|
||||||
@@ -693,18 +720,22 @@ export class ModelDuplicatesManager {
|
|||||||
// Reposition relative to container if too close to right edge
|
// Reposition relative to container if too close to right edge
|
||||||
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
|
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Rest of the event listeners remain unchanged
|
this.helpTooltipHandlers.mouseleave = () => {
|
||||||
helpIcon.addEventListener('mouseleave', () => {
|
|
||||||
helpTooltip.style.display = 'none';
|
helpTooltip.style.display = 'none';
|
||||||
});
|
};
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
this.helpTooltipHandlers.click = (e) => {
|
||||||
if (!helpIcon.contains(e.target)) {
|
if (!helpIcon.contains(e.target)) {
|
||||||
helpTooltip.style.display = 'none';
|
helpTooltip.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
helpIcon.addEventListener('mouseenter', this.helpTooltipHandlers.mouseenter);
|
||||||
|
helpIcon.addEventListener('mouseleave', this.helpTooltipHandlers.mouseleave);
|
||||||
|
document.addEventListener('click', this.helpTooltipHandlers.click);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle verify hashes button click
|
// Handle verify hashes button click
|
||||||
@@ -719,7 +750,7 @@ export class ModelDuplicatesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
this.loadingManager.showSimpleLoading('Verifying hashes...');
|
state.loadingManager.showSimpleLoading('Verifying hashes...');
|
||||||
|
|
||||||
// Get file paths for all models in the group
|
// Get file paths for all models in the group
|
||||||
const filePaths = group.models.map(model => model.file_path);
|
const filePaths = group.models.map(model => model.file_path);
|
||||||
@@ -772,7 +803,87 @@ export class ModelDuplicatesManager {
|
|||||||
showToast('Failed to verify hashes: ' + error.message, 'error');
|
showToast('Failed to verify hashes: ' + error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
// Hide loading state
|
// Hide loading state
|
||||||
this.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this new method for badge toggle setup
|
||||||
|
setupBadgeToggle() {
|
||||||
|
const toggleControl = document.getElementById('badgeToggleControl');
|
||||||
|
const toggleInput = document.getElementById('badgeToggleInput');
|
||||||
|
|
||||||
|
if (!toggleControl || !toggleInput) return;
|
||||||
|
|
||||||
|
// Clean up existing handler first
|
||||||
|
this.cleanupBadgeToggleHandler();
|
||||||
|
|
||||||
|
// Set initial state based on stored preference (default to true/checked)
|
||||||
|
toggleInput.checked = this.showBadge;
|
||||||
|
|
||||||
|
// Create and store the handler function
|
||||||
|
this.badgeToggleHandler = (e) => {
|
||||||
|
this.showBadge = e.target.checked;
|
||||||
|
setShowDuplicatesNotification(this.showBadge);
|
||||||
|
|
||||||
|
// Update badge visibility immediately if not in duplicate mode
|
||||||
|
if (!this.inDuplicateMode) {
|
||||||
|
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
this.showBadge ? 'Duplicates notification will be shown' : 'Duplicates notification will be hidden',
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add change event listener
|
||||||
|
toggleInput.addEventListener('change', this.badgeToggleHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up all event handlers
|
||||||
|
cleanupEventHandlers() {
|
||||||
|
this.cleanupBadgeToggleHandler();
|
||||||
|
this.cleanupHelpTooltipHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up badge toggle event handler
|
||||||
|
cleanupBadgeToggleHandler() {
|
||||||
|
if (this.badgeToggleHandler) {
|
||||||
|
const toggleInput = document.getElementById('badgeToggleInput');
|
||||||
|
if (toggleInput) {
|
||||||
|
toggleInput.removeEventListener('change', this.badgeToggleHandler);
|
||||||
|
}
|
||||||
|
this.badgeToggleHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up help tooltip event handlers
|
||||||
|
cleanupHelpTooltipHandlers() {
|
||||||
|
const helpIcon = document.getElementById('duplicatesHelp');
|
||||||
|
|
||||||
|
if (helpIcon && this.helpTooltipHandlers.mouseenter) {
|
||||||
|
helpIcon.removeEventListener('mouseenter', this.helpTooltipHandlers.mouseenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpIcon && this.helpTooltipHandlers.mouseleave) {
|
||||||
|
helpIcon.removeEventListener('mouseleave', this.helpTooltipHandlers.mouseleave);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.helpTooltipHandlers.click) {
|
||||||
|
document.removeEventListener('click', this.helpTooltipHandlers.click);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset handler references
|
||||||
|
this.helpTooltipHandlers = {
|
||||||
|
mouseenter: null,
|
||||||
|
mouseleave: null,
|
||||||
|
click: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hide tooltip if it's visible
|
||||||
|
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
|
||||||
|
if (helpTooltip) {
|
||||||
|
helpTooltip.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ function showModelModalFromCard(card, modelType) {
|
|||||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||||
modelDescription: card.dataset.modelDescription || '',
|
modelDescription: card.dataset.modelDescription || '',
|
||||||
// LoRA specific fields
|
// LoRA specific fields
|
||||||
...(modelType === 'lora' && {
|
...(modelType === MODEL_TYPES.LORA && {
|
||||||
usage_tips: card.dataset.usage_tips,
|
usage_tips: card.dataset.usage_tips,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -273,18 +273,27 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
if (hasRemoteExamples) {
|
if (hasRemoteExamples) {
|
||||||
downloadBtn.classList.remove('disabled');
|
downloadBtn.classList.remove('disabled');
|
||||||
downloadBtn.removeAttribute('title');
|
downloadBtn.removeAttribute('title');
|
||||||
downloadBtn.onclick = () => {
|
downloadBtn.onclick = async () => {
|
||||||
|
// Get the model hash
|
||||||
|
const modelHash = card.dataset.sha256;
|
||||||
|
if (!modelHash) {
|
||||||
|
showToast('Missing model hash information.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
modalManager.closeModal('exampleAccessModal');
|
modalManager.closeModal('exampleAccessModal');
|
||||||
// Open settings modal and scroll to example images section
|
|
||||||
const settingsModal = document.getElementById('settingsModal');
|
try {
|
||||||
if (settingsModal) {
|
// Use the appropriate model API client to download examples
|
||||||
modalManager.showModal('settingsModal');
|
const apiClient = getModelApiClient(modelType);
|
||||||
setTimeout(() => {
|
await apiClient.downloadExampleImages([modelHash]);
|
||||||
const exampleSection = settingsModal.querySelector('.settings-section:nth-child(7)');
|
|
||||||
if (exampleSection) {
|
// Open the example images folder if successful
|
||||||
exampleSection.scrollIntoView({ behavior: 'smooth' });
|
openExampleImagesFolder(modelHash);
|
||||||
}
|
} catch (error) {
|
||||||
}, 300);
|
console.error('Error downloading example images:', error);
|
||||||
|
// Error already shown by the API client
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ 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');
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -15,8 +17,10 @@ export class DownloadManager {
|
|||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
|
this.useDefaultPath = false;
|
||||||
|
|
||||||
this.loadingManager = new LoadingManager();
|
this.loadingManager = new LoadingManager();
|
||||||
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.folderClickHandler = null;
|
this.folderClickHandler = null;
|
||||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||||
|
|
||||||
@@ -27,6 +31,7 @@ export class DownloadManager {
|
|||||||
this.handleBackToUrl = this.backToUrl.bind(this);
|
this.handleBackToUrl = this.backToUrl.bind(this);
|
||||||
this.handleBackToVersions = this.backToVersions.bind(this);
|
this.handleBackToVersions = this.backToVersions.bind(this);
|
||||||
this.handleCloseModal = this.closeModal.bind(this);
|
this.handleCloseModal = this.closeModal.bind(this);
|
||||||
|
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
showDownloadModal() {
|
showDownloadModal() {
|
||||||
@@ -71,6 +76,9 @@ export class DownloadManager {
|
|||||||
document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl);
|
document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl);
|
||||||
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
|
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
|
||||||
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
|
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
|
||||||
|
|
||||||
|
// Default path toggle handler
|
||||||
|
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModalLabels() {
|
updateModalLabels() {
|
||||||
@@ -106,9 +114,10 @@ export class DownloadManager {
|
|||||||
document.getElementById('modelUrl').value = '';
|
document.getElementById('modelUrl').value = '';
|
||||||
document.getElementById('urlError').textContent = '';
|
document.getElementById('urlError').textContent = '';
|
||||||
|
|
||||||
const newFolderInput = document.getElementById('newFolder');
|
// Clear folder path input
|
||||||
if (newFolderInput) {
|
const folderPathInput = document.getElementById('folderPath');
|
||||||
newFolderInput.value = '';
|
if (folderPathInput) {
|
||||||
|
folderPathInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentVersion = null;
|
this.currentVersion = null;
|
||||||
@@ -118,11 +127,14 @@ export class DownloadManager {
|
|||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
const folderBrowser = document.getElementById('folderBrowser');
|
|
||||||
if (folderBrowser) {
|
// Clear folder tree selection
|
||||||
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
|
if (this.folderTreeManager) {
|
||||||
f.classList.remove('selected'));
|
this.folderTreeManager.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset default path toggle
|
||||||
|
this.loadDefaultPathSetting();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateAndFetchVersions() {
|
async validateAndFetchVersions() {
|
||||||
@@ -285,8 +297,6 @@ export class DownloadManager {
|
|||||||
document.getElementById('locationStep').style.display = 'block';
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = this.apiClient.apiConfig.config;
|
|
||||||
|
|
||||||
// Fetch model roots
|
// Fetch model roots
|
||||||
const rootsData = await this.apiClient.fetchModelRoots();
|
const rootsData = await this.apiClient.fetchModelRoots();
|
||||||
const modelRoot = document.getElementById('modelRoot');
|
const modelRoot = document.getElementById('modelRoot');
|
||||||
@@ -295,26 +305,96 @@ export class DownloadManager {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default root if available
|
// Set default root if available
|
||||||
const defaultRootKey = `default_${this.apiClient.modelType}_root`;
|
const singularType = this.apiClient.modelType.replace(/s$/, '');
|
||||||
|
const defaultRootKey = `default_${singularType}_root`;
|
||||||
const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
|
const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
|
||||||
|
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
|
||||||
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders
|
// Set autocomplete="off" on folderPath input
|
||||||
const foldersData = await this.apiClient.fetchModelFolders();
|
const folderPathInput = document.getElementById('folderPath');
|
||||||
const folderBrowser = document.getElementById('folderBrowser');
|
if (folderPathInput) {
|
||||||
|
folderPathInput.setAttribute('autocomplete', 'off');
|
||||||
|
}
|
||||||
|
|
||||||
folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
// Initialize folder tree
|
||||||
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
|
await this.initializeFolderTree();
|
||||||
).join('');
|
|
||||||
|
|
||||||
this.initializeFolderBrowser();
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -763,22 +763,7 @@ class ExampleImagesManager {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (!data.success) {
|
||||||
// Only show progress if there are actually items to download
|
|
||||||
if (data.status && data.status.total > 0) {
|
|
||||||
this.isDownloading = true;
|
|
||||||
this.isPaused = false;
|
|
||||||
this.hasShownCompletionToast = false;
|
|
||||||
this.startTime = new Date();
|
|
||||||
this.updateUI(data.status);
|
|
||||||
this.showProgressPanel();
|
|
||||||
this.startProgressUpdates();
|
|
||||||
this.updateDownloadButtonText();
|
|
||||||
console.log(`Auto download started: ${data.status.total} items to process`);
|
|
||||||
} else {
|
|
||||||
console.log('Auto download check completed - no new items to download');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('Auto download check failed:', data.error);
|
console.warn('Auto download check failed:', data.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,48 +4,31 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { bulkManager } from './BulkManager.js';
|
import { bulkManager } from './BulkManager.js';
|
||||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
|
|
||||||
class MoveManager {
|
class MoveManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
this.modal = document.getElementById('moveModal');
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.modelRootSelect = document.getElementById('moveModelRoot');
|
this.initialized = false;
|
||||||
this.folderBrowser = document.getElementById('moveFolderBrowser');
|
|
||||||
this.newFolderInput = document.getElementById('moveNewFolder');
|
|
||||||
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
|
|
||||||
this.modalTitle = document.getElementById('moveModalTitle');
|
|
||||||
this.rootLabel = document.getElementById('moveRootLabel');
|
|
||||||
|
|
||||||
this.initializeEventListeners();
|
// Bind methods
|
||||||
|
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeEventListeners() {
|
initializeEventListeners() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
const modelRootSelect = document.getElementById('moveModelRoot');
|
||||||
|
|
||||||
// Initialize model root directory selector
|
// Initialize model root directory selector
|
||||||
this.modelRootSelect.addEventListener('change', () => this.updatePathPreview());
|
modelRootSelect.addEventListener('change', async () => {
|
||||||
|
await this.initializeFolderTree();
|
||||||
// Folder selection event
|
this.updateTargetPath();
|
||||||
this.folderBrowser.addEventListener('click', (e) => {
|
|
||||||
const folderItem = e.target.closest('.folder-item');
|
|
||||||
if (!folderItem) return;
|
|
||||||
|
|
||||||
// If clicking already selected folder, deselect it
|
|
||||||
if (folderItem.classList.contains('selected')) {
|
|
||||||
folderItem.classList.remove('selected');
|
|
||||||
} else {
|
|
||||||
// Deselect other folders
|
|
||||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
|
||||||
item.classList.remove('selected');
|
|
||||||
});
|
|
||||||
// Select current folder
|
|
||||||
folderItem.classList.add('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePathPreview();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// New folder input event
|
this.initialized = true;
|
||||||
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async showMoveModal(filePath, modelType = null) {
|
async showMoveModal(filePath, modelType = null) {
|
||||||
@@ -65,31 +48,30 @@ class MoveManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.bulkFilePaths = selectedPaths;
|
this.bulkFilePaths = selectedPaths;
|
||||||
this.modalTitle.textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
|
document.getElementById('moveModalTitle').textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
|
||||||
} else {
|
} else {
|
||||||
// Single file mode
|
// Single file mode
|
||||||
this.currentFilePath = filePath;
|
this.currentFilePath = filePath;
|
||||||
this.modalTitle.textContent = `Move ${modelConfig.displayName}`;
|
document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI labels based on model type
|
// Update UI labels based on model type
|
||||||
this.rootLabel.textContent = `Select ${modelConfig.displayName} Root:`;
|
document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`;
|
||||||
this.pathDisplay.querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`;
|
document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`;
|
||||||
|
|
||||||
// Clear previous selections
|
// Clear folder path input
|
||||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
const folderPathInput = document.getElementById('moveFolderPath');
|
||||||
item.classList.remove('selected');
|
if (folderPathInput) {
|
||||||
});
|
folderPathInput.value = '';
|
||||||
this.newFolderInput.value = '';
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch model roots
|
// Fetch model roots
|
||||||
|
const modelRootSelect = document.getElementById('moveModelRoot');
|
||||||
let rootsData;
|
let rootsData;
|
||||||
if (modelType) {
|
if (modelType) {
|
||||||
// For checkpoints, use the specific API method that considers modelType
|
|
||||||
rootsData = await apiClient.fetchModelRoots(modelType);
|
rootsData = await apiClient.fetchModelRoots(modelType);
|
||||||
} else {
|
} else {
|
||||||
// For other model types, use the generic method
|
|
||||||
rootsData = await apiClient.fetchModelRoots();
|
rootsData = await apiClient.fetchModelRoots();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,27 +80,38 @@ class MoveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Populate model root selector
|
// Populate model root selector
|
||||||
this.modelRootSelect.innerHTML = rootsData.roots.map(root =>
|
modelRootSelect.innerHTML = rootsData.roots.map(root =>
|
||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default root if available
|
// Set default root if available
|
||||||
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; // Remove 's' from plural
|
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
|
||||||
const defaultRoot = getStorageItem('settings', {})[settingsKey];
|
const defaultRoot = getStorageItem('settings', {})[settingsKey];
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
this.modelRootSelect.value = defaultRoot;
|
modelRootSelect.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders dynamically
|
// Initialize event listeners
|
||||||
const foldersData = await apiClient.fetchModelFolders();
|
this.initializeEventListeners();
|
||||||
|
|
||||||
// Update folder browser with dynamic content
|
// Setup folder tree manager
|
||||||
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
this.folderTreeManager.init({
|
||||||
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
|
onPathChange: (path) => {
|
||||||
).join('');
|
this.updateTargetPath();
|
||||||
|
},
|
||||||
|
elementsPrefix: 'move'
|
||||||
|
});
|
||||||
|
|
||||||
this.updatePathPreview();
|
// Initialize folder tree
|
||||||
modalManager.showModal('moveModal');
|
await this.initializeFolderTree();
|
||||||
|
|
||||||
|
this.updateTargetPath();
|
||||||
|
modalManager.showModal('moveModal', null, () => {
|
||||||
|
// Cleanup on modal close
|
||||||
|
if (this.folderTreeManager) {
|
||||||
|
this.folderTreeManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
|
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
|
||||||
@@ -126,36 +119,60 @@ class MoveManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePathPreview() {
|
async initializeFolderTree() {
|
||||||
const selectedRoot = this.modelRootSelect.value;
|
try {
|
||||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
const apiClient = getModelApiClient();
|
||||||
const newFolder = this.newFolderInput.value.trim();
|
// Fetch unified folder tree
|
||||||
|
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||||
|
|
||||||
let targetPath = selectedRoot;
|
if (treeData.success) {
|
||||||
if (selectedFolder) {
|
// Load tree data into folder tree manager
|
||||||
targetPath = `${targetPath}/${selectedFolder}`;
|
await this.folderTreeManager.loadTree(treeData.tree);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch folder tree:', treeData.error);
|
||||||
|
showToast('Failed to load folder tree', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing folder tree:', error);
|
||||||
|
showToast('Error loading folder tree', 'error');
|
||||||
}
|
}
|
||||||
if (newFolder) {
|
}
|
||||||
targetPath = `${targetPath}/${newFolder}`;
|
|
||||||
|
updateTargetPath() {
|
||||||
|
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||||
|
const modelRoot = document.getElementById('moveModelRoot').value;
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
|
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
||||||
|
|
||||||
|
if (modelRoot) {
|
||||||
|
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
|
||||||
|
if (selectedPath) {
|
||||||
|
fullPath += '/' + selectedPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pathDisplay.querySelector('.path-text').textContent = targetPath;
|
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveModel() {
|
async moveModel() {
|
||||||
const selectedRoot = this.modelRootSelect.value;
|
const selectedRoot = document.getElementById('moveModelRoot').value;
|
||||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
const apiClient = getModelApiClient();
|
||||||
const newFolder = this.newFolderInput.value.trim();
|
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;
|
let targetPath = selectedRoot;
|
||||||
if (selectedFolder) {
|
if (targetFolder) {
|
||||||
targetPath = `${targetPath}/${selectedFolder}`;
|
targetPath = `${targetPath}/${targetFolder}`;
|
||||||
}
|
}
|
||||||
if (newFolder) {
|
|
||||||
targetPath = `${targetPath}/${newFolder}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiClient = getModelApiClient();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.bulkFilePaths) {
|
if (this.bulkFilePaths) {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
import { resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js';
|
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -73,11 +73,30 @@ export class SettingsManager {
|
|||||||
// We can delete the old setting, but keeping it for backwards compatibility
|
// We can delete the old setting, but keeping it for backwards compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default for download path template if undefined
|
// Migrate legacy download_path_template to new structure
|
||||||
if (state.global.settings.download_path_template === undefined) {
|
if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) {
|
||||||
state.global.settings.download_path_template = DOWNLOAD_PATH_TEMPLATES.BASE_MODEL_TAG.value;
|
const legacyTemplate = state.global.settings.download_path_template;
|
||||||
|
state.global.settings.download_path_templates = {
|
||||||
|
lora: legacyTemplate,
|
||||||
|
checkpoint: legacyTemplate,
|
||||||
|
embedding: legacyTemplate
|
||||||
|
};
|
||||||
|
delete state.global.settings.download_path_template;
|
||||||
|
setStorageItem('settings', state.global.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set default for download path templates if undefined
|
||||||
|
if (state.global.settings.download_path_templates === undefined) {
|
||||||
|
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all model types have templates
|
||||||
|
Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => {
|
||||||
|
if (typeof state.global.settings.download_path_templates[modelType] === 'undefined') {
|
||||||
|
state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set default for base model path mappings if undefined
|
// Set default for base model path mappings if undefined
|
||||||
if (state.global.settings.base_model_path_mappings === undefined) {
|
if (state.global.settings.base_model_path_mappings === undefined) {
|
||||||
state.global.settings.base_model_path_mappings = {};
|
state.global.settings.base_model_path_mappings = {};
|
||||||
@@ -105,7 +124,7 @@ export class SettingsManager {
|
|||||||
'default_checkpoint_root',
|
'default_checkpoint_root',
|
||||||
'default_embedding_root',
|
'default_embedding_root',
|
||||||
'base_model_path_mappings',
|
'base_model_path_mappings',
|
||||||
'download_path_template'
|
'download_path_templates'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build payload for syncing
|
// Build payload for syncing
|
||||||
@@ -113,7 +132,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
fieldsToSync.forEach(key => {
|
fieldsToSync.forEach(key => {
|
||||||
if (localSettings[key] !== undefined) {
|
if (localSettings[key] !== undefined) {
|
||||||
if (key === 'base_model_path_mappings') {
|
if (key === 'base_model_path_mappings' || key === 'download_path_templates') {
|
||||||
payload[key] = JSON.stringify(localSettings[key]);
|
payload[key] = JSON.stringify(localSettings[key]);
|
||||||
} else {
|
} else {
|
||||||
payload[key] = localSettings[key];
|
payload[key] = localSettings[key];
|
||||||
@@ -165,6 +184,30 @@ export class SettingsManager {
|
|||||||
button.addEventListener('click', () => this.toggleInputVisibility(button));
|
button.addEventListener('click', () => this.toggleInputVisibility(button));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||||
|
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
||||||
|
if (customInput) {
|
||||||
|
customInput.addEventListener('input', (e) => {
|
||||||
|
const template = e.target.value;
|
||||||
|
settingsManager.validateTemplate(modelType, template);
|
||||||
|
settingsManager.updateTemplatePreview(modelType, template);
|
||||||
|
});
|
||||||
|
|
||||||
|
customInput.addEventListener('blur', (e) => {
|
||||||
|
const template = e.target.value;
|
||||||
|
if (settingsManager.validateTemplate(modelType, template)) {
|
||||||
|
settingsManager.updateTemplate(modelType, template);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
customInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,12 +254,8 @@ export class SettingsManager {
|
|||||||
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
|
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set download path template setting
|
// Load download path templates
|
||||||
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
|
this.loadDownloadPathTemplates();
|
||||||
if (downloadPathTemplateSelect) {
|
|
||||||
downloadPathTemplateSelect.value = state.global.settings.download_path_template || '';
|
|
||||||
this.updatePathTemplatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set include trigger words setting
|
// Set include trigger words setting
|
||||||
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
|
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
|
||||||
@@ -529,19 +568,184 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePathTemplatePreview() {
|
loadDownloadPathTemplates() {
|
||||||
const templateSelect = document.getElementById('downloadPathTemplate');
|
const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES;
|
||||||
const previewElement = document.getElementById('pathTemplatePreview');
|
|
||||||
if (!templateSelect || !previewElement) return;
|
|
||||||
|
|
||||||
const template = templateSelect.value;
|
Object.keys(templates).forEach(modelType => {
|
||||||
const templateInfo = Object.values(DOWNLOAD_PATH_TEMPLATES).find(t => t.value === template);
|
this.loadTemplateForModelType(modelType, templates[modelType]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (templateInfo) {
|
loadTemplateForModelType(modelType, template) {
|
||||||
previewElement.textContent = templateInfo.example;
|
const presetSelect = document.getElementById(`${modelType}TemplatePreset`);
|
||||||
previewElement.style.display = 'block';
|
const customRow = document.getElementById(`${modelType}CustomRow`);
|
||||||
|
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
||||||
|
|
||||||
|
if (!presetSelect) return;
|
||||||
|
|
||||||
|
// Find matching preset
|
||||||
|
const matchingPreset = this.findMatchingPreset(template);
|
||||||
|
|
||||||
|
if (matchingPreset !== null) {
|
||||||
|
presetSelect.value = matchingPreset;
|
||||||
|
if (customRow) customRow.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
previewElement.style.display = 'none';
|
// Custom template
|
||||||
|
presetSelect.value = 'custom';
|
||||||
|
if (customRow) customRow.style.display = 'block';
|
||||||
|
if (customInput) {
|
||||||
|
customInput.value = template;
|
||||||
|
this.validateTemplate(modelType, template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTemplatePreview(modelType, template);
|
||||||
|
}
|
||||||
|
|
||||||
|
findMatchingPreset(template) {
|
||||||
|
const presetValues = Object.values(DOWNLOAD_PATH_TEMPLATES)
|
||||||
|
.map(t => t.value)
|
||||||
|
.filter(v => v !== 'custom');
|
||||||
|
|
||||||
|
return presetValues.includes(template) ? template : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplatePreset(modelType, value) {
|
||||||
|
const customRow = document.getElementById(`${modelType}CustomRow`);
|
||||||
|
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
||||||
|
|
||||||
|
if (value === 'custom') {
|
||||||
|
if (customRow) customRow.style.display = 'block';
|
||||||
|
if (customInput) customInput.focus();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (customRow) customRow.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update template
|
||||||
|
this.updateTemplate(modelType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplate(modelType, template) {
|
||||||
|
// Validate template if it's custom
|
||||||
|
if (document.getElementById(`${modelType}TemplatePreset`).value === 'custom') {
|
||||||
|
if (!this.validateTemplate(modelType, template)) {
|
||||||
|
return; // Don't save invalid templates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
if (!state.global.settings.download_path_templates) {
|
||||||
|
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
|
||||||
|
}
|
||||||
|
state.global.settings.download_path_templates[modelType] = template;
|
||||||
|
|
||||||
|
// Update preview
|
||||||
|
this.updateTemplatePreview(modelType, template);
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
this.saveDownloadPathTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
validateTemplate(modelType, template) {
|
||||||
|
const validationElement = document.getElementById(`${modelType}Validation`);
|
||||||
|
if (!validationElement) return true;
|
||||||
|
|
||||||
|
// Reset validation state
|
||||||
|
validationElement.innerHTML = '';
|
||||||
|
validationElement.className = 'template-validation';
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid (flat structure)';
|
||||||
|
validationElement.classList.add('valid');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters
|
||||||
|
const invalidChars = /[<>:"|?*]/;
|
||||||
|
if (invalidChars.test(template)) {
|
||||||
|
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
|
||||||
|
validationElement.classList.add('invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for double slashes
|
||||||
|
if (template.includes('//')) {
|
||||||
|
validationElement.innerHTML = '<i class="fas fa-times"></i> Double slashes not allowed';
|
||||||
|
validationElement.classList.add('invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it starts or ends with slash
|
||||||
|
if (template.startsWith('/') || template.endsWith('/')) {
|
||||||
|
validationElement.innerHTML = '<i class="fas fa-times"></i> Cannot start or end with slash';
|
||||||
|
validationElement.classList.add('invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract placeholders
|
||||||
|
const placeholderRegex = /\{([^}]+)\}/g;
|
||||||
|
const matches = template.match(placeholderRegex) || [];
|
||||||
|
|
||||||
|
// Check for invalid placeholders
|
||||||
|
const invalidPlaceholders = matches.filter(match =>
|
||||||
|
!PATH_TEMPLATE_PLACEHOLDERS.includes(match)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidPlaceholders.length > 0) {
|
||||||
|
validationElement.innerHTML = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
|
||||||
|
validationElement.classList.add('invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template is valid
|
||||||
|
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid template';
|
||||||
|
validationElement.classList.add('valid');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplatePreview(modelType, template) {
|
||||||
|
const previewElement = document.getElementById(`${modelType}Preview`);
|
||||||
|
if (!previewElement) return;
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
previewElement.textContent = 'model-name.safetensors';
|
||||||
|
} else {
|
||||||
|
// Generate example preview
|
||||||
|
const exampleTemplate = template
|
||||||
|
.replace('{base_model}', 'Flux.1 D')
|
||||||
|
.replace('{author}', 'authorname')
|
||||||
|
.replace('{first_tag}', 'style');
|
||||||
|
previewElement.textContent = `${exampleTemplate}/model-name.safetensors`;
|
||||||
|
}
|
||||||
|
previewElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDownloadPathTemplates() {
|
||||||
|
try {
|
||||||
|
// Save to localStorage
|
||||||
|
setStorageItem('settings', state.global.settings);
|
||||||
|
|
||||||
|
// Save to backend
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
download_path_templates: JSON.stringify(state.global.settings.download_path_templates)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save download path templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Download path templates updated', 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving download path templates:', error);
|
||||||
|
showToast('Failed to save download path templates: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,9 +855,6 @@ export class SettingsManager {
|
|||||||
state.global.settings.compactMode = (value !== 'default');
|
state.global.settings.compactMode = (value !== 'default');
|
||||||
} else if (settingKey === 'card_info_display') {
|
} else if (settingKey === 'card_info_display') {
|
||||||
state.global.settings.cardInfoDisplay = value;
|
state.global.settings.cardInfoDisplay = value;
|
||||||
} else if (settingKey === 'download_path_template') {
|
|
||||||
state.global.settings.download_path_template = value;
|
|
||||||
this.updatePathTemplatePreview();
|
|
||||||
} else {
|
} else {
|
||||||
// For any other settings that might be added in the future
|
// For any other settings that might be added in the future
|
||||||
state.global.settings[settingKey] = value;
|
state.global.settings[settingKey] = value;
|
||||||
@@ -664,9 +865,13 @@ export class SettingsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// For backend settings, make API call
|
// For backend settings, make API call
|
||||||
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') {
|
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
payload[settingKey] = value;
|
if (settingKey === 'download_path_templates') {
|
||||||
|
payload[settingKey] = JSON.stringify(state.global.settings.download_path_templates);
|
||||||
|
} else {
|
||||||
|
payload[settingKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/settings', {
|
const response = await fetch('/api/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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,29 +202,16 @@ 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
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const state = {
|
|||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -83,6 +84,7 @@ export const state = {
|
|||||||
searchOptions: {
|
searchOptions: {
|
||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -110,6 +112,7 @@ export const state = {
|
|||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -214,3 +214,60 @@ export function getMapFromStorage(key) {
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the show duplicates notification preference
|
||||||
|
* @returns {boolean} True if notification should be shown (default: true)
|
||||||
|
*/
|
||||||
|
export function getShowDuplicatesNotification() {
|
||||||
|
return getStorageItem('show_duplicates_notification', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the show duplicates notification preference
|
||||||
|
* @param {boolean} show - Whether to show the notification
|
||||||
|
*/
|
||||||
|
export function setShowDuplicatesNotification(show) {
|
||||||
|
setStorageItem('show_duplicates_notification', show);
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
|
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
|
||||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
||||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
||||||
|
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> Download Example Images</div>
|
||||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||||
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
|
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
@@ -29,27 +30,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/duplicates_banner.html' %}
|
||||||
<!-- Duplicates banner (hidden by default) -->
|
|
||||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
|
||||||
<div class="banner-content">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
<span id="duplicatesCount">Found 0 duplicate groups</span>
|
|
||||||
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
|
|
||||||
<div class="banner-actions">
|
|
||||||
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
|
|
||||||
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
|
||||||
</button>
|
|
||||||
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
|
|
||||||
<i class="fas fa-times"></i> Exit Mode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help-tooltip" id="duplicatesHelpTooltip">
|
|
||||||
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
|
|
||||||
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checkpoint cards container -->
|
<!-- Checkpoint cards container -->
|
||||||
<div class="card-grid" id="modelGrid">
|
<div class="card-grid" id="modelGrid">
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
<div class="context-menu-item" data-action="preview">
|
<div class="context-menu-item" data-action="preview">
|
||||||
<i class="fas fa-folder-open"></i> Open Examples Folder
|
<i class="fas fa-folder-open"></i> Open Examples Folder
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="download-examples">
|
||||||
|
<i class="fas fa-download"></i> Download Example Images
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="replace-preview">
|
<div class="context-menu-item" data-action="replace-preview">
|
||||||
<i class="fas fa-image"></i> Replace Preview
|
<i class="fas fa-image"></i> Replace Preview
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
templates/components/duplicates_banner.html
Normal file
27
templates/components/duplicates_banner.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- Duplicates banner (hidden by default) -->
|
||||||
|
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||||
|
<div class="banner-content">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span id="duplicatesCount">Found 0 duplicate groups</span>
|
||||||
|
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<div class="setting-contro" id="badgeToggleControl">
|
||||||
|
<span>Show Duplicates Notification:</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="badgeToggleInput">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
|
||||||
|
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
||||||
|
</button>
|
||||||
|
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
|
||||||
|
<i class="fas fa-times"></i> Exit Mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-tooltip" id="duplicatesHelpTooltip">
|
||||||
|
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
|
||||||
|
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
|
||||||
|
</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,6 +6,7 @@
|
|||||||
<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">
|
||||||
@@ -14,18 +15,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label id="moveRootLabel">Select Model Root:</label>
|
<label for="moveModelRoot" id="moveRootLabel">Select Model Root:</label>
|
||||||
<select id="moveModelRoot"></select>
|
<select id="moveModelRoot"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Path input with autocomplete -->
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Target Folder:</label>
|
<label for="moveFolderPath">Target Folder Path:</label>
|
||||||
<div class="folder-browser" id="moveFolderBrowser">
|
<div class="path-input-container">
|
||||||
<!-- Folders will be loaded dynamically -->
|
<input type="text" id="moveFolderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
|
||||||
|
<button type="button" id="moveCreateFolderBtn" class="create-folder-btn" title="Create new folder">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="path-suggestions" id="movePathSuggestions" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumb navigation -->
|
||||||
|
<div class="breadcrumb-nav" id="moveBreadcrumbNav">
|
||||||
|
<span class="breadcrumb-item root" data-path="">
|
||||||
|
<i class="fas fa-home"></i> Root
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hierarchical folder tree -->
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="moveNewFolder">New Folder (optional):</label>
|
<label>Browse Folders:</label>
|
||||||
<input type="text" id="moveNewFolder" placeholder="Enter folder name" />
|
<div class="folder-tree-container">
|
||||||
|
<div class="folder-tree" id="moveFolderTree">
|
||||||
|
<!-- Tree will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|||||||
@@ -91,6 +91,57 @@
|
|||||||
</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">
|
||||||
<h3>Folder Settings</h3>
|
<h3>Folder Settings</h3>
|
||||||
@@ -149,104 +200,117 @@
|
|||||||
|
|
||||||
<!-- 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="input-help">
|
||||||
|
Configure folder structures for different model types when downloading from Civitai.
|
||||||
|
<div class="placeholder-info">
|
||||||
|
<strong>Available placeholders:</strong>
|
||||||
|
<span class="placeholder-tag">{base_model}</span>
|
||||||
|
<span class="placeholder-tag">{author}</span>
|
||||||
|
<span class="placeholder-tag">{first_tag}</span>
|
||||||
|
</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 for="downloadPathTemplate">Download Path Template</label>
|
<label for="loraTemplatePreset">LoRA</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control select-control">
|
<div class="setting-control select-control">
|
||||||
<select id="downloadPathTemplate" onchange="settingsManager.saveSelectSetting('downloadPathTemplate', 'download_path_template')">
|
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
|
||||||
<option value="">Flat Structure</option>
|
<option value="">Flat Structure</option>
|
||||||
<option value="{base_model}">By Base Model</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="{first_tag}">By First Tag</option>
|
||||||
<option value="{base_model}/{first_tag}">Base Model + 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;">
|
||||||
Configure path structure for default download locations
|
<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>Flat:</strong> All models in root folder</li>
|
|
||||||
<li><strong>Base Model:</strong> Organized by model type (e.g., Flux.1 D, SDXL)</li>
|
|
||||||
<li><strong>First Tag:</strong> Organized by primary tag (e.g., style, character)</li>
|
|
||||||
<li><strong>Base Model + Tag:</strong> Two-level organization for better structure</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="pathTemplatePreview" class="template-preview"></div>
|
<div class="template-preview" id="loraPreview"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>Base Model Path Mappings</label>
|
<label for="checkpointTemplatePreset">Checkpoint</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="setting-control select-control">
|
||||||
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
|
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
|
||||||
<i class="fas fa-plus"></i>
|
<option value="">Flat Structure</option>
|
||||||
<span>Add Mapping</span>
|
<option value="{base_model}">By Base Model</option>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<div class="input-help">
|
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
|
||||||
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
|
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
|
||||||
|
<div class="template-validation" id="checkpointValidation"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mappings-container">
|
<div class="template-preview" id="checkpointPreview"></div>
|
||||||
<div id="baseModelMappingsContainer">
|
</div>
|
||||||
<!-- Mapping rows will be added dynamically -->
|
|
||||||
|
<!-- 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Add Layout Settings Section -->
|
<div class="setting-item">
|
||||||
<div class="settings-section">
|
<div class="setting-row">
|
||||||
<h3>Layout Settings</h3>
|
<div class="setting-info">
|
||||||
|
<label>Base Model Path Mappings</label>
|
||||||
<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>
|
||||||
<div class="input-help">
|
<div class="setting-control">
|
||||||
Choose how many cards to display per row:
|
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
|
||||||
<ul class="list-description">
|
<i class="fas fa-plus"></i>
|
||||||
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
|
<span>Add Mapping</span>
|
||||||
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="input-help">
|
||||||
<!-- Add Card Info Display setting -->
|
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
|
||||||
<div class="setting-item">
|
</div>
|
||||||
<div class="setting-row">
|
<div class="mappings-container">
|
||||||
<div class="setting-info">
|
<div id="baseModelMappingsContainer">
|
||||||
<label for="cardInfoDisplay">Card Info Display</label>
|
<!-- Mapping rows will be added dynamically -->
|
||||||
</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>
|
</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 %}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
|
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
|
||||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
||||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
||||||
|
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> Download Example Images</div>
|
||||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||||
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
|
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
@@ -29,27 +30,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/duplicates_banner.html' %}
|
||||||
<!-- Duplicates banner (hidden by default) -->
|
|
||||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
|
||||||
<div class="banner-content">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
<span id="duplicatesCount">Found 0 duplicate groups</span>
|
|
||||||
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
|
|
||||||
<div class="banner-actions">
|
|
||||||
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
|
|
||||||
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
|
||||||
</button>
|
|
||||||
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
|
|
||||||
<i class="fas fa-times"></i> Exit Mode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help-tooltip" id="duplicatesHelpTooltip">
|
|
||||||
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
|
|
||||||
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embedding cards container -->
|
<!-- Embedding cards container -->
|
||||||
<div class="card-grid" id="modelGrid">
|
<div class="card-grid" id="modelGrid">
|
||||||
|
|||||||
@@ -11,32 +11,12 @@
|
|||||||
|
|
||||||
{% 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' %}
|
||||||
{% include 'components/alphabet_bar.html' %}
|
{% include 'components/alphabet_bar.html' %}
|
||||||
|
{% include 'components/duplicates_banner.html' %}
|
||||||
<!-- Duplicates banner (hidden by default) -->
|
|
||||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
|
||||||
<div class="banner-content">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
<span id="duplicatesCount">Found 0 duplicate groups</span>
|
|
||||||
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
|
|
||||||
<div class="banner-actions">
|
|
||||||
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
|
|
||||||
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
|
||||||
</button>
|
|
||||||
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
|
|
||||||
<i class="fas fa-times"></i> Exit Mode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="help-tooltip" id="duplicatesHelpTooltip">
|
|
||||||
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
|
|
||||||
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lora卡片容器 -->
|
<!-- Lora卡片容器 -->
|
||||||
<div class="card-grid" id="modelGrid">
|
<div class="card-grid" id="modelGrid">
|
||||||
|
|||||||
452
web/comfyui/autocomplete.js
Normal file
452
web/comfyui/autocomplete.js
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
||||||
|
|
||||||
|
class AutoComplete {
|
||||||
|
constructor(inputElement, modelType = 'loras', options = {}) {
|
||||||
|
this.inputElement = inputElement;
|
||||||
|
this.modelType = modelType;
|
||||||
|
this.options = {
|
||||||
|
maxItems: 15,
|
||||||
|
minChars: 1,
|
||||||
|
debounceDelay: 200,
|
||||||
|
showPreview: true,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dropdown = null;
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
this.items = [];
|
||||||
|
this.debounceTimer = null;
|
||||||
|
this.isVisible = false;
|
||||||
|
this.currentSearchTerm = '';
|
||||||
|
this.previewTooltip = null;
|
||||||
|
|
||||||
|
// Initialize TextAreaCaretHelper
|
||||||
|
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createDropdown();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
createDropdown() {
|
||||||
|
this.dropdown = document.createElement('div');
|
||||||
|
this.dropdown.className = 'comfy-autocomplete-dropdown';
|
||||||
|
|
||||||
|
// Apply new color scheme
|
||||||
|
this.dropdown.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10000;
|
||||||
|
overflow-y: visible;
|
||||||
|
background-color: rgba(40, 44, 52, 0.95);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
display: none;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 200px;
|
||||||
|
width: auto;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Custom scrollbar styles with new color scheme
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.comfy-autocomplete-dropdown::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.comfy-autocomplete-dropdown::-webkit-scrollbar-track {
|
||||||
|
background: rgba(40, 44, 52, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(226, 232, 240, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(226, 232, 240, 0.4);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Append to body to avoid overflow issues
|
||||||
|
document.body.appendChild(this.dropdown);
|
||||||
|
|
||||||
|
// Initialize preview tooltip if needed
|
||||||
|
if (this.options.showPreview && this.modelType === 'loras') {
|
||||||
|
this.initPreviewTooltip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initPreviewTooltip() {
|
||||||
|
// Dynamically import and create preview tooltip
|
||||||
|
import('./loras_widget_components.js').then(module => {
|
||||||
|
this.previewTooltip = new module.PreviewTooltip();
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('Failed to load preview tooltip:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Handle input changes
|
||||||
|
this.inputElement.addEventListener('input', (e) => {
|
||||||
|
this.handleInput(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
this.inputElement.addEventListener('keydown', (e) => {
|
||||||
|
this.handleKeyDown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle focus out to hide dropdown
|
||||||
|
this.inputElement.addEventListener('blur', (e) => {
|
||||||
|
// Delay hiding to allow for clicks on dropdown items
|
||||||
|
setTimeout(() => {
|
||||||
|
this.hide();
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle clicks outside to hide dropdown
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.dropdown.contains(e.target) && e.target !== this.inputElement) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(value = '') {
|
||||||
|
// Clear previous debounce timer
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the search term (text after last comma)
|
||||||
|
const searchTerm = this.getSearchTerm(value);
|
||||||
|
|
||||||
|
if (searchTerm.length < this.options.minChars) {
|
||||||
|
this.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the search
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
this.search(searchTerm);
|
||||||
|
}, this.options.debounceDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSearchTerm(value) {
|
||||||
|
const lastCommaIndex = value.lastIndexOf(',');
|
||||||
|
if (lastCommaIndex === -1) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return value.substring(lastCommaIndex + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(term = '') {
|
||||||
|
try {
|
||||||
|
this.currentSearchTerm = term;
|
||||||
|
const response = await api.fetchApi(`/${this.modelType}/relative-paths?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.relative_paths && data.relative_paths.length > 0) {
|
||||||
|
this.items = data.relative_paths;
|
||||||
|
this.render();
|
||||||
|
this.show();
|
||||||
|
} else {
|
||||||
|
this.items = [];
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Autocomplete search error:', error);
|
||||||
|
this.items = [];
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.dropdown.innerHTML = '';
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
|
||||||
|
// Early return if no items to prevent empty dropdown
|
||||||
|
if (!this.items || this.items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.items.forEach((relativePath, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'comfy-autocomplete-item';
|
||||||
|
|
||||||
|
// Create highlighted content
|
||||||
|
const highlightedContent = this.highlightMatch(relativePath, this.currentSearchTerm);
|
||||||
|
item.innerHTML = highlightedContent;
|
||||||
|
|
||||||
|
// Apply item styles with new color scheme
|
||||||
|
item.style.cssText = `
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(226, 232, 240, 0.8);
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Hover and selection handlers
|
||||||
|
item.addEventListener('mouseenter', () => {
|
||||||
|
this.selectItem(index);
|
||||||
|
this.showPreviewForItem(relativePath, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('mouseleave', () => {
|
||||||
|
this.hidePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this.insertSelection(relativePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove border from last item
|
||||||
|
if (this.dropdown.lastChild) {
|
||||||
|
this.dropdown.lastChild.style.borderBottom = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightMatch(text, searchTerm) {
|
||||||
|
if (!searchTerm) return text;
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||||
|
return text.replace(regex, '<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
showPreviewForItem(relativePath, itemElement) {
|
||||||
|
if (!this.previewTooltip) return;
|
||||||
|
|
||||||
|
// Extract filename without extension for preview
|
||||||
|
const fileName = relativePath.split('/').pop();
|
||||||
|
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||||
|
|
||||||
|
// Get item position for tooltip positioning
|
||||||
|
const rect = itemElement.getBoundingClientRect();
|
||||||
|
const x = rect.right + 10;
|
||||||
|
const y = rect.top;
|
||||||
|
|
||||||
|
this.previewTooltip.show(loraName, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePreview() {
|
||||||
|
if (this.previewTooltip) {
|
||||||
|
this.previewTooltip.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
if (!this.items || this.items.length === 0) {
|
||||||
|
this.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position dropdown at cursor position using TextAreaCaretHelper
|
||||||
|
this.positionAtCursor();
|
||||||
|
this.dropdown.style.display = 'block';
|
||||||
|
this.isVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
positionAtCursor() {
|
||||||
|
const position = this.helper.getCursorOffset();
|
||||||
|
this.dropdown.style.left = (position.left ?? 0) + "px";
|
||||||
|
this.dropdown.style.top = (position.top ?? 0) + "px";
|
||||||
|
this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
|
||||||
|
|
||||||
|
// Adjust width to fit content
|
||||||
|
// Temporarily show the dropdown to measure content width
|
||||||
|
const originalDisplay = this.dropdown.style.display;
|
||||||
|
this.dropdown.style.display = 'block';
|
||||||
|
this.dropdown.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
// Measure the content width
|
||||||
|
let maxWidth = 200; // minimum width
|
||||||
|
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
const itemWidth = item.scrollWidth + 24; // Add padding
|
||||||
|
maxWidth = Math.max(maxWidth, itemWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the width and restore visibility
|
||||||
|
this.dropdown.style.width = Math.min(maxWidth, 400) + 'px'; // Cap at 400px
|
||||||
|
this.dropdown.style.visibility = 'visible';
|
||||||
|
this.dropdown.style.display = originalDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCaretPosition() {
|
||||||
|
return this.inputElement.selectionStart || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.dropdown.style.display = 'none';
|
||||||
|
this.isVisible = false;
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
|
||||||
|
// Hide preview tooltip
|
||||||
|
this.hidePreview();
|
||||||
|
|
||||||
|
// Clear selection styles from all items
|
||||||
|
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
item.classList.remove('comfy-autocomplete-item-selected');
|
||||||
|
item.style.backgroundColor = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(index) {
|
||||||
|
// Remove previous selection
|
||||||
|
const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected');
|
||||||
|
if (prevSelected) {
|
||||||
|
prevSelected.classList.remove('comfy-autocomplete-item-selected');
|
||||||
|
prevSelected.style.backgroundColor = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new selection
|
||||||
|
if (index >= 0 && index < this.items.length) {
|
||||||
|
this.selectedIndex = index;
|
||||||
|
const item = this.dropdown.children[index];
|
||||||
|
item.classList.add('comfy-autocomplete-item-selected');
|
||||||
|
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||||
|
|
||||||
|
// Scroll into view if needed
|
||||||
|
item.scrollIntoView({ block: 'nearest' });
|
||||||
|
|
||||||
|
// Show preview for selected item
|
||||||
|
if (this.options.showPreview) {
|
||||||
|
this.showPreviewForItem(this.items[index], item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(e) {
|
||||||
|
if (!this.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectItem(Math.max(this.selectedIndex - 1, 0));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||||||
|
this.insertSelection(this.items[this.selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
this.hide();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertSelection(relativePath) {
|
||||||
|
// Extract just the filename for LoRA name
|
||||||
|
const fileName = relativePath.split('/').pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||||
|
|
||||||
|
// Get usage tips and extract strength
|
||||||
|
let strength = 1.0; // Default strength
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(`/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.usage_tips) {
|
||||||
|
// Parse JSON string and extract strength
|
||||||
|
try {
|
||||||
|
const usageTips = JSON.parse(data.usage_tips);
|
||||||
|
if (usageTips.strength && typeof usageTips.strength === 'number') {
|
||||||
|
strength = usageTips.strength;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse usage tips JSON:', parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch usage tips:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the LoRA code with strength
|
||||||
|
const loraCode = `<lora:${fileName}:${strength}>, `;
|
||||||
|
|
||||||
|
const currentValue = this.inputElement.value;
|
||||||
|
const caretPos = this.getCaretPosition();
|
||||||
|
const lastCommaIndex = currentValue.lastIndexOf(',', caretPos - 1);
|
||||||
|
|
||||||
|
let newValue;
|
||||||
|
let newCaretPos;
|
||||||
|
|
||||||
|
if (lastCommaIndex === -1) {
|
||||||
|
// No comma found before cursor, replace from start or current search term start
|
||||||
|
const searchTerm = this.getSearchTerm(currentValue.substring(0, caretPos));
|
||||||
|
const searchStartPos = caretPos - searchTerm.length;
|
||||||
|
newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos);
|
||||||
|
newCaretPos = searchStartPos + loraCode.length;
|
||||||
|
} else {
|
||||||
|
// Replace text after last comma before cursor
|
||||||
|
const afterCommaPos = lastCommaIndex + 1;
|
||||||
|
// Skip whitespace after comma
|
||||||
|
let insertPos = afterCommaPos;
|
||||||
|
while (insertPos < caretPos && /\s/.test(currentValue[insertPos])) {
|
||||||
|
insertPos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
newValue = currentValue.substring(0, insertPos) + loraCode + currentValue.substring(caretPos);
|
||||||
|
newCaretPos = insertPos + loraCode.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inputElement.value = newValue;
|
||||||
|
|
||||||
|
// Trigger input event to notify about the change
|
||||||
|
const event = new Event('input', { bubbles: true });
|
||||||
|
this.inputElement.dispatchEvent(event);
|
||||||
|
|
||||||
|
this.hide();
|
||||||
|
|
||||||
|
// Focus back to input and position cursor
|
||||||
|
this.inputElement.focus();
|
||||||
|
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previewTooltip) {
|
||||||
|
this.previewTooltip.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dropdown && this.dropdown.parentNode) {
|
||||||
|
this.dropdown.parentNode.removeChild(this.dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners would be added here if we tracked them
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AutoComplete };
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback,
|
chainCallback,
|
||||||
mergeLoras
|
mergeLoras,
|
||||||
|
setupInputWidgetWithAutocomplete
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
|
||||||
@@ -144,8 +145,9 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up multiple spaces and trim
|
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
|
||||||
newText = newText.replace(/\s+/g, " ").trim();
|
newText = newText.replace(/\s+/g, " ").replace(/,\s*,+/g, ",").trim();
|
||||||
|
if (newText === ",") newText = "";
|
||||||
|
|
||||||
inputWidget.value = newText;
|
inputWidget.value = newText;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -156,8 +158,10 @@ 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) => {
|
|
||||||
|
const originalCallback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
@@ -171,6 +175,9 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup input widget with autocomplete
|
||||||
|
inputWidget.callback = setupInputWidgetWithAutocomplete(this, inputWidget, originalCallback);
|
||||||
|
|
||||||
// Register this node with the backend
|
// Register this node with the backend
|
||||||
this.registerNode = async () => {
|
this.registerNode = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback,
|
chainCallback,
|
||||||
mergeLoras
|
mergeLoras,
|
||||||
|
setupInputWidgetWithAutocomplete
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
|
||||||
@@ -52,8 +53,9 @@ app.registerExtension({
|
|||||||
return currentLoras.includes(name) ? match : '';
|
return currentLoras.includes(name) ? match : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up multiple spaces and trim
|
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
|
||||||
newText = newText.replace(/\s+/g, ' ').trim();
|
newText = newText.replace(/\s+/g, " ").replace(/,\s*,+/g, ",").trim();
|
||||||
|
if (newText === ",") newText = "";
|
||||||
|
|
||||||
inputWidget.value = newText;
|
inputWidget.value = newText;
|
||||||
|
|
||||||
@@ -77,8 +79,10 @@ 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) => {
|
// Wrap the callback with autocomplete setup
|
||||||
|
const originalCallback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
@@ -98,6 +102,7 @@ app.registerExtension({
|
|||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
inputWidget.callback = setupInputWidgetWithAutocomplete(this, inputWidget, originalCallback);
|
||||||
|
|
||||||
// Register this node with the backend
|
// Register this node with the backend
|
||||||
this.registerNode = async () => {
|
this.registerNode = async () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -219,18 +219,26 @@ export class PreviewTooltip {
|
|||||||
display: 'none',
|
display: 'none',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxWidth: '300px',
|
maxWidth: '300px',
|
||||||
|
pointerEvents: 'none', // Prevent interference with autocomplete
|
||||||
});
|
});
|
||||||
document.body.appendChild(this.element);
|
document.body.appendChild(this.element);
|
||||||
this.hideTimeout = null;
|
this.hideTimeout = null;
|
||||||
|
this.isFromAutocomplete = false;
|
||||||
|
|
||||||
// Add global click event to hide tooltip
|
// Modified event listeners for autocomplete compatibility
|
||||||
document.addEventListener('click', () => this.hide());
|
this.globalClickHandler = (e) => {
|
||||||
|
// Don't hide if click is on autocomplete dropdown
|
||||||
|
if (!e.target.closest('.comfy-autocomplete-dropdown')) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', this.globalClickHandler);
|
||||||
|
|
||||||
// Add scroll event listener
|
this.globalScrollHandler = () => this.hide();
|
||||||
document.addEventListener('scroll', () => this.hide(), true);
|
document.addEventListener('scroll', this.globalScrollHandler, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async show(loraName, x, y) {
|
async show(loraName, x, y, fromAutocomplete = false) {
|
||||||
try {
|
try {
|
||||||
// Clear previous hide timer
|
// Clear previous hide timer
|
||||||
if (this.hideTimeout) {
|
if (this.hideTimeout) {
|
||||||
@@ -238,8 +246,12 @@ export class PreviewTooltip {
|
|||||||
this.hideTimeout = null;
|
this.hideTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track if this is from autocomplete
|
||||||
|
this.isFromAutocomplete = fromAutocomplete;
|
||||||
|
|
||||||
// Don't redisplay the same lora preview
|
// Don't redisplay the same lora preview
|
||||||
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
||||||
|
this.position(x, y);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +312,7 @@ export class PreviewTooltip {
|
|||||||
left: '0',
|
left: '0',
|
||||||
right: '0',
|
right: '0',
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
color: 'rgba(255, 255, 255, 0.95)',
|
color: 'white',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
||||||
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
|
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
|
||||||
@@ -349,6 +361,10 @@ export class PreviewTooltip {
|
|||||||
top = y - rect.height - 10;
|
top = y - rect.height - 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure minimum distance from edges
|
||||||
|
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
|
||||||
|
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
|
||||||
|
|
||||||
Object.assign(this.element.style, {
|
Object.assign(this.element.style, {
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
top: `${top}px`
|
top: `${top}px`
|
||||||
@@ -362,6 +378,7 @@ export class PreviewTooltip {
|
|||||||
this.hideTimeout = setTimeout(() => {
|
this.hideTimeout = setTimeout(() => {
|
||||||
this.element.style.display = 'none';
|
this.element.style.display = 'none';
|
||||||
this.currentLora = null;
|
this.currentLora = null;
|
||||||
|
this.isFromAutocomplete = false;
|
||||||
// Stop video playback
|
// Stop video playback
|
||||||
const video = this.element.querySelector('video');
|
const video = this.element.querySelector('video');
|
||||||
if (video) {
|
if (video) {
|
||||||
@@ -376,9 +393,9 @@ export class PreviewTooltip {
|
|||||||
if (this.hideTimeout) {
|
if (this.hideTimeout) {
|
||||||
clearTimeout(this.hideTimeout);
|
clearTimeout(this.hideTimeout);
|
||||||
}
|
}
|
||||||
// Remove all event listeners
|
// Remove event listeners properly
|
||||||
document.removeEventListener('click', () => this.hide());
|
document.removeEventListener('click', this.globalClickHandler);
|
||||||
document.removeEventListener('scroll', () => this.hide(), true);
|
document.removeEventListener('scroll', this.globalScrollHandler, true);
|
||||||
this.element.remove();
|
this.element.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
332
web/comfyui/textarea_caret_helper.js
Normal file
332
web/comfyui/textarea_caret_helper.js
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/*
|
||||||
|
https://github.com/component/textarea-caret-position
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
const getCaretCoordinates = (function () {
|
||||||
|
// We'll copy the properties below into the mirror div.
|
||||||
|
// Note that some browsers, such as Firefox, do not concatenate properties
|
||||||
|
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
|
||||||
|
// so we have to list every single property explicitly.
|
||||||
|
var properties = [
|
||||||
|
"direction", // RTL support
|
||||||
|
"boxSizing",
|
||||||
|
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
|
||||||
|
"height",
|
||||||
|
"overflowX",
|
||||||
|
"overflowY", // copy the scrollbar for IE
|
||||||
|
|
||||||
|
"borderTopWidth",
|
||||||
|
"borderRightWidth",
|
||||||
|
"borderBottomWidth",
|
||||||
|
"borderLeftWidth",
|
||||||
|
"borderStyle",
|
||||||
|
|
||||||
|
"paddingTop",
|
||||||
|
"paddingRight",
|
||||||
|
"paddingBottom",
|
||||||
|
"paddingLeft",
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
|
||||||
|
"fontStyle",
|
||||||
|
"fontVariant",
|
||||||
|
"fontWeight",
|
||||||
|
"fontStretch",
|
||||||
|
"fontSize",
|
||||||
|
"fontSizeAdjust",
|
||||||
|
"lineHeight",
|
||||||
|
"fontFamily",
|
||||||
|
|
||||||
|
"textAlign",
|
||||||
|
"textTransform",
|
||||||
|
"textIndent",
|
||||||
|
"textDecoration", // might not make a difference, but better be safe
|
||||||
|
|
||||||
|
"letterSpacing",
|
||||||
|
"wordSpacing",
|
||||||
|
|
||||||
|
"tabSize",
|
||||||
|
"MozTabSize",
|
||||||
|
];
|
||||||
|
|
||||||
|
var isBrowser = typeof window !== "undefined";
|
||||||
|
var isFirefox = isBrowser && window.mozInnerScreenX != null;
|
||||||
|
|
||||||
|
return function getCaretCoordinates(element, position, options) {
|
||||||
|
if (!isBrowser) {
|
||||||
|
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
var debug = (options && options.debug) || false;
|
||||||
|
if (debug) {
|
||||||
|
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
|
||||||
|
if (el) el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mirror div will replicate the textarea's style
|
||||||
|
var div = document.createElement("div");
|
||||||
|
div.id = "input-textarea-caret-position-mirror-div";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
var style = div.style;
|
||||||
|
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
|
||||||
|
var isInput = element.nodeName === "INPUT";
|
||||||
|
|
||||||
|
// Default textarea styles
|
||||||
|
style.whiteSpace = "pre-wrap";
|
||||||
|
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
|
||||||
|
|
||||||
|
// Position off-screen
|
||||||
|
style.position = "absolute"; // required to return coordinates properly
|
||||||
|
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
|
||||||
|
|
||||||
|
// Transfer the element's properties to the div
|
||||||
|
properties.forEach(function (prop) {
|
||||||
|
if (isInput && prop === "lineHeight") {
|
||||||
|
// Special case for <input>s because text is rendered centered and line height may be != height
|
||||||
|
if (computed.boxSizing === "border-box") {
|
||||||
|
var height = parseInt(computed.height);
|
||||||
|
var outerHeight =
|
||||||
|
parseInt(computed.paddingTop) +
|
||||||
|
parseInt(computed.paddingBottom) +
|
||||||
|
parseInt(computed.borderTopWidth) +
|
||||||
|
parseInt(computed.borderBottomWidth);
|
||||||
|
var targetHeight = outerHeight + parseInt(computed.lineHeight);
|
||||||
|
if (height > targetHeight) {
|
||||||
|
style.lineHeight = height - outerHeight + "px";
|
||||||
|
} else if (height === targetHeight) {
|
||||||
|
style.lineHeight = computed.lineHeight;
|
||||||
|
} else {
|
||||||
|
style.lineHeight = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style.lineHeight = computed.height;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style[prop] = computed[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isFirefox) {
|
||||||
|
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
|
||||||
|
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
|
||||||
|
} else {
|
||||||
|
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
|
||||||
|
}
|
||||||
|
|
||||||
|
div.textContent = element.value.substring(0, position);
|
||||||
|
// The second special handling for input type="text" vs textarea:
|
||||||
|
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
|
||||||
|
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
|
||||||
|
|
||||||
|
var span = document.createElement("span");
|
||||||
|
// Wrapping must be replicated *exactly*, including when a long word gets
|
||||||
|
// onto the next line, with whitespace at the end of the line before (#7).
|
||||||
|
// The *only* reliable way to do that is to copy the *entire* rest of the
|
||||||
|
// textarea's content into the <span> created at the caret position.
|
||||||
|
// For inputs, just '.' would be enough, but no need to bother.
|
||||||
|
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
|
||||||
|
div.appendChild(span);
|
||||||
|
|
||||||
|
var coordinates = {
|
||||||
|
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
|
||||||
|
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
|
||||||
|
height: parseInt(computed["lineHeight"]),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
span.style.backgroundColor = "#aaa";
|
||||||
|
} else {
|
||||||
|
document.body.removeChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coordinates;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/*
|
||||||
|
Key functions from:
|
||||||
|
https://github.com/yuku/textcomplete
|
||||||
|
© Yuku Takahashi - This software is licensed under the MIT license.
|
||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
const CHAR_CODE_ZERO = "0".charCodeAt(0);
|
||||||
|
const CHAR_CODE_NINE = "9".charCodeAt(0);
|
||||||
|
|
||||||
|
export class TextAreaCaretHelper {
|
||||||
|
constructor(el, getScale) {
|
||||||
|
this.el = el;
|
||||||
|
this.getScale = getScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#calculateElementOffset() {
|
||||||
|
const rect = this.el.getBoundingClientRect();
|
||||||
|
const owner = this.el.ownerDocument;
|
||||||
|
if (owner == null) {
|
||||||
|
throw new Error("Given element does not belong to document");
|
||||||
|
}
|
||||||
|
const { defaultView, documentElement } = owner;
|
||||||
|
if (defaultView == null) {
|
||||||
|
throw new Error("Given element does not belong to window");
|
||||||
|
}
|
||||||
|
const offset = {
|
||||||
|
top: rect.top + defaultView.pageYOffset,
|
||||||
|
left: rect.left + defaultView.pageXOffset,
|
||||||
|
};
|
||||||
|
if (documentElement) {
|
||||||
|
offset.top -= documentElement.clientTop;
|
||||||
|
offset.left -= documentElement.clientLeft;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
#isDigit(charCode) {
|
||||||
|
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
#getLineHeightPx() {
|
||||||
|
const computedStyle = getComputedStyle(this.el);
|
||||||
|
const lineHeight = computedStyle.lineHeight;
|
||||||
|
// If the char code starts with a digit, it is either a value in pixels,
|
||||||
|
// or unitless, as per:
|
||||||
|
// https://drafts.csswg.org/css2/visudet.html#propdef-line-height
|
||||||
|
// https://drafts.csswg.org/css2/cascade.html#computed-value
|
||||||
|
if (this.#isDigit(lineHeight.charCodeAt(0))) {
|
||||||
|
const floatLineHeight = parseFloat(lineHeight);
|
||||||
|
// In real browsers the value is *always* in pixels, even for unit-less
|
||||||
|
// line-heights. However, we still check as per the spec.
|
||||||
|
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
|
||||||
|
? floatLineHeight * parseFloat(computedStyle.fontSize)
|
||||||
|
: floatLineHeight;
|
||||||
|
}
|
||||||
|
// Otherwise, the value is "normal".
|
||||||
|
// If the line-height is "normal", calculate by font-size
|
||||||
|
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns calculated line-height of the given node in pixels.
|
||||||
|
*/
|
||||||
|
#calculateLineHeightPx(nodeName, computedStyle) {
|
||||||
|
const body = document.body;
|
||||||
|
if (!body) return 0;
|
||||||
|
|
||||||
|
const tempNode = document.createElement(nodeName);
|
||||||
|
tempNode.innerHTML = " ";
|
||||||
|
Object.assign(tempNode.style, {
|
||||||
|
fontSize: computedStyle.fontSize,
|
||||||
|
fontFamily: computedStyle.fontFamily,
|
||||||
|
padding: "0",
|
||||||
|
position: "absolute",
|
||||||
|
});
|
||||||
|
body.appendChild(tempNode);
|
||||||
|
|
||||||
|
// Make sure textarea has only 1 row
|
||||||
|
if (tempNode instanceof HTMLTextAreaElement) {
|
||||||
|
tempNode.rows = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume the height of the element is the line-height
|
||||||
|
const height = tempNode.offsetHeight;
|
||||||
|
body.removeChild(tempNode);
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCursorOffset() {
|
||||||
|
const scale = this.getScale();
|
||||||
|
const elOffset = this.#calculateElementOffset();
|
||||||
|
const elScroll = this.#getElScroll();
|
||||||
|
const cursorPosition = this.#getCursorPosition();
|
||||||
|
const lineHeight = this.#getLineHeightPx();
|
||||||
|
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
|
||||||
|
const left = elOffset.left - elScroll.left + cursorPosition.left;
|
||||||
|
const clientTop = this.el.getBoundingClientRect().top;
|
||||||
|
if (this.el.dir !== "rtl") {
|
||||||
|
return { top, left, lineHeight, clientTop };
|
||||||
|
} else {
|
||||||
|
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
|
||||||
|
return { top, right, lineHeight, clientTop };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#getElScroll() {
|
||||||
|
return { top: this.el.scrollTop, left: this.el.scrollLeft };
|
||||||
|
}
|
||||||
|
|
||||||
|
#getCursorPosition() {
|
||||||
|
return getCaretCoordinates(this.el, this.el.selectionEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBeforeCursor() {
|
||||||
|
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAfterCursor() {
|
||||||
|
return this.el.value.substring(this.el.selectionEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAtCursor(value, offset, finalOffset) {
|
||||||
|
if (this.el.selectionStart != null) {
|
||||||
|
const startPos = this.el.selectionStart;
|
||||||
|
const endPos = this.el.selectionEnd;
|
||||||
|
|
||||||
|
// Move selection to beginning of offset
|
||||||
|
this.el.selectionStart = this.el.selectionStart + offset;
|
||||||
|
|
||||||
|
// Using execCommand to support undo, but since it's officially
|
||||||
|
// 'deprecated' we need a backup solution, but it won't support undo :(
|
||||||
|
let pasted = true;
|
||||||
|
try {
|
||||||
|
if (!document.execCommand("insertText", false, value)) {
|
||||||
|
pasted = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error caught during execCommand:", e);
|
||||||
|
pasted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pasted) {
|
||||||
|
console.error(
|
||||||
|
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
|
||||||
|
textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
|
||||||
|
} else {
|
||||||
|
// Using execCommand to support undo, but since it's officially
|
||||||
|
// 'deprecated' we need a backup solution, but it won't support undo :(
|
||||||
|
let pasted = true;
|
||||||
|
try {
|
||||||
|
if (!document.execCommand("insertText", false, value)) {
|
||||||
|
pasted = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error caught during execCommand:", e);
|
||||||
|
pasted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pasted) {
|
||||||
|
console.error(
|
||||||
|
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
|
||||||
|
this.el.value += value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const CONVERTED_TYPE = 'converted-widget';
|
export const CONVERTED_TYPE = 'converted-widget';
|
||||||
|
import { AutoComplete } from "./autocomplete.js";
|
||||||
|
|
||||||
export function chainCallback(object, property, callback) {
|
export function chainCallback(object, property, callback) {
|
||||||
if (object == undefined) {
|
if (object == undefined) {
|
||||||
@@ -227,3 +228,57 @@ export function mergeLoras(lorasText, lorasArr) {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize autocomplete for an input widget and setup cleanup
|
||||||
|
* @param {Object} node - The node instance
|
||||||
|
* @param {Object} inputWidget - The input widget to add autocomplete to
|
||||||
|
* @param {Function} originalCallback - The original callback function
|
||||||
|
* @returns {Function} Enhanced callback function with autocomplete
|
||||||
|
*/
|
||||||
|
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback) {
|
||||||
|
let autocomplete = null;
|
||||||
|
|
||||||
|
// Enhanced callback that initializes autocomplete and calls original callback
|
||||||
|
const enhancedCallback = (value) => {
|
||||||
|
// Initialize autocomplete on first callback if not already done
|
||||||
|
if (!autocomplete && inputWidget.inputEl) {
|
||||||
|
autocomplete = new AutoComplete(inputWidget.inputEl, 'loras', {
|
||||||
|
maxItems: 15,
|
||||||
|
minChars: 1,
|
||||||
|
debounceDelay: 200
|
||||||
|
});
|
||||||
|
// Store reference for cleanup
|
||||||
|
node.autocomplete = autocomplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the original callback
|
||||||
|
if (originalCallback) {
|
||||||
|
originalCallback(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup cleanup on node removal
|
||||||
|
setupAutocompleteCleanup(node);
|
||||||
|
|
||||||
|
return enhancedCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup autocomplete cleanup when node is removed
|
||||||
|
* @param {Object} node - The node instance
|
||||||
|
*/
|
||||||
|
export function setupAutocompleteCleanup(node) {
|
||||||
|
// Override onRemoved to cleanup autocomplete
|
||||||
|
const originalOnRemoved = node.onRemoved;
|
||||||
|
node.onRemoved = function() {
|
||||||
|
if (this.autocomplete) {
|
||||||
|
this.autocomplete.destroy();
|
||||||
|
this.autocomplete = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalOnRemoved) {
|
||||||
|
originalOnRemoved.call(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
getActiveLorasFromNode,
|
getActiveLorasFromNode,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback,
|
chainCallback,
|
||||||
mergeLoras
|
mergeLoras,
|
||||||
|
setupInputWidgetWithAutocomplete
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { addLorasWidget } from "./loras_widget.js";
|
||||||
|
|
||||||
@@ -56,8 +57,9 @@ app.registerExtension({
|
|||||||
return currentLoras.includes(name) ? match : '';
|
return currentLoras.includes(name) ? match : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up multiple spaces and trim
|
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
|
||||||
newText = newText.replace(/\s+/g, ' ').trim();
|
newText = newText.replace(/\s+/g, " ").replace(/,\s*,+/g, ",").trim();
|
||||||
|
if (newText === ",") newText = "";
|
||||||
|
|
||||||
inputWidget.value = newText;
|
inputWidget.value = newText;
|
||||||
|
|
||||||
@@ -78,8 +80,10 @@ 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) => {
|
// Wrap the callback with autocomplete setup
|
||||||
|
const originalCallback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
@@ -96,6 +100,7 @@ app.registerExtension({
|
|||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
inputWidget.callback = setupInputWidgetWithAutocomplete(this, inputWidget, originalCallback);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 162 KiB |
Reference in New Issue
Block a user