diff --git a/py/config.py b/py/config.py index 9737fa45..b0c944c3 100644 --- a/py/config.py +++ b/py/config.py @@ -17,15 +17,17 @@ class Config: def __init__(self): self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates') self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static') - # 路径映射字典, target to link mapping + # Path mapping dictionary, target to link mapping self._path_mappings = {} - # 静态路由映射字典, target to route mapping + # Static route mapping dictionary, target to route mapping self._route_mappings = {} self.loras_roots = self._init_lora_paths() self.checkpoints_roots = None self.unet_roots = None + self.embeddings_roots = None self.base_models_roots = self._init_checkpoint_paths() - # 在初始化时扫描符号链接 + self.embeddings_roots = self._init_embedding_paths() + # Scan symbolic links during initialization self._scan_symbolic_links() if not standalone_mode: @@ -48,6 +50,7 @@ class Config: 'loras': self.loras_roots, 'checkpoints': self.checkpoints_roots, 'unet': self.unet_roots, + 'embeddings': self.embeddings_roots, } # Add default roots if there's only one item and key doesn't exist @@ -83,15 +86,18 @@ class Config: return False def _scan_symbolic_links(self): - """扫描所有 LoRA 和 Checkpoint 根目录中的符号链接""" + """Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories""" for root in self.loras_roots: self._scan_directory_links(root) for root in self.base_models_roots: self._scan_directory_links(root) + + for root in self.embeddings_roots: + self._scan_directory_links(root) def _scan_directory_links(self, root: str): - """递归扫描目录中的符号链接""" + """Recursively scan symbolic links in a directory""" try: with os.scandir(root) as it: for entry in it: @@ -106,40 +112,40 @@ class Config: logger.error(f"Error scanning links in {root}: {e}") def add_path_mapping(self, link_path: str, target_path: str): - """添加符号链接路径映射 - target_path: 实际目标路径 - link_path: 符号链接路径 + """Add a symbolic link path mapping + target_path: actual target path + link_path: symbolic link path """ normalized_link = os.path.normpath(link_path).replace(os.sep, '/') normalized_target = os.path.normpath(target_path).replace(os.sep, '/') - # 保持原有的映射关系:目标路径 -> 链接路径 + # Keep the original mapping: target path -> link path self._path_mappings[normalized_target] = normalized_link logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}") def add_route_mapping(self, path: str, route: str): - """添加静态路由映射""" + """Add a static route mapping""" normalized_path = os.path.normpath(path).replace(os.sep, '/') self._route_mappings[normalized_path] = route # logger.info(f"Added route mapping: {normalized_path} -> {route}") def map_path_to_link(self, path: str) -> str: - """将目标路径映射回符号链接路径""" + """Map a target path back to its symbolic link path""" normalized_path = os.path.normpath(path).replace(os.sep, '/') - # 检查路径是否包含在任何映射的目标路径中 + # Check if the path is contained in any mapped target path for target_path, link_path in self._path_mappings.items(): if normalized_path.startswith(target_path): - # 如果路径以目标路径开头,则替换为链接路径 + # If the path starts with the target path, replace with link path mapped_path = normalized_path.replace(target_path, link_path, 1) return mapped_path return path def map_link_to_path(self, link_path: str) -> str: - """将符号链接路径映射回实际路径""" + """Map a symbolic link path back to the actual path""" normalized_link = os.path.normpath(link_path).replace(os.sep, '/') - # 检查路径是否包含在任何映射的目标路径中 + # Check if the path is contained in any mapped target path for target_path, link_path in self._path_mappings.items(): if normalized_link.startswith(target_path): - # 如果路径以目标路径开头,则替换为实际路径 + # If the path starts with the target path, replace with actual path mapped_path = normalized_link.replace(target_path, link_path, 1) return mapped_path return link_path @@ -223,6 +229,36 @@ class Config: logger.warning(f"Error initializing checkpoint paths: {e}") return [] + def _init_embedding_paths(self) -> List[str]: + """Initialize and validate embedding paths from ComfyUI settings""" + try: + raw_paths = folder_paths.get_folder_paths("embeddings") + + # Normalize and resolve symlinks, store mapping from resolved -> original + path_map = {} + for path in raw_paths: + if os.path.exists(path): + real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/') + path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen + + # Now sort and use only the deduplicated real paths + unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) + logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) + + if not unique_paths: + logger.warning("No valid embeddings folders found in ComfyUI configuration") + return [] + + for original_path in unique_paths: + real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') + if real_path != original_path: + self.add_path_mapping(original_path, real_path) + + return unique_paths + except Exception as e: + logger.warning(f"Error initializing embedding paths: {e}") + return [] + def get_preview_static_url(self, preview_path: str) -> str: """Convert local preview path to static URL""" if not preview_path: diff --git a/py/lora_manager.py b/py/lora_manager.py index e211d75e..0c2f5e1d 100644 --- a/py/lora_manager.py +++ b/py/lora_manager.py @@ -94,21 +94,45 @@ class LoraManager: config.add_route_mapping(real_root, preview_path) added_targets.add(real_root) + # Add static routes for each embedding root + for idx, root in enumerate(config.embeddings_roots, start=1): + preview_path = f'/embeddings_static/root{idx}/preview' + + real_root = root + if root in config._path_mappings.values(): + for target, link in config._path_mappings.items(): + if link == root: + real_root = target + break + # Add static route for original path + app.router.add_static(preview_path, real_root) + logger.info(f"Added static route {preview_path} -> {real_root}") + + # Record route mapping + config.add_route_mapping(real_root, preview_path) + added_targets.add(real_root) + # Add static routes for symlink target paths link_idx = { 'lora': 1, - 'checkpoint': 1 + 'checkpoint': 1, + 'embedding': 1 } for target_path, link_path in config._path_mappings.items(): if target_path not in added_targets: - # Determine if this is a checkpoint or lora link based on path + # Determine if this is a checkpoint, lora, or embedding link based on path is_checkpoint = any(cp_root in link_path for cp_root in config.base_models_roots) is_checkpoint = is_checkpoint or any(cp_root in target_path for cp_root in config.base_models_roots) + is_embedding = any(emb_root in link_path for emb_root in config.embeddings_roots) + is_embedding = is_embedding or any(emb_root in target_path for emb_root in config.embeddings_roots) if is_checkpoint: route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview' link_idx["checkpoint"] += 1 + elif is_embedding: + route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview' + link_idx["embedding"] += 1 else: route_path = f'/loras_static/link_{link_idx["lora"]}/preview' link_idx["lora"] += 1 @@ -168,6 +192,7 @@ class LoraManager: # Initialize scanners in background lora_scanner = await ServiceRegistry.get_lora_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() + embedding_scanner = await ServiceRegistry.get_embedding_scanner() # Initialize recipe scanner if needed recipe_scanner = await ServiceRegistry.get_recipe_scanner() @@ -175,6 +200,7 @@ class LoraManager: # Create low-priority initialization tasks asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init') asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init') + asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init') asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init') await ExampleImagesMigration.check_and_run_migrations() diff --git a/py/routes/embedding_routes.py b/py/routes/embedding_routes.py new file mode 100644 index 00000000..eb9a5203 --- /dev/null +++ b/py/routes/embedding_routes.py @@ -0,0 +1,105 @@ +import logging +from aiohttp import web + +from .base_model_routes import BaseModelRoutes +from ..services.embedding_service import EmbeddingService +from ..services.service_registry import ServiceRegistry + +logger = logging.getLogger(__name__) + +class EmbeddingRoutes(BaseModelRoutes): + """Embedding-specific route controller""" + + def __init__(self): + """Initialize Embedding routes with Embedding service""" + # Service will be initialized later via setup_routes + self.service = None + self.civitai_client = None + self.template_name = "embeddings.html" + + async def initialize_services(self): + """Initialize services from ServiceRegistry""" + embedding_scanner = await ServiceRegistry.get_embedding_scanner() + self.service = EmbeddingService(embedding_scanner) + self.civitai_client = await ServiceRegistry.get_civitai_client() + + # Initialize parent with the service + super().__init__(self.service) + + def setup_routes(self, app: web.Application): + """Setup Embedding routes""" + # Schedule service initialization on app startup + app.on_startup.append(lambda _: self.initialize_services()) + + # Setup common routes with 'embeddings' prefix (includes page route) + super().setup_routes(app, 'embeddings') + + def setup_specific_routes(self, app: web.Application, prefix: str): + """Setup Embedding-specific routes""" + # Embedding-specific CivitAI integration + app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_embedding) + + # Embedding info by name + app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_embedding_info) + + async def get_embedding_info(self, request: web.Request) -> web.Response: + """Get detailed information for a specific embedding by name""" + try: + name = request.match_info.get('name', '') + embedding_info = await self.service.get_model_info_by_name(name) + + if embedding_info: + return web.json_response(embedding_info) + else: + return web.json_response({"error": "Embedding not found"}, status=404) + + except Exception as e: + logger.error(f"Error in get_embedding_info: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def get_civitai_versions_embedding(self, request: web.Request) -> web.Response: + """Get available versions for a Civitai embedding model with local availability info""" + try: + model_id = request.match_info['model_id'] + response = await self.civitai_client.get_model_versions(model_id) + if not response or not response.get('modelVersions'): + return web.Response(status=404, text="Model not found") + + versions = response.get('modelVersions', []) + model_type = response.get('type', '') + + # Check model type - should be TextualInversion (Embedding) + if model_type.lower() not in ['textualinversion', 'embedding']: + return web.json_response({ + 'error': f"Model type mismatch. Expected TextualInversion/Embedding, got {model_type}" + }, status=400) + + # Check local availability for each version + for version in versions: + # Find the primary model file (type="Model" and primary=true) in the files list + model_file = next((file for file in version.get('files', []) + if file.get('type') == 'Model' and file.get('primary') == True), None) + + # If no primary file found, try to find any model file + if not model_file: + model_file = next((file for file in version.get('files', []) + if file.get('type') == 'Model'), None) + + if model_file: + sha256 = model_file.get('hashes', {}).get('SHA256') + if sha256: + # Set existsLocally and localPath at the version level + version['existsLocally'] = self.service.has_hash(sha256) + if version['existsLocally']: + version['localPath'] = self.service.get_path_by_hash(sha256) + + # Also set the model file size at the version level for easier access + version['modelSizeKB'] = model_file.get('sizeKB') + else: + # No model file found in this version + version['existsLocally'] = False + + return web.json_response(versions) + except Exception as e: + logger.error(f"Error fetching embedding model versions: {e}") + return web.Response(status=500, text=str(e)) diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index ac7dc4ed..54f0494c 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -45,21 +45,21 @@ class LoraRoutes(BaseModelRoutes): 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/lora-preview-url', self.get_lora_preview_url) - app.router.add_get(f'/api/lora-civitai-url', self.get_lora_civitai_url) - app.router.add_get(f'/api/lora-model-description', self.get_lora_model_description) + 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) # LoRA-specific management routes - app.router.add_post(f'/api/move_model', self.move_model) - app.router.add_post(f'/api/move_models_bulk', self.move_models_bulk) + app.router.add_post(f'/api/{prefix}/move_model', self.move_model) + app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk) # CivitAI integration with LoRA-specific validation app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora) - app.router.add_get(f'/api/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version) - app.router.add_get(f'/api/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash) + app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version) + app.router.add_get(f'/api/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash) # ComfyUI integration - app.router.add_post(f'/loramanager/get_trigger_words', self.get_trigger_words) + app.router.add_post(f'/api/{prefix}/get_trigger_words', self.get_trigger_words) def _parse_specific_params(self, request: web.Request) -> Dict: """Parse LoRA-specific parameters""" @@ -199,35 +199,6 @@ class LoraRoutes(BaseModelRoutes): 'error': str(e) }, status=500) - # Override get_models to add LoRA-specific response data - async def get_models(self, request: web.Request) -> web.Response: - """Get paginated LoRA data with LoRA-specific fields""" - try: - # Parse common query parameters - params = self._parse_common_params(request) - - # Get data from service - result = await self.service.get_paginated_data(**params) - - # Get all available folders from cache for LoRA-specific response - cache = await self.service.scanner.get_cached_data() - - # Format response items with LoRA-specific structure - formatted_result = { - 'items': [await self.service.format_response(item) for item in result['items']], - 'folders': cache.folders, # LoRA-specific: include folders in response - 'total': result['total'], - 'page': result['page'], - 'page_size': result['page_size'], - 'total_pages': result['total_pages'] - } - - return web.json_response(formatted_result) - - except Exception as e: - logger.error(f"Error in get_loras: {e}", exc_info=True) - return web.json_response({"error": str(e)}, status=500) - # CivitAI integration methods async def get_civitai_versions_lora(self, request: web.Request) -> web.Response: """Get available versions for a Civitai LoRA model with local availability info""" @@ -345,7 +316,7 @@ class LoraRoutes(BaseModelRoutes): success = await self.service.scanner.move_model(file_path, target_path) if success: - return web.json_response({'success': True}) + return web.json_response({'success': True, 'new_file_path': target_file_path}) else: return web.Response(text='Failed to move model', status=500) diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py index 596e0323..4fdd9485 100644 --- a/py/routes/misc_routes.py +++ b/py/routes/misc_routes.py @@ -632,9 +632,10 @@ class MiscRoutes: 'error': 'Parameter modelId must be an integer' }, status=400) - # Get both lora and checkpoint scanners + # Get all scanners lora_scanner = await ServiceRegistry.get_lora_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() + embedding_scanner = await ServiceRegistry.get_embedding_scanner() # If modelVersionId is provided, check for specific version if model_version_id_str: @@ -646,18 +647,19 @@ class MiscRoutes: 'error': 'Parameter modelVersionId must be an integer' }, status=400) - # Check if the specific version exists in either scanner + # Check lora scanner first exists = False model_type = None - - # Check lora scanner first + if await lora_scanner.check_model_version_exists(model_id, model_version_id): exists = True model_type = 'lora' - # If not found in lora, check checkpoint scanner elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_id, model_version_id): exists = True model_type = 'checkpoint' + elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_id, model_version_id): + exists = True + model_type = 'embedding' return web.json_response({ 'success': True, @@ -667,25 +669,29 @@ class MiscRoutes: # If modelVersionId is not provided, return all version IDs for the model else: - # Get versions from lora scanner first lora_versions = await lora_scanner.get_model_versions_by_id(model_id) checkpoint_versions = [] - - # Only check checkpoint scanner if no lora versions found + embedding_versions = [] + + # 优先lora,其次checkpoint,最后embedding if not lora_versions: checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id) - - # Determine model type and combine results + if not lora_versions and not checkpoint_versions: + embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id) + model_type = None versions = [] - + if lora_versions: model_type = 'lora' versions = lora_versions elif checkpoint_versions: model_type = 'checkpoint' versions = checkpoint_versions - + elif embedding_versions: + model_type = 'embedding' + versions = embedding_versions + return web.json_response({ 'success': True, 'modelId': model_id, diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index 59bb7f39..af6f948a 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -1,13 +1,11 @@ from datetime import datetime import aiohttp import os -import json import logging import asyncio from email.parser import Parser from typing import Optional, Dict, Tuple, List from urllib.parse import unquote -from ..utils.models import LoraMetadata logger = logging.getLogger(__name__) diff --git a/py/services/download_manager.py b/py/services/download_manager.py index e98e5498..aacdc362 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -4,7 +4,7 @@ import asyncio from collections import OrderedDict import uuid from typing import Dict -from ..utils.models import LoraMetadata, CheckpointMetadata +from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES, CIVITAI_MODEL_TAGS from ..utils.exif_utils import ExifUtils from ..utils.metadata_manager import MetadataManager @@ -204,6 +204,8 @@ class DownloadManager: model_type = 'checkpoint' elif model_type_from_info in VALID_LORA_TYPES: model_type = 'lora' + elif model_type_from_info == 'textualinversion': + model_type = 'embedding' else: return {'success': False, 'error': f'Model type "{model_type_from_info}" is not supported for download'} @@ -222,6 +224,11 @@ class DownloadManager: checkpoint_scanner = await self._get_checkpoint_scanner() if await checkpoint_scanner.check_model_version_exists(version_model_id, version_id): return {'success': False, 'error': 'Model version already exists in checkpoint library'} + elif model_type == 'embedding': + # Embeddings are not checked in scanners, but we can still check if it exists + embedding_scanner = await ServiceRegistry.get_embedding_scanner() + if await embedding_scanner.check_model_version_exists(version_model_id, version_id): + return {'success': False, 'error': 'Model version already exists in embedding library'} # Handle use_default_paths if use_default_paths: @@ -231,11 +238,16 @@ class DownloadManager: if not default_path: return {'success': False, 'error': 'Default checkpoint root path not set in settings'} save_dir = default_path - else: # model_type == 'lora' + elif model_type == 'lora': default_path = settings.get('default_lora_root') if not default_path: return {'success': False, 'error': 'Default lora root path not set in settings'} save_dir = default_path + elif model_type == 'embedding': + default_path = settings.get('default_embedding_root') + if not default_path: + return {'success': False, 'error': 'Default embedding root path not set in settings'} + save_dir = default_path # Calculate relative path using template relative_path = self._calculate_relative_path(version_info) @@ -282,9 +294,12 @@ class DownloadManager: if model_type == "checkpoint": metadata = CheckpointMetadata.from_civitai_info(version_info, file_info, save_path) logger.info(f"Creating CheckpointMetadata for {file_name}") - else: + elif model_type == "lora": metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path) logger.info(f"Creating LoraMetadata for {file_name}") + elif model_type == "embedding": + metadata = EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path) + logger.info(f"Creating EmbeddingMetadata for {file_name}") # 6. Start download process result = await self._execute_download( @@ -447,9 +462,12 @@ class DownloadManager: if model_type == "checkpoint": scanner = await self._get_checkpoint_scanner() logger.info(f"Updating checkpoint cache for {save_path}") - else: + elif model_type == "lora": scanner = await self._get_lora_scanner() logger.info(f"Updating lora cache for {save_path}") + elif model_type == "embedding": + scanner = await ServiceRegistry.get_embedding_scanner() + logger.info(f"Updating embedding cache for {save_path}") # Convert metadata to dictionary metadata_dict = metadata.to_dict() diff --git a/py/services/embedding_scanner.py b/py/services/embedding_scanner.py new file mode 100644 index 00000000..89257e1e --- /dev/null +++ b/py/services/embedding_scanner.py @@ -0,0 +1,26 @@ +import logging +from typing import List + +from ..utils.models import EmbeddingMetadata +from ..config import config +from .model_scanner import ModelScanner +from .model_hash_index import ModelHashIndex + +logger = logging.getLogger(__name__) + +class EmbeddingScanner(ModelScanner): + """Service for scanning and managing embedding files""" + + def __init__(self): + # Define supported file extensions + file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'} + super().__init__( + model_type="embedding", + model_class=EmbeddingMetadata, + file_extensions=file_extensions, + hash_index=ModelHashIndex() + ) + + def get_model_roots(self) -> List[str]: + """Get embedding root directories""" + return config.embeddings_roots diff --git a/py/services/embedding_service.py b/py/services/embedding_service.py new file mode 100644 index 00000000..b63559d2 --- /dev/null +++ b/py/services/embedding_service.py @@ -0,0 +1,51 @@ +import os +import logging +from typing import Dict, List, Optional + +from .base_model_service import BaseModelService +from ..utils.models import EmbeddingMetadata +from ..config import config +from ..utils.routes_common import ModelRouteUtils + +logger = logging.getLogger(__name__) + +class EmbeddingService(BaseModelService): + """Embedding-specific service implementation""" + + def __init__(self, scanner): + """Initialize Embedding service + + Args: + scanner: Embedding scanner instance + """ + super().__init__("embedding", scanner, EmbeddingMetadata) + + async def format_response(self, embedding_data: Dict) -> Dict: + """Format Embedding data for API response""" + return { + "model_name": embedding_data["model_name"], + "file_name": embedding_data["file_name"], + "preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")), + "preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0), + "base_model": embedding_data.get("base_model", ""), + "folder": embedding_data["folder"], + "sha256": embedding_data.get("sha256", ""), + "file_path": embedding_data["file_path"].replace(os.sep, "/"), + "file_size": embedding_data.get("size", 0), + "modified": embedding_data.get("modified", ""), + "tags": embedding_data.get("tags", []), + "modelDescription": embedding_data.get("modelDescription", ""), + "from_civitai": embedding_data.get("from_civitai", True), + "notes": embedding_data.get("notes", ""), + "model_type": embedding_data.get("model_type", "embedding"), + "favorite": embedding_data.get("favorite", False), + "civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {})) + } + + def find_duplicate_hashes(self) -> Dict: + """Find Embeddings with duplicate SHA256 hashes""" + return self.scanner._hash_index.get_duplicate_hashes() + + def find_duplicate_filenames(self) -> Dict: + """Find Embeddings with conflicting filenames""" + return self.scanner._hash_index.get_duplicate_filenames() diff --git a/py/services/model_service_factory.py b/py/services/model_service_factory.py index 6cc8a3a3..4d655eed 100644 --- a/py/services/model_service_factory.py +++ b/py/services/model_service_factory.py @@ -122,11 +122,13 @@ class ModelServiceFactory: def register_default_model_types(): - """Register the default model types (LoRA and Checkpoint)""" + """Register the default model types (LoRA, Checkpoint, and Embedding)""" from ..services.lora_service import LoraService from ..services.checkpoint_service import CheckpointService + from ..services.embedding_service import EmbeddingService from ..routes.lora_routes import LoraRoutes from ..routes.checkpoint_routes import CheckpointRoutes + from ..routes.embedding_routes import EmbeddingRoutes # Register LoRA model type ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes) @@ -134,4 +136,7 @@ def register_default_model_types(): # Register Checkpoint model type ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes) - logger.info("Registered default model types: lora, checkpoint") \ No newline at end of file + # Register Embedding model type + ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes) + + logger.info("Registered default model types: lora, checkpoint, embedding") \ No newline at end of file diff --git a/py/services/service_registry.py b/py/services/service_registry.py index 6cefb4d4..2cb102ae 100644 --- a/py/services/service_registry.py +++ b/py/services/service_registry.py @@ -35,6 +35,18 @@ class ServiceRegistry: """ return cls._services.get(name) + @classmethod + def get_service_sync(cls, name: str) -> Optional[Any]: + """Synchronously get a service instance by name + + Args: + name: Service name identifier + + Returns: + Service instance or None if not found + """ + return cls._services.get(name) + @classmethod def _get_lock(cls, name: str) -> asyncio.Lock: """Get or create a lock for a service @@ -174,6 +186,27 @@ class ServiceRegistry: logger.debug(f"Registered {service_name}") return ws_manager + @classmethod + async def get_embedding_scanner(cls): + """Get or create Embedding scanner instance""" + service_name = "embedding_scanner" + + if service_name in cls._services: + return cls._services[service_name] + + async with cls._get_lock(service_name): + # Double-check after acquiring lock + if service_name in cls._services: + return cls._services[service_name] + + # Import here to avoid circular imports + from .embedding_scanner import EmbeddingScanner + + scanner = await EmbeddingScanner.get_instance() + cls._services[service_name] = scanner + logger.debug(f"Created and registered {service_name}") + return scanner + @classmethod def clear_services(cls): """Clear all registered services - mainly for testing""" diff --git a/py/utils/metadata_manager.py b/py/utils/metadata_manager.py index 694a4c0c..15ab4b1f 100644 --- a/py/utils/metadata_manager.py +++ b/py/utils/metadata_manager.py @@ -206,6 +206,20 @@ class MetadataManager: model_type="checkpoint", from_civitai=True ) + elif model_class.__name__ == "EmbeddingMetadata": + metadata = model_class( + file_name=base_name, + model_name=base_name, + file_path=normalize_path(file_path), + size=os.path.getsize(real_path), + modified=datetime.now().timestamp(), + sha256=sha256, + base_model="Unknown", + preview_url=normalize_path(preview_url), + tags=[], + modelDescription="", + from_civitai=True + ) else: # Default to LoraMetadata metadata = model_class( file_name=base_name, diff --git a/py/utils/models.py b/py/utils/models.py index 3947e671..ac2650d7 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -123,7 +123,7 @@ class LoraMetadata(BaseModelMetadata): @dataclass class CheckpointMetadata(BaseModelMetadata): """Represents the metadata structure for a Checkpoint model""" - model_type: str = "checkpoint" # Model type (checkpoint, inpainting, etc.) + model_type: str = "checkpoint" # Model type (checkpoint, diffusion_model, etc.) @classmethod def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'CheckpointMetadata': @@ -158,3 +158,41 @@ class CheckpointMetadata(BaseModelMetadata): modelDescription=description ) +@dataclass +class EmbeddingMetadata(BaseModelMetadata): + """Represents the metadata structure for an Embedding model""" + model_type: str = "embedding" # Model type (embedding, textual_inversion, etc.) + + @classmethod + def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'EmbeddingMetadata': + """Create EmbeddingMetadata instance from Civitai version info""" + file_name = file_info['name'] + base_model = determine_base_model(version_info.get('baseModel', '')) + model_type = version_info.get('type', 'embedding') + + # Extract tags and description if available + tags = [] + description = "" + if 'model' in version_info: + if 'tags' in version_info['model']: + tags = version_info['model']['tags'] + if 'description' in version_info['model']: + description = version_info['model']['description'] + + return cls( + file_name=os.path.splitext(file_name)[0], + model_name=version_info.get('model').get('name', os.path.splitext(file_name)[0]), + file_path=save_path.replace(os.sep, '/'), + size=file_info.get('sizeKB', 0) * 1024, + modified=datetime.now().timestamp(), + sha256=file_info['hashes'].get('SHA256', '').lower(), + base_model=base_model, + preview_url=None, # Will be updated after preview download + preview_nsfw_level=0, + from_civitai=True, + civitai=version_info, + model_type=model_type, + tags=tags, + modelDescription=description + ) + diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index 99f679cd..40b02cb5 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -329,8 +329,6 @@ class ModelRouteUtils: # Update hash index if available if hasattr(scanner, '_hash_index') and scanner._hash_index: scanner._hash_index.remove_by_path(file_path) - - await scanner._save_cache_to_disk() return web.json_response({ 'success': True, @@ -553,8 +551,6 @@ class ModelRouteUtils: # Add to excluded models list scanner._excluded_models.append(file_path) - - await scanner._save_cache_to_disk() return web.json_response({ 'success': True, @@ -1038,6 +1034,7 @@ class ModelRouteUtils: return web.json_response({ 'success': True, 'new_file_path': new_file_path, + 'new_preview_path': config.get_preview_static_url(new_preview), 'renamed_files': renamed_files, 'reload_required': False }) diff --git a/standalone.py b/standalone.py index 8890e05f..d55f4c31 100644 --- a/standalone.py +++ b/standalone.py @@ -279,23 +279,50 @@ class StandaloneLoraManager(LoraManager): # Record route mapping config.add_route_mapping(real_root, preview_path) added_targets.add(os.path.normpath(real_root)) + + # Add static routes for each embedding root + for idx, root in enumerate(getattr(config, "embeddings_roots", []), start=1): + if not os.path.exists(root): + logger.warning(f"Embedding root path does not exist: {root}") + continue + + preview_path = f'/embeddings_static/root{idx}/preview' + + real_root = root + for target, link in config._path_mappings.items(): + if os.path.normpath(link) == os.path.normpath(root): + real_root = target + break + + display_root = real_root.replace('\\', '/') + app.router.add_static(preview_path, real_root) + logger.info(f"Added static route {preview_path} -> {display_root}") + + config.add_route_mapping(real_root, preview_path) + added_targets.add(os.path.normpath(real_root)) # Add static routes for symlink target paths that aren't already covered link_idx = { 'lora': 1, - 'checkpoint': 1 + 'checkpoint': 1, + 'embedding': 1 } for target_path, link_path in config._path_mappings.items(): norm_target = os.path.normpath(target_path) if norm_target not in added_targets: - # Determine if this is a checkpoint or lora link based on path + # Determine if this is a checkpoint, lora, or embedding link based on path is_checkpoint = any(os.path.normpath(cp_root) in os.path.normpath(link_path) for cp_root in config.base_models_roots) is_checkpoint = is_checkpoint or any(os.path.normpath(cp_root) in norm_target for cp_root in config.base_models_roots) - + is_embedding = any(os.path.normpath(emb_root) in os.path.normpath(link_path) for emb_root in getattr(config, "embeddings_roots", [])) + is_embedding = is_embedding or any(os.path.normpath(emb_root) in norm_target for emb_root in getattr(config, "embeddings_roots", [])) + if is_checkpoint: route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview' link_idx["checkpoint"] += 1 + elif is_embedding: + route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview' + link_idx["embedding"] += 1 else: route_path = f'/loras_static/link_{link_idx["lora"]}/preview' link_idx["lora"] += 1 diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css index 18f04001..40ec2bf2 100644 --- a/static/css/components/bulk.css +++ b/static/css/components/bulk.css @@ -73,12 +73,12 @@ } /* Style for selected cards */ -.lora-card.selected { +.model-card.selected { box-shadow: 0 0 0 2px var(--lora-accent); position: relative; } -.lora-card.selected::after { +.model-card.selected::after { content: "✓"; position: absolute; top: 10px; diff --git a/static/css/components/card.css b/static/css/components/card.css index acaeb5b6..1137db35 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -14,7 +14,7 @@ box-sizing: border-box; /* Include padding in width calculation */ } -.lora-card { +.model-card { background: var(--lora-surface); border: 1px solid var(--lora-border); border-radius: var(--border-radius-base); @@ -30,12 +30,12 @@ overflow: hidden; } -.lora-card:hover { +.model-card:hover { transform: translateY(-2px); background: oklch(100% 0 0 / 0.6); } -.lora-card:focus-visible { +.model-card:focus-visible { outline: 2px solid var(--lora-accent); outline-offset: 2px; } @@ -47,7 +47,7 @@ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); } - .lora-card { + .model-card { max-width: 270px; } } @@ -59,7 +59,7 @@ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } - .lora-card { + .model-card { max-width: 280px; } } @@ -70,7 +70,7 @@ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } - .lora-card { + .model-card { max-width: 240px; } } @@ -259,8 +259,8 @@ transition: opacity 0.2s ease; } -.hover-reveal .lora-card:hover .card-header, -.hover-reveal .lora-card:hover .card-footer { +.hover-reveal .model-card:hover .card-header, +.hover-reveal .model-card:hover .card-footer { opacity: 1; } @@ -345,7 +345,7 @@ grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */ } - .lora-card { + .model-card { max-width: 100%; /* Allow cards to fill available space on mobile */ } } @@ -425,8 +425,8 @@ } /* Prevent text selection on cards and interactive elements */ -.lora-card, -.lora-card *, +.model-card, +.model-card *, .card-actions, .card-actions i, .toggle-blur-btn, @@ -510,7 +510,7 @@ } } -/* Add after the existing .lora-card:hover styles */ +/* Add after the existing .model-card:hover styles */ @keyframes update-pulse { 0% { box-shadow: 0 0 0 0 var(--lora-accent-transparent); } @@ -523,7 +523,7 @@ --lora-accent-transparent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.6); } - .lora-card.updated { + .model-card.updated { animation: update-pulse 1.2s ease-out; } diff --git a/static/css/components/duplicates.css b/static/css/components/duplicates.css index 74f58dcf..d521b3c4 100644 --- a/static/css/components/duplicates.css +++ b/static/css/components/duplicates.css @@ -195,7 +195,7 @@ } /* Make cards in duplicate groups have consistent width */ -.card-group-container .lora-card { +.card-group-container .model-card { flex: 0 0 auto; width: 240px; margin: 0; @@ -241,26 +241,26 @@ } /* Duplicate card styling */ -.lora-card.duplicate { +.model-card.duplicate { position: relative; transition: all 0.2s ease; } -.lora-card.duplicate:hover { +.model-card.duplicate:hover { border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h); } -.lora-card.duplicate.latest { +.model-card.duplicate.latest { border-style: solid; border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); } -.lora-card.duplicate-selected { +.model-card.duplicate-selected { border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h)); box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); } -.lora-card .selector-checkbox { +.model-card .selector-checkbox { position: absolute; top: 10px; right: 10px; @@ -271,7 +271,7 @@ } /* Latest indicator */ -.lora-card.duplicate.latest::after { +.model-card.duplicate.latest::after { content: "Latest"; position: absolute; top: 10px; @@ -365,13 +365,13 @@ } /* Hash Mismatch Styling */ -.lora-card.duplicate.hash-mismatch { +.model-card.duplicate.hash-mismatch { border: 2px dashed oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); opacity: 0.85; position: relative; } -.lora-card.duplicate.hash-mismatch::before { +.model-card.duplicate.hash-mismatch::before { content: ""; position: absolute; top: 0; @@ -389,7 +389,7 @@ pointer-events: none; } -.lora-card.duplicate.hash-mismatch .card-preview { +.model-card.duplicate.hash-mismatch .card-preview { filter: grayscale(20%); } @@ -407,7 +407,7 @@ } /* Disabled checkbox style */ -.lora-card.duplicate.hash-mismatch .selector-checkbox { +.model-card.duplicate.hash-mismatch .selector-checkbox { opacity: 0.5; cursor: not-allowed; } diff --git a/static/css/components/header.css b/static/css/components/header.css index 0eb2237a..b04e1cea 100644 --- a/static/css/components/header.css +++ b/static/css/components/header.css @@ -31,7 +31,7 @@ align-items: center; text-decoration: none; color: var(--text-color); - gap: 8px; + gap: 2px; } .app-logo { diff --git a/static/css/components/loading.css b/static/css/components/loading.css index e118c4a9..44e29b24 100644 --- a/static/css/components/loading.css +++ b/static/css/components/loading.css @@ -109,7 +109,7 @@ } @media (prefers-reduced-motion: reduce) { - .lora-card, + .model-card, .progress-bar, .current-item-bar { transition: none; diff --git a/static/css/components/modal.css b/static/css/components/modal.css deleted file mode 100644 index 031ed917..00000000 --- a/static/css/components/modal.css +++ /dev/null @@ -1,1369 +0,0 @@ -/* 修改 modal 基础样式 */ -.modal { - display: none; - position: fixed; - top: 48px; /* Start below the header */ - left: 0; - width: 100%; - height: calc(100% - 48px); /* Adjust height to exclude header */ - background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */ - z-index: var(--z-modal); - overflow: auto; /* Change from hidden to auto to allow scrolling */ -} - -/* 当模态窗口打开时,禁止body滚动 */ -body.modal-open { - position: fixed; - width: 100%; - padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */ -} - -/* 修改 modal-content 样式 */ -.modal-content { - position: relative; - max-width: 800px; - height: auto; - max-height: calc(90vh - 48px); /* Adjust to account for header height */ - margin: 1rem auto; /* Keep reduced top margin */ - background: var(--lora-surface); - border-radius: var(--border-radius-base); - padding: var(--space-3); - border: 1px solid var(--lora-border); - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06), - 0 10px 15px -3px rgba(0, 0, 0, 0.05); - overflow-y: auto; - overflow-x: hidden; /* 防止水平滚动条 */ -} - -/* 当 modal 打开时锁定 body */ -body.modal-open { - overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ - padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */ -} - -/* Delete Modal specific styles */ - -.delete-message { - color: var(--text-color); - margin: var(--space-2) 0; -} - -/* Update delete modal styles */ -.delete-modal { - display: none; /* Set initial display to none */ - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - z-index: var(--z-overlay); -} - -/* Add new style for when modal is shown */ -.delete-modal.show { - display: flex; - align-items: center; - justify-content: center; -} - -.delete-modal-content { - max-width: 500px; - width: 90%; - text-align: center; - margin: 0 auto; - position: relative; - animation: modalFadeIn 0.2s ease-out; -} - -.delete-model-info, -.exclude-model-info { - /* Update info display styling */ - background: var(--lora-surface); - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - margin: var(--space-2) 0; - color: var(--text-color); - word-break: break-all; - text-align: left; - line-height: 1.5; -} - -@keyframes modalFadeIn { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.modal-actions { - display: flex; - gap: var(--space-2); - justify-content: center; - margin-top: var(--space-3); -} - -.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn { - padding: 8px var(--space-2); - border-radius: 6px; - border: none; - cursor: pointer; - font-weight: 500; - min-width: 100px; -} - -.cancel-btn { - background: var(--lora-surface); - border: 1px solid var(--lora-border); - color: var(--text-color); -} - -.delete-btn { - background: var(--lora-error); - color: white; -} - -/* Style for exclude button - different from delete button */ -.exclude-btn, .confirm-btn { - background: var(--lora-accent, #4f46e5); - color: white; -} - -.cancel-btn:hover { - background: var(--lora-border); -} - -.delete-btn:hover { - opacity: 0.9; -} - -.exclude-btn:hover, .confirm-btn:hover { - opacity: 0.9; - background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%); -} - -.modal-content h2 { - color: var(--text-color); - margin-bottom: var(--space-1); - font-size: 1.5em; -} - -.close { - position: absolute; - top: var(--space-2); - right: var(--space-2); - background: transparent; - border: none; - color: var(--text-color); - font-size: 1.5em; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.2s; -} - -.close:hover { - opacity: 1; -} - -/* Update Modal specific styles */ -.update-actions { - display: flex; - flex-direction: column; - gap: var(--space-2); - align-items: stretch; - flex-wrap: nowrap; -} - -.update-link { - color: var(--lora-accent); - text-decoration: none; - display: flex; - align-items: center; - gap: 8px; - font-size: 0.95em; -} - -.update-link:hover { - text-decoration: underline; -} - -/* Update progress styles */ -.update-progress { - background: rgba(0, 0, 0, 0.03); - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - margin: var(--space-2) 0; -} - -[data-theme="dark"] .update-progress { - background: rgba(255, 255, 255, 0.03); -} - -.progress-info { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.progress-text { - font-size: 0.9em; - color: var(--text-color); - opacity: 0.8; -} - -.progress-bar { - width: 100%; - height: 8px; - background-color: rgba(0, 0, 0, 0.1); - border-radius: 4px; - overflow: hidden; -} - -[data-theme="dark"] .progress-bar { - background-color: rgba(255, 255, 255, 0.1); -} - -.progress-fill { - height: 100%; - background-color: var(--lora-accent); - width: 0%; - transition: width 0.3s ease; - border-radius: 4px; -} - -/* Update button states */ -#updateBtn { - min-width: 120px; -} - -#updateBtn.updating { - background-color: var(--lora-warning); - cursor: not-allowed; -} - -#updateBtn.success { - background-color: var(--lora-success); -} - -#updateBtn.error { - background-color: var(--lora-error); -} - -/* Settings styles */ -.settings-toggle { - width: 36px; - height: 36px; - border-radius: 50%; - background: var(--card-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; -} - -.settings-toggle:hover { - background: var(--lora-accent); - color: white; - transform: translateY(-2px); -} - -.settings-modal { - max-width: 650px; /* Further increased from 600px for more space */ -} - -/* Settings Links */ -.settings-links { - margin-top: var(--space-3); - padding-top: var(--space-2); - border-top: 1px solid var(--lora-border); - display: flex; - gap: var(--space-2); - justify-content: center; -} - -.settings-link { - width: 36px; - height: 36px; - border-radius: 50%; - background: var(--card-bg); - border: 1px solid var(--border-color); - color: var(--text-color); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; - text-decoration: none; - position: relative; -} - -.settings-link:hover { - background: var(--lora-accent); - color: white; - transform: translateY(-2px); -} - -.settings-link i { - font-size: 1.1em; -} - -/* Tooltip styles */ -.settings-link::after { - content: attr(title); - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 4px 8px; - border-radius: 4px; - font-size: 0.8em; - white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; - pointer-events: none; -} - -.settings-link:hover::after { - opacity: 1; - visibility: visible; -} - -/* Responsive adjustment */ -@media (max-width: 480px) { - .settings-links { - flex-wrap: wrap; - } -} - -/* API key input specific styles */ -.api-key-input { - width: 100%; /* Take full width of parent */ - position: relative; - display: flex; - align-items: center; -} - -.api-key-input input { - width: 100%; - padding: 6px 40px 6px 10px; /* Add left padding */ - height: 32px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var(--text-color); -} - -.api-key-input .toggle-visibility { - position: absolute; - right: 8px; - background: none; - border: none; - color: var(--text-color); - opacity: 0.6; - cursor: pointer; - padding: 4px 8px; -} - -.api-key-input .toggle-visibility:hover { - opacity: 1; -} - -.input-help { - font-size: 0.85em; - color: var(--text-color); - opacity: 0.7; - margin-top: 8px; /* Space between control and help */ - line-height: 1.4; - width: 100%; /* Full width */ -} - -/* 统一各个 section 的样式 */ -.support-section, -.changelog-section, -.update-info, -.info-item, -.path-preview { - background: rgba(0, 0, 0, 0.03); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: var(--border-radius-sm); - padding: var(--space-2); -} - -/* 深色主题统一样式 */ -[data-theme="dark"] .modal-content { - background: var(--lora-surface); - border: 1px solid var(--lora-border); -} - -[data-theme="dark"] .support-section, -[data-theme="dark"] .changelog-section, -[data-theme="dark"] .update-info, -[data-theme="dark"] .info-item, -[data-theme="dark"] .path-preview { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--lora-border); -} - -/* Settings Styles */ -.settings-section { - margin-top: var(--space-3); - border-top: 1px solid var(--lora-border); - padding-top: var(--space-2); -} - -.settings-section h3 { - font-size: 1.1em; - margin-bottom: var(--space-2); - color: var(--text-color); - opacity: 0.9; -} - -.setting-item { - display: flex; - flex-direction: column; /* Changed to column for help text placement */ - margin-bottom: var(--space-3); /* Increased to provide more spacing between items */ - padding: var(--space-1); - border-radius: var(--border-radius-xs); -} - -.setting-item:hover { - background: rgba(0, 0, 0, 0.02); -} - -[data-theme="dark"] .setting-item:hover { - background: rgba(255, 255, 255, 0.05); -} - -/* Control row with label and input together */ -.setting-row { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - width: 100%; -} - -.setting-info { - margin-bottom: 0; - width: 35%; /* Increased from 30% to prevent wrapping */ - flex-shrink: 0; /* Prevent shrinking */ -} - -.setting-info label { - display: block; - font-weight: 500; - margin-bottom: 0; - white-space: nowrap; /* Prevent label wrapping */ -} - -.setting-control { - width: 60%; /* Decreased slightly from 65% */ - margin-bottom: 0; - display: flex; - justify-content: flex-end; /* Right-align all controls */ -} - -/* Select Control Styles */ -.select-control { - width: 100%; - display: flex; - justify-content: flex-end; -} - -.select-control select { - width: 100%; - max-width: 100%; /* Increased from 200px */ - 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; - height: 32px; -} - -/* Fix dark theme select dropdown text color */ -[data-theme="dark"] .select-control select { - background-color: rgba(30, 30, 30, 0.9); - color: var(--text-color); -} - -[data-theme="dark"] .select-control select option { - background-color: #2d2d2d; - color: var(--text-color); -} - -.select-control select:focus { - border-color: var(--lora-accent); - outline: none; -} - -/* Toggle Switch */ -.toggle-switch { - position: relative; - display: inline-block; - width: 50px; - height: 24px; - cursor: pointer; - margin-left: auto; /* Push to right side */ -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--border-color); - transition: .3s; - border-radius: 24px; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: .3s; - border-radius: 50%; -} - -input:checked + .toggle-slider { - background-color: var(--lora-accent); -} - -input:checked + .toggle-slider:before { - transform: translateX(26px); -} - -.toggle-label { - margin-left: 60px; - line-height: 24px; -} - -/* Add small animation for the toggle */ -.toggle-slider:active:before { - width: 22px; -} - -/* Blur effect for NSFW content */ -.nsfw-blur { - filter: blur(12px); - transition: filter 0.3s ease; -} - -.nsfw-blur:hover { - filter: blur(8px); -} - -/* Example Images Settings Styles */ -.download-buttons { - justify-content: flex-start; - gap: var(--space-2); -} - -.primary-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background-color: var(--lora-accent); - color: var(--lora-text); - border: none; - border-radius: var(--border-radius-sm); - cursor: pointer; - transition: background-color 0.2s; - font-size: 0.95em; -} - -.primary-btn:hover { - background-color: oklch(from var(--lora-accent) l c h / 85%); - color: var(--lora-text); -} - -/* Secondary button styles */ -.secondary-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background-color: var(--card-bg); - color: var (--text-color); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - cursor: pointer; - transition: all 0.2s; - font-size: 0.95em; -} - -.secondary-btn:hover { - background-color: var(--border-color); - color: var(--text-color); -} - -/* Disabled button styles */ -.primary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - background-color: var(--lora-accent); - color: var(--lora-text); - pointer-events: none; -} - -.secondary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -.restart-required-icon { - color: var(--lora-warning); - margin-left: 5px; - font-size: 0.85em; - vertical-align: text-bottom; -} - -/* Dark theme specific button adjustments */ -[data-theme="dark"] .primary-btn:hover { - background-color: oklch(from var(--lora-accent) l c h / 75%); -} - -[data-theme="dark"] .secondary-btn { - background-color: var(--lora-surface); -} - -[data-theme="dark"] .secondary-btn:hover { - background-color: oklch(35% 0.02 256 / 0.98); -} - -.primary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.path-control { - display: flex; - gap: 8px; - align-items: center; - width: 100%; -} - -.path-control input[type="text"] { - flex: 1; - 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; - height: 32px; -} - -.primary-btn.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Add styles for delete preview image */ -.delete-preview { - max-width: 150px; - margin: 0 auto var(--space-2); - overflow: hidden; -} - -.delete-preview img { - width: 100%; - height: auto; - max-height: 150px; - object-fit: contain; - border-radius: var(--border-radius-sm); -} - -.delete-info { - text-align: center; -} - -.delete-info h3 { - margin-bottom: var(--space-1); - word-break: break-word; -} - -.delete-info p { - margin: var(--space-1) 0; - font-size: 0.9em; - opacity: 0.8; -} - -.delete-note { - font-size: 0.85em; - color: var(--text-color); - opacity: 0.7; - font-style: italic; - margin-top: var(--space-1); - text-align: center; -} - -/* Add styles for markdown elements in changelog */ -.changelog-item ul { - padding-left: 20px; - margin-top: 8px; -} - -.changelog-item li { - margin-bottom: 6px; - line-height: 1.4; -} - -.changelog-item strong { - font-weight: 600; -} - -.changelog-item em { - font-style: italic; -} - -.changelog-item code { - background: rgba(0, 0, 0, 0.05); - padding: 2px 4px; - border-radius: 3px; - font-family: monospace; - font-size: 0.9em; -} - -[data-theme="dark"] .changelog-item code { - background: rgba(255, 255, 255, 0.1); -} - -.changelog-item a { - color: var(--lora-accent); - text-decoration: none; -} - -.changelog-item a:hover { - text-decoration: underline; -} - -/* Add warning text style for settings */ -.warning-text { - color: var(--lora-warning, #e67e22); - font-weight: 500; -} - -[data-theme="dark"] .warning-text { - color: var(--lora-warning, #f39c12); -} - -/* Add styles for list description */ -.list-description { - margin: 8px 0; - padding-left: 20px; - font-size: 0.9em; -} - -.list-description li { - margin-bottom: 4px; -} - -/* Help Modal styles */ -.help-modal { - max-width: 850px; -} - -.help-header { - display: flex; - align-items: center; - margin-bottom: var(--space-2); -} - -.modal-help-icon { - font-size: 24px; - color: var(--lora-accent); - margin-right: var(--space-2); - vertical-align: text-bottom; -} - -/* Tab navigation styles */ -.help-tabs { - display: flex; - border-bottom: 1px solid var(--lora-border); - margin-bottom: var(--space-2); - gap: 8px; -} - -.tab-btn { - padding: 8px 16px; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--text-color); - cursor: pointer; - font-weight: 500; - transition: all 0.2s; - opacity: 0.7; -} - -.tab-btn:hover { - background-color: rgba(0, 0, 0, 0.05); - opacity: 0.9; -} - -.tab-btn.active { - color: var(--lora-accent); - border-bottom: 2px solid var(--lora-accent); - opacity: 1; -} - -/* Add styles for tab with new content indicator */ -.tab-btn.has-new-content { - position: relative; -} - -.tab-btn.has-new-content::after { - content: ""; - position: absolute; - top: 4px; - right: 4px; - width: 8px; - height: 8px; - background-color: var(--lora-accent); - border-radius: 50%; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.7; transform: scale(1.1); } - 100% { opacity: 1; transform: scale(1); } -} - -/* Tab content styles */ -.help-content { - padding: var(--space-1) 0; - overflow-y: auto; -} - -.tab-pane { - display: none; -} - -.tab-pane.active { - display: block; -} - -.help-text { - margin: var(--space-2) 0; -} - -.help-text ul { - padding-left: 20px; - margin-top: 8px; -} - -.help-text li { - margin-bottom: 8px; -} - -/* Documentation link styles */ -.docs-section { - margin-bottom: var(--space-3); -} - -.docs-section h4 { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: var(--space-1); -} - -.docs-links { - list-style-type: none; - padding-left: var(--space-3); -} - -.docs-links li { - margin-bottom: var(--space-1); - position: relative; -} - -.docs-links li:before { - content: "•"; - position: absolute; - left: -15px; - color: var(--lora-accent); -} - -.docs-links a { - color: var(--lora-accent); - text-decoration: none; - transition: color 0.2s; -} - -.docs-links a:hover { - text-decoration: underline; -} - -/* New content badge styles */ -.new-content-badge { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.7em; - font-weight: 600; - background-color: var(--lora-accent); - color: var(--lora-text); - padding: 2px 6px; - border-radius: 10px; - margin-left: 8px; - vertical-align: middle; - animation: fadeIn 0.5s ease-in-out; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.new-content-badge.inline { - font-size: 0.65em; - padding: 1px 4px; - margin-left: 6px; - border-radius: 8px; -} - -/* Dark theme adjustments for new content badge */ -[data-theme="dark"] .new-content-badge { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); -} - -/* Update video list styles */ -.video-list { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.video-item { - display: flex; - flex-direction: column; -} - -.video-info { - padding: var(--space-1); -} - -.video-info h4 { - margin-bottom: var(--space-1); -} - -.video-info p { - font-size: 0.9em; - opacity: 0.8; -} - -/* Dark theme adjustments */ -[data-theme="dark"] .tab-btn:hover { - background-color: rgba(255, 255, 255, 0.05); -} - -/* Update date badge styles */ -.update-date-badge { - display: inline-flex; - align-items: center; - font-size: 0.75em; - font-weight: 500; - background-color: var(--lora-accent); - color: var(--lora-text); - padding: 4px 8px; - border-radius: 12px; - margin-left: 10px; - vertical-align: middle; - animation: fadeIn 0.5s ease-in-out; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.update-date-badge i { - margin-right: 5px; - font-size: 0.9em; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Dark theme adjustments */ -[data-theme="dark"] .update-date-badge { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -/* Re-link to Civitai Modal styles */ -.warning-box { - background-color: rgba(255, 193, 7, 0.1); - border: 1px solid rgba(255, 193, 7, 0.5); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - margin-bottom: var(--space-3); -} - -.warning-box i { - color: var(--lora-warning); - margin-right: var(--space-1); -} - -.warning-box ul { - padding-left: 20px; - margin: var(--space-1) 0; -} - -.warning-box li { - margin-bottom: 4px; -} - -.input-group { - display: flex; - flex-direction: column; - margin-bottom: var(--space-2); -} - -.input-group label { - margin-bottom: var(--space-1); - font-weight: 500; -} - -.input-group input { - padding: 8px 12px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - background-color: var(--lora-surface); - color: var(--text-color); -} - -.input-error { - color: var(--lora-error); - font-size: 0.9em; - min-height: 20px; - margin-top: 4px; -} - -[data-theme="dark"] .warning-box { - background-color: rgba(255, 193, 7, 0.05); - border-color: rgba(255, 193, 7, 0.3); -} - -/* Privacy-friendly video embed styles */ -.video-container { - position: relative; - width: 100%; - padding-bottom: 56.25%; /* 16:9 aspect ratio */ - height: 0; - margin-bottom: var(--space-2); - border-radius: var(--border-radius-sm); - overflow: hidden; - background-color: rgba(0, 0, 0, 0.05); -} - -.video-thumbnail { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.video-thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; - transition: filter 0.2s ease; -} - -.video-play-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - transition: opacity 0.2s ease; -} - -/* External link button styles */ -.external-link-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 20px; - border-radius: var(--border-radius-sm); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - background-color: var(--lora-accent); - color: white; - text-decoration: none; - border: none; -} - -.external-link-btn:hover { - background-color: oklch(from var(--lora-accent) l c h / 85%); -} - -.video-thumbnail i { - font-size: 1.2em; -} - -/* Smaller video container for the updates tab */ -.video-item .video-container { - padding-bottom: 40%; /* Shorter height for the playlist */ -} - -/* Dark theme adjustments */ -[data-theme="dark"] .video-container { - background-color: rgba(255, 255, 255, 0.03); -} - -/* Example Access Modal */ -.example-access-modal { - max-width: 550px; - text-align: center; -} - -.example-access-options { - display: flex; - flex-direction: column; - gap: var(--space-2); - margin: var(--space-3) 0; -} - -.example-option-btn { - display: flex; - flex-direction: column; - align-items: center; - padding: var(--space-2); - border-radius: var(--border-radius-sm); - border: 1px solid var(--lora-border); - background-color: var(--lora-surface); - cursor: pointer; - transition: all 0.2s; -} - -.example-option-btn:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - border-color: var(--lora-accent); -} - -.example-option-btn i { - font-size: 2em; - margin-bottom: var(--space-1); - color: var(--lora-accent); -} - -.option-title { - font-weight: 500; - margin-bottom: 4px; - font-size: 1.1em; -} - -.option-desc { - font-size: 0.9em; - opacity: 0.8; -} - -.example-option-btn.disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.example-option-btn.disabled i { - color: var(--text-color); - opacity: 0.5; -} - -.modal-footer-note { - font-size: 0.9em; - opacity: 0.7; - margin-top: var(--space-2); - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -/* Dark theme adjustments */ -[data-theme="dark"] .example-option-btn:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); -} - -/* Path Template Settings Styles */ -.template-preview { - background: rgba(0, 0, 0, 0.03); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: var(--border-radius-xs); - padding: var(--space-1); - margin-top: 8px; - font-family: monospace; - font-size: 1.1em; - color: var(--lora-accent); - display: none; -} - -[data-theme="dark"] .template-preview { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--lora-border); -} - -.template-preview:before { - content: "Preview: "; - opacity: 0.7; - color: var(--text-color); - font-family: inherit; -} - -/* Base Model Mappings Styles - Updated to match other settings */ -.mappings-container { - border: 1px solid var(--lora-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - background: rgba(0, 0, 0, 0.02); - margin-top: 8px; /* Add consistent spacing */ -} - -[data-theme="dark"] .mappings-container { - background: rgba(255, 255, 255, 0.02); -} - -.add-mapping-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: var(--lora-accent); - color: white; - border: none; - border-radius: var(--border-radius-xs); - cursor: pointer; - font-size: 0.9em; - transition: all 0.2s; - height: 32px; /* Match other control heights */ -} - -.add-mapping-btn:hover { - background: oklch(from var(--lora-accent) l c h / 85%); -} - -.mapping-row { - margin-bottom: var(--space-2); -} - -.mapping-row:last-child { - margin-bottom: 0; -} - -.mapping-controls { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: var(--space-1); - align-items: center; -} - -.base-model-select, -.path-value-input { - 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.9em; - height: 32px; -} - -.path-value-input { - height: 18px; -} - -.base-model-select:focus, -.path-value-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); -} - -.remove-mapping-btn { - width: 32px; - height: 32px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--lora-error); - background: transparent; - color: var(--lora-error); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; -} - -.remove-mapping-btn:hover { - background: var(--lora-error); - color: white; -} - -.mapping-empty-state { - text-align: center; - padding: var(--space-3); - color: var(--text-color); - opacity: 0.6; - font-style: italic; -} - -/* Responsive adjustments for mapping controls */ -@media (max-width: 768px) { - .mapping-controls { - grid-template-columns: 1fr; - gap: 8px; - } - - .remove-mapping-btn { - width: 100%; - height: 36px; - justify-self: stretch; - } -} - -/* Dark theme specific adjustments */ -[data-theme="dark"] .base-model-select, -[data-theme="dark"] .path-value-input { - background-color: rgba(30, 30, 30, 0.9); -} - -[data-theme="dark"] .base-model-select option { - background-color: #2d2d2d; - color: var(--text-color); -} \ No newline at end of file diff --git a/static/css/components/modal/_base.css b/static/css/components/modal/_base.css new file mode 100644 index 00000000..cfc172f9 --- /dev/null +++ b/static/css/components/modal/_base.css @@ -0,0 +1,274 @@ +/* modal 基础样式 */ +.modal { + display: none; + position: fixed; + top: 48px; /* Start below the header */ + left: 0; + width: 100%; + height: calc(100% - 48px); /* Adjust height to exclude header */ + background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */ + z-index: var(--z-modal); + overflow: auto; /* Change from hidden to auto to allow scrolling */ +} + +/* 当模态窗口打开时,禁止body滚动 */ +body.modal-open { + position: fixed; + width: 100%; + padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */ +} + +/* modal-content 样式 */ +.modal-content { + position: relative; + max-width: 800px; + height: auto; + max-height: calc(90vh - 48px); /* Adjust to account for header height */ + margin: 1rem auto; /* Keep reduced top margin */ + background: var(--lora-surface); + border-radius: var(--border-radius-base); + padding: var(--space-3); + border: 1px solid var(--lora-border); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06), + 0 10px 15px -3px rgba(0, 0, 0, 0.05); + overflow-y: auto; + overflow-x: hidden; /* 防止水平滚动条 */ +} + +/* 当 modal 打开时锁定 body */ +body.modal-open { + overflow: hidden !important; /* 覆盖 base.css 中的 scroll */ + padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */ +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-actions { + display: flex; + gap: var(--space-2); + justify-content: center; + margin-top: var(--space-3); +} + +.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn { + padding: 8px var(--space-2); + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 500; + min-width: 100px; +} + +.cancel-btn { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + color: var(--text-color); +} + +.delete-btn { + background: var(--lora-error); + color: white; +} + +/* Style for exclude button - different from delete button */ +.exclude-btn, .confirm-btn { + background: var(--lora-accent, #4f46e5); + color: white; +} + +.cancel-btn:hover { + background: var(--lora-border); +} + +.delete-btn:hover { + opacity: 0.9; +} + +.exclude-btn:hover, .confirm-btn:hover { + opacity: 0.9; + background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%); +} + +.modal-content h2 { + color: var(--text-color); + margin-bottom: var(--space-1); + font-size: 1.5em; +} + +.close { + position: absolute; + top: var(--space-2); + right: var(--space-2); + background: transparent; + border: none; + color: var(--text-color); + font-size: 1.5em; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; +} + +.close:hover { + opacity: 1; +} + +/* 统一各个 section 的样式 */ +.support-section, +.changelog-section, +.update-info, +.info-item, +.path-preview { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-sm); + padding: var(--space-2); +} + +/* 深色主题统一样式 */ +[data-theme="dark"] .modal-content { + background: var(--lora-surface); + border: 1px solid var(--lora-border); +} + +[data-theme="dark"] .support-section, +[data-theme="dark"] .changelog-section, +[data-theme="dark"] .update-info, +[data-theme="dark"] .info-item, +[data-theme="dark"] .path-preview { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.primary-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: var(--lora-accent); + color: var(--lora-text); + border: none; + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: background-color 0.2s; + font-size: 0.95em; +} + +.primary-btn:hover { + background-color: oklch(from var(--lora-accent) l c h / 85%); + color: var(--lora-text); +} + +/* Secondary button styles */ +.secondary-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: var(--card-bg); + color: var (--text-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: all 0.2s; + font-size: 0.95em; +} + +.secondary-btn:hover { + background-color: var(--border-color); + color: var(--text-color); +} + +/* Disabled button styles */ +.primary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--lora-accent); + color: var(--lora-text); + pointer-events: none; +} + +.secondary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.restart-required-icon { + color: var(--lora-warning); + margin-left: 5px; + font-size: 0.85em; + vertical-align: text-bottom; +} + +/* Dark theme specific button adjustments */ +[data-theme="dark"] .primary-btn:hover { + background-color: oklch(from var(--lora-accent) l c h / 75%); +} + +[data-theme="dark"] .secondary-btn { + background-color: var(--lora-surface); +} + +[data-theme="dark"] .secondary-btn:hover { + background-color: oklch(35% 0.02 256 / 0.98); +} + +.primary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.primary-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Add styles for delete preview image */ +.delete-preview { + max-width: 150px; + margin: 0 auto var(--space-2); + overflow: hidden; +} + +.delete-preview img { + width: 100%; + height: auto; + max-height: 150px; + object-fit: contain; + border-radius: var(--border-radius-sm); +} + +.delete-info { + text-align: center; +} + +.delete-info h3 { + margin-bottom: var(--space-1); + word-break: break-word; +} + +.delete-info p { + margin: var(--space-1) 0; + font-size: 0.9em; + opacity: 0.8; +} + +.delete-note { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + font-style: italic; + margin-top: var(--space-1); + text-align: center; +} \ No newline at end of file diff --git a/static/css/components/modal/delete-modal.css b/static/css/components/modal/delete-modal.css new file mode 100644 index 00000000..7a1334a4 --- /dev/null +++ b/static/css/components/modal/delete-modal.css @@ -0,0 +1,48 @@ +/* Delete Modal specific styles */ + +.delete-message { + color: var(--text-color); + margin: var(--space-2) 0; +} + +/* Update delete modal styles */ +.delete-modal { + display: none; /* Set initial display to none */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: var(--z-overlay); +} + +/* Add new style for when modal is shown */ +.delete-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.delete-modal-content { + max-width: 500px; + width: 90%; + text-align: center; + margin: 0 auto; + position: relative; + animation: modalFadeIn 0.2s ease-out; +} + +.delete-model-info, +.exclude-model-info { + /* Update info display styling */ + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin: var(--space-2) 0; + color: var(--text-color); + word-break: break-all; + text-align: left; + line-height: 1.5; +} \ No newline at end of file diff --git a/static/css/components/modal/example-access-modal.css b/static/css/components/modal/example-access-modal.css new file mode 100644 index 00000000..1e00bc28 --- /dev/null +++ b/static/css/components/modal/example-access-modal.css @@ -0,0 +1,72 @@ +/* Example Access Modal */ +.example-access-modal { + max-width: 550px; + text-align: center; +} + +.example-access-options { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: var(--space-3) 0; +} + +.example-option-btn { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-2); + border-radius: var(--border-radius-sm); + border: 1px solid var(--lora-border); + background-color: var(--lora-surface); + cursor: pointer; + transition: all 0.2s; +} + +.example-option-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-color: var(--lora-accent); +} + +.example-option-btn i { + font-size: 2em; + margin-bottom: var(--space-1); + color: var(--lora-accent); +} + +.option-title { + font-weight: 500; + margin-bottom: 4px; + font-size: 1.1em; +} + +.option-desc { + font-size: 0.9em; + opacity: 0.8; +} + +.example-option-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.example-option-btn.disabled i { + color: var(--text-color); + opacity: 0.5; +} + +.modal-footer-note { + font-size: 0.9em; + opacity: 0.7; + margin-top: var(--space-2); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +/* Dark theme adjustments */ +[data-theme="dark"] .example-option-btn:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} \ No newline at end of file diff --git a/static/css/components/modal/help-modal.css b/static/css/components/modal/help-modal.css new file mode 100644 index 00000000..6e0c4492 --- /dev/null +++ b/static/css/components/modal/help-modal.css @@ -0,0 +1,307 @@ +/* Help Modal styles */ +.help-modal { + max-width: 850px; +} + +.help-header { + display: flex; + align-items: center; + margin-bottom: var(--space-2); +} + +.modal-help-icon { + font-size: 24px; + color: var(--lora-accent); + margin-right: var(--space-2); + vertical-align: text-bottom; +} + +/* Tab navigation styles */ +.help-tabs { + display: flex; + border-bottom: 1px solid var(--lora-border); + margin-bottom: var(--space-2); + gap: 8px; +} + +.tab-btn { + padding: 8px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-color); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; + opacity: 0.7; +} + +.tab-btn:hover { + background-color: rgba(0, 0, 0, 0.05); + opacity: 0.9; +} + +.tab-btn.active { + color: var(--lora-accent); + border-bottom: 2px solid var(--lora-accent); + opacity: 1; +} + +/* Add styles for tab with new content indicator */ +.tab-btn.has-new-content { + position: relative; +} + +.tab-btn.has-new-content::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background-color: var(--lora-accent); + border-radius: 50%; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.1); } + 100% { opacity: 1; transform: scale(1); } +} + +/* Tab content styles */ +.help-content { + padding: var(--space-1) 0; + overflow-y: auto; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +.help-text { + margin: var(--space-2) 0; +} + +.help-text ul { + padding-left: 20px; + margin-top: 8px; +} + +.help-text li { + margin-bottom: 8px; +} + +/* Documentation link styles */ +.docs-section { + margin-bottom: var(--space-3); +} + +.docs-section h4 { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: var(--space-1); +} + +.docs-links { + list-style-type: none; + padding-left: var(--space-3); +} + +.docs-links li { + margin-bottom: var(--space-1); + position: relative; +} + +.docs-links li:before { + content: "•"; + position: absolute; + left: -15px; + color: var(--lora-accent); +} + +.docs-links a { + color: var(--lora-accent); + text-decoration: none; + transition: color 0.2s; +} + +.docs-links a:hover { + text-decoration: underline; +} + +/* New content badge styles */ +.new-content-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7em; + font-weight: 600; + background-color: var(--lora-accent); + color: var(--lora-text); + padding: 2px 6px; + border-radius: 10px; + margin-left: 8px; + vertical-align: middle; + animation: fadeIn 0.5s ease-in-out; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.new-content-badge.inline { + font-size: 0.65em; + padding: 1px 4px; + margin-left: 6px; + border-radius: 8px; +} + +/* Dark theme adjustments for new content badge */ +[data-theme="dark"] .new-content-badge { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +/* Update video list styles */ +.video-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.video-item { + display: flex; + flex-direction: column; +} + +.video-info { + padding: var(--space-1); +} + +.video-info h4 { + margin-bottom: var(--space-1); +} + +.video-info p { + font-size: 0.9em; + opacity: 0.8; +} + +/* Dark theme adjustments */ +[data-theme="dark"] .tab-btn:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +/* Update date badge styles */ +.update-date-badge { + display: inline-flex; + align-items: center; + font-size: 0.75em; + font-weight: 500; + background-color: var(--lora-accent); + color: var(--lora-text); + padding: 4px 8px; + border-radius: 12px; + margin-left: 10px; + vertical-align: middle; + animation: fadeIn 0.5s ease-in-out; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.update-date-badge i { + margin-right: 5px; + font-size: 0.9em; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Dark theme adjustments */ +[data-theme="dark"] .update-date-badge { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +/* Privacy-friendly video embed styles */ +.video-container { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + height: 0; + margin-bottom: var(--space-2); + border-radius: var(--border-radius-sm); + overflow: hidden; + background-color: rgba(0, 0, 0, 0.05); +} + +.video-thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.video-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + transition: filter 0.2s ease; +} + +.video-play-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + transition: opacity 0.2s ease; +} + +/* External link button styles */ +.external-link-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--border-radius-sm); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background-color: var(--lora-accent); + color: white; + text-decoration: none; + border: none; +} + +.external-link-btn:hover { + background-color: oklch(from var(--lora-accent) l c h / 85%); +} + +.video-thumbnail i { + font-size: 1.2em; +} + +/* Smaller video container for the updates tab */ +.video-item .video-container { + padding-bottom: 40%; /* Shorter height for the playlist */ +} + +/* Dark theme adjustments */ +[data-theme="dark"] .video-container { + background-color: rgba(255, 255, 255, 0.03); +} \ No newline at end of file diff --git a/static/css/components/modal/relink-civitai-modal.css b/static/css/components/modal/relink-civitai-modal.css new file mode 100644 index 00000000..e19515fe --- /dev/null +++ b/static/css/components/modal/relink-civitai-modal.css @@ -0,0 +1,53 @@ +/* Re-link to Civitai Modal styles */ +.warning-box { + background-color: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.5); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin-bottom: var(--space-3); +} + +.warning-box i { + color: var(--lora-warning); + margin-right: var(--space-1); +} + +.warning-box ul { + padding-left: 20px; + margin: var(--space-1) 0; +} + +.warning-box li { + margin-bottom: 4px; +} + +.input-group { + display: flex; + flex-direction: column; + margin-bottom: var(--space-2); +} + +.input-group label { + margin-bottom: var(--space-1); + font-weight: 500; +} + +.input-group input { + padding: 8px 12px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); +} + +.input-error { + color: var(--lora-error); + font-size: 0.9em; + min-height: 20px; + margin-top: 4px; +} + +[data-theme="dark"] .warning-box { + background-color: rgba(255, 193, 7, 0.05); + border-color: rgba(255, 193, 7, 0.3); +} \ No newline at end of file diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css new file mode 100644 index 00000000..7d67e79e --- /dev/null +++ b/static/css/components/modal/settings-modal.css @@ -0,0 +1,485 @@ +/* Settings styles */ +.settings-toggle { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.settings-toggle:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-2px); +} + +.settings-modal { + max-width: 650px; /* Further increased from 600px for more space */ +} + +/* Settings Links */ +.settings-links { + margin-top: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid var(--lora-border); + display: flex; + gap: var(--space-2); + justify-content: center; +} + +.settings-link { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + position: relative; +} + +.settings-link:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-2px); +} + +.settings-link i { + font-size: 1.1em; +} + +/* Tooltip styles */ +.settings-link::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8em; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; + pointer-events: none; +} + +.settings-link:hover::after { + opacity: 1; + visibility: visible; +} + +/* Responsive adjustment */ +@media (max-width: 480px) { + .settings-links { + flex-wrap: wrap; + } +} + +/* API key input specific styles */ +.api-key-input { + width: 100%; /* Take full width of parent */ + position: relative; + display: flex; + align-items: center; +} + +.api-key-input input { + width: 100%; + padding: 6px 40px 6px 10px; /* Add left padding */ + height: 32px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); +} + +.api-key-input .toggle-visibility { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-color); + opacity: 0.6; + cursor: pointer; + padding: 4px 8px; +} + +.api-key-input .toggle-visibility:hover { + opacity: 1; +} + +.input-help { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + margin-top: 8px; /* Space between control and help */ + line-height: 1.4; + width: 100%; /* Full width */ +} + +/* Settings Styles */ +.settings-section { + margin-top: var(--space-3); + border-top: 1px solid var(--lora-border); + padding-top: var(--space-2); +} + +.settings-section h3 { + font-size: 1.1em; + margin-bottom: var(--space-2); + color: var(--text-color); + opacity: 0.9; +} + +.setting-item { + display: flex; + flex-direction: column; /* Changed to column for help text placement */ + margin-bottom: var(--space-3); /* Increased to provide more spacing between items */ + padding: var(--space-1); + border-radius: var(--border-radius-xs); +} + +.setting-item:hover { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="dark"] .setting-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Control row with label and input together */ +.setting-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.setting-info { + margin-bottom: 0; + width: 35%; /* Increased from 30% to prevent wrapping */ + flex-shrink: 0; /* Prevent shrinking */ +} + +.setting-info label { + display: block; + font-weight: 500; + margin-bottom: 0; + white-space: nowrap; /* Prevent label wrapping */ +} + +.setting-control { + width: 60%; /* Decreased slightly from 65% */ + margin-bottom: 0; + display: flex; + justify-content: flex-end; /* Right-align all controls */ +} + +/* Select Control Styles */ +.select-control { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.select-control select { + width: 100%; + max-width: 100%; /* Increased from 200px */ + 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; + height: 32px; +} + +/* Fix dark theme select dropdown text color */ +[data-theme="dark"] .select-control select { + background-color: rgba(30, 30, 30, 0.9); + color: var(--text-color); +} + +[data-theme="dark"] .select-control select option { + background-color: #2d2d2d; + color: var(--text-color); +} + +.select-control select:focus { + border-color: var(--lora-accent); + outline: none; +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; + cursor: pointer; + margin-left: auto; /* Push to right side */ +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: .3s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .3s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--lora-accent); +} + +input:checked + .toggle-slider:before { + transform: translateX(26px); +} + +.toggle-label { + margin-left: 60px; + line-height: 24px; +} + +/* Add small animation for the toggle */ +.toggle-slider:active:before { + width: 22px; +} + +/* Blur effect for NSFW content */ +.nsfw-blur { + filter: blur(12px); + transition: filter 0.3s ease; +} + +.nsfw-blur:hover { + filter: blur(8px); +} + +/* Example Images Settings Styles */ +.download-buttons { + justify-content: flex-start; + gap: var(--space-2); +} + +.path-control { + display: flex; + gap: 8px; + align-items: center; + width: 100%; +} + +.path-control input[type="text"] { + flex: 1; + 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; + height: 32px; +} + +/* Add warning text style for settings */ +.warning-text { + color: var(--lora-warning, #e67e22); + font-weight: 500; +} + +[data-theme="dark"] .warning-text { + color: var(--lora-warning, #f39c12); +} + +/* Add styles for list description */ +.list-description { + margin: 8px 0; + padding-left: 20px; + font-size: 0.9em; +} + +.list-description li { + margin-bottom: 4px; +} + +/* Path Template Settings Styles */ +.template-preview { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + margin-top: 8px; + font-family: monospace; + font-size: 1.1em; + color: var(--lora-accent); + display: none; +} + +[data-theme="dark"] .template-preview { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.template-preview:before { + content: "Preview: "; + opacity: 0.7; + color: var(--text-color); + font-family: inherit; +} + +/* Base Model Mappings Styles - Updated to match other settings */ +.mappings-container { + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + background: rgba(0, 0, 0, 0.02); + margin-top: 8px; /* Add consistent spacing */ +} + +[data-theme="dark"] .mappings-container { + background: rgba(255, 255, 255, 0.02); +} + +.add-mapping-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--lora-accent); + color: white; + border: none; + border-radius: var(--border-radius-xs); + cursor: pointer; + font-size: 0.9em; + transition: all 0.2s; + height: 32px; /* Match other control heights */ +} + +.add-mapping-btn:hover { + background: oklch(from var(--lora-accent) l c h / 85%); +} + +.mapping-row { + margin-bottom: var(--space-2); +} + +.mapping-row:last-child { + margin-bottom: 0; +} + +.mapping-controls { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-1); + align-items: center; +} + +.base-model-select, +.path-value-input { + 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.9em; + height: 32px; +} + +.path-value-input { + height: 18px; +} + +.base-model-select:focus, +.path-value-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); +} + +.remove-mapping-btn { + width: 32px; + height: 32px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--lora-error); + background: transparent; + color: var(--lora-error); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.remove-mapping-btn:hover { + background: var(--lora-error); + color: white; +} + +.mapping-empty-state { + text-align: center; + padding: var(--space-3); + color: var(--text-color); + opacity: 0.6; + font-style: italic; +} + +/* Responsive adjustments for mapping controls */ +@media (max-width: 768px) { + .mapping-controls { + grid-template-columns: 1fr; + gap: 8px; + } + + .remove-mapping-btn { + width: 100%; + height: 36px; + justify-self: stretch; + } +} + +/* Dark theme specific adjustments */ +[data-theme="dark"] .base-model-select, +[data-theme="dark"] .path-value-input { + background-color: rgba(30, 30, 30, 0.9); +} + +[data-theme="dark"] .base-model-select option { + background-color: #2d2d2d; + color: var(--text-color); +} \ No newline at end of file diff --git a/static/css/components/support-modal.css b/static/css/components/modal/support-modal.css similarity index 100% rename from static/css/components/support-modal.css rename to static/css/components/modal/support-modal.css diff --git a/static/css/components/modal/update-modal.css b/static/css/components/modal/update-modal.css new file mode 100644 index 00000000..7645b751 --- /dev/null +++ b/static/css/components/modal/update-modal.css @@ -0,0 +1,124 @@ +/* Update Modal specific styles */ +.update-actions { + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: stretch; + flex-wrap: nowrap; +} + +.update-link { + color: var(--lora-accent); + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95em; +} + +.update-link:hover { + text-decoration: underline; +} + +/* Update progress styles */ +.update-progress { + background: rgba(0, 0, 0, 0.03); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin: var(--space-2) 0; +} + +[data-theme="dark"] .update-progress { + background: rgba(255, 255, 255, 0.03); +} + +.progress-info { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.progress-text { + font-size: 0.9em; + color: var(--text-color); + opacity: 0.8; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; +} + +[data-theme="dark"] .progress-bar { + background-color: rgba(255, 255, 255, 0.1); +} + +.progress-fill { + height: 100%; + background-color: var(--lora-accent); + width: 0%; + transition: width 0.3s ease; + border-radius: 4px; +} + +/* Update button states */ +#updateBtn { + min-width: 120px; +} + +#updateBtn.updating { + background-color: var(--lora-warning); + cursor: not-allowed; +} + +#updateBtn.success { + background-color: var(--lora-success); +} + +#updateBtn.error { + background-color: var(--lora-error); +} + +/* Add styles for markdown elements in changelog */ +.changelog-item ul { + padding-left: 20px; + margin-top: 8px; +} + +.changelog-item li { + margin-bottom: 6px; + line-height: 1.4; +} + +.changelog-item strong { + font-weight: 600; +} + +.changelog-item em { + font-style: italic; +} + +.changelog-item code { + background: rgba(0, 0, 0, 0.05); + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; +} + +[data-theme="dark"] .changelog-item code { + background: rgba(255, 255, 255, 0.1); +} + +.changelog-item a { + color: var(--lora-accent); + text-decoration: none; +} + +.changelog-item a:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index c2d18a54..91644220 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -7,7 +7,14 @@ /* Import Components */ @import 'components/header.css'; @import 'components/card.css'; -@import 'components/modal.css'; +@import 'components/modal/_base.css'; +@import 'components/modal/delete-modal.css'; +@import 'components/modal/update-modal.css'; +@import 'components/modal/settings-modal.css'; +@import 'components/modal/help-modal.css'; +@import 'components/modal/relink-civitai-modal.css'; +@import 'components/modal/example-access-modal.css'; +@import 'components/modal/support-modal.css'; @import 'components/download-modal.css'; @import 'components/toast.css'; @import 'components/loading.css'; @@ -20,7 +27,6 @@ @import 'components/lora-modal/showcase.css'; @import 'components/lora-modal/triggerwords.css'; @import 'components/shared/edit-metadata.css'; -@import 'components/support-modal.css'; @import 'components/search-filter.css'; @import 'components/bulk.css'; @import 'components/shared.css'; diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js new file mode 100644 index 00000000..d64769ba --- /dev/null +++ b/static/js/api/apiConfig.js @@ -0,0 +1,168 @@ +import { state } from '../state/index.js'; + +/** + * API Configuration + * Centralized configuration for all model types and their endpoints + */ + +// Model type definitions +export const MODEL_TYPES = { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings' // Future model type +}; + +// Base API configuration for each model type +export const MODEL_CONFIG = { + [MODEL_TYPES.LORA]: { + displayName: 'LoRA', + singularName: 'lora', + defaultPageSize: 100, + supportsLetterFilter: true, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'loras.html' + }, + [MODEL_TYPES.CHECKPOINT]: { + displayName: 'Checkpoint', + singularName: 'checkpoint', + defaultPageSize: 100, + supportsLetterFilter: false, + supportsBulkOperations: true, + supportsMove: false, + templateName: 'checkpoints.html' + }, + [MODEL_TYPES.EMBEDDING]: { + displayName: 'Embedding', + singularName: 'embedding', + defaultPageSize: 100, + supportsLetterFilter: true, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'embeddings.html' + } +}; + +/** + * Generate API endpoints for a given model type + * @param {string} modelType - The model type (e.g., 'loras', 'checkpoints') + * @returns {Object} Object containing all API endpoints for the model type + */ +export function getApiEndpoints(modelType) { + if (!Object.values(MODEL_TYPES).includes(modelType)) { + throw new Error(`Invalid model type: ${modelType}`); + } + + return { + // Base CRUD operations + list: `/api/${modelType}`, + delete: `/api/${modelType}/delete`, + exclude: `/api/${modelType}/exclude`, + rename: `/api/${modelType}/rename`, + save: `/api/${modelType}/save-metadata`, + + // Bulk operations + bulkDelete: `/api/${modelType}/bulk-delete`, + + // CivitAI integration + fetchCivitai: `/api/${modelType}/fetch-civitai`, + fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`, + relinkCivitai: `/api/${modelType}/relink-civitai`, + civitaiVersions: `/api/${modelType}/civitai/versions`, + + // Preview management + replacePreview: `/api/${modelType}/replace-preview`, + + // Query operations + scan: `/api/${modelType}/scan`, + topTags: `/api/${modelType}/top-tags`, + baseModels: `/api/${modelType}/base-models`, + roots: `/api/${modelType}/roots`, + folders: `/api/${modelType}/folders`, + duplicates: `/api/${modelType}/find-duplicates`, + conflicts: `/api/${modelType}/find-filename-conflicts`, + verify: `/api/${modelType}/verify-duplicates`, + + // Model-specific endpoints (will be merged with specific configs) + specific: {} + }; +} + +/** + * Model-specific endpoint configurations + */ +export const MODEL_SPECIFIC_ENDPOINTS = { + [MODEL_TYPES.LORA]: { + letterCounts: `/api/${MODEL_TYPES.LORA}/letter-counts`, + notes: `/api/${MODEL_TYPES.LORA}/get-notes`, + triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`, + previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`, + civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`, + modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`, + moveModel: `/api/${MODEL_TYPES.LORA}/move_model`, + moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`, + getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`, + civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`, + civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`, + }, + [MODEL_TYPES.CHECKPOINT]: { + info: `/api/${MODEL_TYPES.CHECKPOINT}/info`, + }, + [MODEL_TYPES.EMBEDDING]: { + } +}; + +/** + * Get complete API configuration for a model type + * @param {string} modelType - The model type + * @returns {Object} Complete API configuration + */ +export function getCompleteApiConfig(modelType) { + const baseEndpoints = getApiEndpoints(modelType); + const specificEndpoints = MODEL_SPECIFIC_ENDPOINTS[modelType] || {}; + const config = MODEL_CONFIG[modelType]; + + return { + modelType, + config, + endpoints: { + ...baseEndpoints, + specific: specificEndpoints + } + }; +} + +/** + * Validate if a model type is supported + * @param {string} modelType - The model type to validate + * @returns {boolean} True if valid, false otherwise + */ +export function isValidModelType(modelType) { + return Object.values(MODEL_TYPES).includes(modelType); +} + +/** + * Get model type from current page or explicit parameter + * @param {string} [explicitType] - Explicitly provided model type + * @returns {string} The model type + */ +export function getCurrentModelType(explicitType = null) { + if (explicitType && isValidModelType(explicitType)) { + return explicitType; + } + + return state.currentPageType || MODEL_TYPES.LORA; +} + +// Download API endpoints (shared across all model types) +export const DOWNLOAD_ENDPOINTS = { + download: '/api/download-model', + downloadGet: '/api/download-model-get', + cancelGet: '/api/cancel-download-get', + progress: '/api/download-progress' +}; + +// WebSocket endpoints +export const WS_ENDPOINTS = { + fetchProgress: '/ws/fetch-progress' +}; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index fe915f86..f2e787f2 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,45 +1,691 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; +import { + getCompleteApiConfig, + getCurrentModelType, + isValidModelType, + DOWNLOAD_ENDPOINTS, + WS_ENDPOINTS +} from './apiConfig.js'; -// New method for virtual scrolling fetch -export async function fetchModelsPage(options = {}) { - const { - modelType = 'lora', - page = 1, - pageSize = 100, - endpoint = '/api/loras' - } = options; +/** + * Universal API client for all model types + */ +class ModelApiClient { + constructor(modelType = null) { + this.modelType = modelType || getCurrentModelType(); + this.apiConfig = getCompleteApiConfig(this.modelType); + } - const pageState = getCurrentPageState(); - - try { - const params = new URLSearchParams({ - page: page, - page_size: pageSize || pageState.pageSize || 20, - sort_by: pageState.sortBy - }); + /** + * Set the model type for this client instance + * @param {string} modelType - The model type to use + */ + setModelType(modelType) { + if (!isValidModelType(modelType)) { + throw new Error(`Invalid model type: ${modelType}`); + } + this.modelType = modelType; + this.apiConfig = getCompleteApiConfig(modelType); + } + + /** + * Get the current page state for this model type + */ + getPageState() { + const currentType = state.currentPageType; + // Temporarily switch to get the right page state + state.currentPageType = this.modelType; + const pageState = getCurrentPageState(); + state.currentPageType = currentType; // Restore + return pageState; + } + + /** + * Fetch models with pagination + */ + async fetchModelsPage(page = 1, pageSize = null) { + const pageState = this.getPageState(); + const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; + try { + const params = this._buildQueryParams({ + page, + page_size: actualPageSize, + sort_by: pageState.sortBy + }, pageState); + + const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); + } + + const data = await response.json(); + + return { + items: data.items, + totalItems: data.total, + totalPages: data.total_pages, + currentPage: page, + hasMore: page < data.total_pages, + folders: data.folders + }; + + } catch (error) { + console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error); + showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error'); + throw error; + } + } + + /** + * Reset and reload models with virtual scrolling + */ + async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { + const pageState = this.getPageState(); + + try { + state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`); + + pageState.isLoading = true; + if (resetPage) { + pageState.currentPage = 1; // Reset to first page + } + + // Fetch the current page + const startTime = performance.now(); + const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize); + const endTime = performance.now(); + console.log(`fetchModelsPage耗时: ${(endTime - startTime).toFixed(2)} ms`); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = pageState.currentPage + 1; + + // Update folders if needed + if (updateFolders && result.folders) { + updateFolderTags(result.folders); + } + + return result; + } catch (error) { + console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error); + showToast(`Failed to reload ${this.apiConfig.config.displayName}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + state.loadingManager.hide(); + } + } + + /** + * Delete a model + */ + async deleteModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.delete, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast(`${this.apiConfig.config.displayName} deleted successfully`, 'success'); + return true; + } else { + throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`); + } + } catch (error) { + console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error); + showToast(`Failed to delete ${this.apiConfig.config.singularName}: ${error.message}`, 'error'); + return false; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Exclude a model + */ + async excludeModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.exclude, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to exclude ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast(`${this.apiConfig.config.displayName} excluded successfully`, 'success'); + return true; + } else { + throw new Error(data.error || `Failed to exclude ${this.apiConfig.config.singularName}`); + } + } catch (error) { + console.error(`Error excluding ${this.apiConfig.config.singularName}:`, error); + showToast(`Failed to exclude ${this.apiConfig.config.singularName}: ${error.message}`, 'error'); + return false; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Rename a model file + */ + async renameModelFile(filePath, newFileName) { + try { + state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); + + const response = await fetch(this.apiConfig.endpoints.rename, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + new_file_name: newFileName + }) + }); + + const result = await response.json(); + + if (result.success) { + state.virtualScroller.updateSingleItem(filePath, { + file_name: newFileName, + file_path: result.new_file_path, + preview_url: result.new_preview_path + }); + + showToast('File name updated successfully', 'success'); + } else { + showToast('Failed to rename file: ' + (result.error || 'Unknown error'), 'error'); + } + + return result; + } catch (error) { + console.error(`Error renaming ${this.apiConfig.config.singularName} file:`, error); + throw error; + } finally { + state.loadingManager.hide(); + } + } + + /** + * Replace model preview + */ + replaceModelPreview(filePath) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*,video/mp4'; + + input.onchange = async () => { + if (!input.files || !input.files[0]) return; + + const file = input.files[0]; + await this.uploadPreview(filePath, file); + }; + + input.click(); + } + + /** + * Upload preview image + */ + async uploadPreview(filePath, file, nsfwLevel = 0) { + try { + state.loadingManager.showSimpleLoading('Uploading preview...'); + + const formData = new FormData(); + formData.append('preview_file', file); + formData.append('model_path', filePath); + formData.append('nsfw_level', nsfwLevel.toString()); + + const response = await fetch(this.apiConfig.endpoints.replacePreview, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + const pageState = this.getPageState(); + + // Update the version timestamp + const timestamp = Date.now(); + if (pageState.previewVersions) { + pageState.previewVersions.set(filePath, timestamp); + + const storageKey = `${this.modelType}_preview_versions`; + saveMapToStorage(storageKey, pageState.previewVersions); + } + + const updateData = { + preview_url: data.preview_url, + preview_nsfw_level: data.preview_nsfw_level + }; + + state.virtualScroller.updateSingleItem(filePath, updateData); + showToast('Preview updated successfully', 'success'); + } catch (error) { + console.error('Error uploading preview:', error); + showToast('Failed to upload preview image', 'error'); + } finally { + state.loadingManager.hide(); + } + } + + /** + * Save model metadata + */ + async saveModelMetadata(filePath, data) { + try { + state.loadingManager.showSimpleLoading('Saving metadata...'); + + const response = await fetch(this.apiConfig.endpoints.save, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + state.virtualScroller.updateSingleItem(filePath, data); + return response.json(); + } finally { + state.loadingManager.hide(); + } + } + + /** + * Refresh models (scan) + */ + async refreshModels(fullRebuild = false) { + try { + state.loadingManager.showSimpleLoading( + `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...` + ); + + const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); + url.searchParams.append('full_rebuild', fullRebuild); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); + } + + showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); + } catch (error) { + console.error('Refresh failed:', error); + showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error'); + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } + } + + /** + * Fetch CivitAI metadata for single model + */ + async refreshSingleModelMetadata(filePath) { + try { + state.loadingManager.showSimpleLoading('Refreshing metadata...'); + + const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error('Failed to refresh metadata'); + } + + const data = await response.json(); + + if (data.success) { + if (data.metadata && state.virtualScroller) { + state.virtualScroller.updateSingleItem(filePath, data.metadata); + } + + showToast('Metadata refreshed successfully', 'success'); + return true; + } else { + throw new Error(data.error || 'Failed to refresh metadata'); + } + } catch (error) { + console.error('Error refreshing metadata:', error); + showToast(error.message, 'error'); + return false; + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } + } + + /** + * Fetch CivitAI metadata for all models + */ + async fetchCivitaiMetadata() { + let ws = null; + + await state.loadingManager.showWithProgress(async (loading) => { + try { + 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); + + switch(data.status) { + case 'started': + loading.setStatus('Starting metadata fetch...'); + break; + + case 'processing': + const percent = ((data.processed / data.total) * 100).toFixed(1); + loading.setProgress(percent); + loading.setStatus( + `Processing (${data.processed}/${data.total}) ${data.current_name}` + ); + break; + + case 'completed': + loading.setProgress(100); + loading.setStatus( + `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` + ); + resolve(); + break; + + case 'error': + reject(new Error(data.error)); + break; + } + }; + + ws.onerror = (error) => { + reject(new Error('WebSocket error: ' + error.message)); + }; + }); + + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + + const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + if (!response.ok) { + throw new Error('Failed to fetch metadata'); + } + + await operationComplete; + + } catch (error) { + console.error('Error fetching metadata:', error); + showToast('Failed to fetch metadata: ' + error.message, 'error'); + } finally { + if (ws) { + ws.close(); + } + } + }, { + initialMessage: 'Connecting...', + completionMessage: 'Metadata update complete' + }); + } + + /** + * Move a single model to target path + * @returns {string|null} - The new file path if moved, null if not moved + */ + async moveSingleModel(filePath, targetPath) { + if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { + showToast('Model is already in the selected folder', 'info'); + return null; + } + + const response = await fetch(this.apiConfig.endpoints.specific.moveModel, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + target_path: targetPath + }) + }); + + const result = await response.json(); + + if (!response.ok) { + if (result && result.error) { + throw new Error(result.error); + } + throw new Error('Failed to move model'); + } + + if (result && result.message) { + showToast(result.message, 'info'); + } else { + showToast('Model moved successfully', 'success'); + } + + // Return new file path if move succeeded + if (result.success) { + return result.new_file_path; + } + return null; + } + + /** + * Move multiple models to target path + * @returns {Array} - Array of new file paths that were moved successfully + */ + async moveBulkModels(filePaths, targetPath) { + const movedPaths = filePaths.filter(path => { + return path.substring(0, path.lastIndexOf('/')) !== targetPath; + }); + + if (movedPaths.length === 0) { + showToast('All selected models are already in the target folder', 'info'); + return []; + } + + const response = await fetch(this.apiConfig.endpoints.specific.moveBulk, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_paths: movedPaths, + target_path: targetPath + }) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error('Failed to move models'); + } + + let successFilePaths = []; + if (result.success) { + if (result.failure_count > 0) { + showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning'); + console.log('Move operation results:', result.results); + const failedFiles = result.results + .filter(r => !r.success) + .map(r => { + const fileName = r.path.substring(r.path.lastIndexOf('/') + 1); + return `${fileName}: ${r.message}`; + }); + if (failedFiles.length > 0) { + const failureMessage = failedFiles.length <= 3 + ? failedFiles.join('\n') + : failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`; + showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000); + } + } else { + showToast(`Successfully moved ${result.success_count} models`, 'success'); + } + // Collect new file paths for successful moves + successFilePaths = result.results + .filter(r => r.success) + .map(r => r.path); + } else { + throw new Error(result.message || 'Failed to move models'); + } + return successFilePaths; + } + + /** + * Fetch Civitai model versions + */ + async fetchCivitaiVersions(modelId) { + try { + const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { + throw new Error(`This model is not a ${this.apiConfig.config.displayName}. Please switch to the appropriate page to download this model type.`); + } + throw new Error('Failed to fetch model versions'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching Civitai versions:', error); + throw error; + } + } + + /** + * Fetch model roots + */ + async fetchModelRoots() { + try { + const response = await fetch(this.apiConfig.endpoints.roots); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} roots`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model roots:', error); + throw error; + } + } + + /** + * Fetch model folders + */ + async fetchModelFolders() { + try { + const response = await fetch(this.apiConfig.endpoints.folders); + if (!response.ok) { + throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model folders:', error); + throw error; + } + } + + /** + * Download a model + */ + async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { + try { + const response = await fetch(DOWNLOAD_ENDPOINTS.download, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_id: modelId, + model_version_id: versionId, + model_root: modelRoot, + relative_path: relativePath, + download_id: downloadId + }) + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + return await response.json(); + } catch (error) { + console.error('Error downloading model:', error); + throw error; + } + } + + /** + * Build query parameters for API requests + */ + _buildQueryParams(baseParams, pageState) { + const params = new URLSearchParams(baseParams); + + // Add common parameters if (pageState.activeFolder !== null) { params.append('folder', pageState.activeFolder); } - // Add favorites filter parameter if enabled if (pageState.showFavoritesOnly) { params.append('favorites_only', 'true'); } - // Add active letter filter if set - if (pageState.activeLetterFilter) { + // Add letter filter for supported model types + if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { params.append('first_letter', pageState.activeLetterFilter); } - // Add search parameters if there's a search term + // Add search parameters if (pageState.filters?.search) { params.append('search', pageState.filters.search); params.append('fuzzy', 'true'); - // Add search option parameters if available if (pageState.searchOptions) { params.append('search_filename', pageState.searchOptions.filename.toString()); params.append('search_modelname', pageState.searchOptions.modelname.toString()); @@ -50,16 +696,14 @@ export async function fetchModelsPage(options = {}) { } } - // Add filter parameters if active + // Add filter parameters if (pageState.filters) { - // Handle tags filters if (pageState.filters.tags && pageState.filters.tags.length > 0) { pageState.filters.tags.forEach(tag => { params.append('tag', tag); }); } - // Handle base model filters if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { pageState.filters.baseModel.forEach(model => { params.append('base_model', model); @@ -68,17 +712,23 @@ export async function fetchModelsPage(options = {}) { } // Add model-specific parameters - if (modelType === 'lora') { - // Check for recipe-based filtering parameters from session storage + this._addModelSpecificParams(params, pageState); + + return params; + } + + /** + * Add model-specific parameters to query + */ + _addModelSpecificParams(params, pageState) { + // Override in specific implementations or handle via configuration + if (this.modelType === 'loras') { const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); - // Add hash filter parameter if present if (filterLoraHash) { params.append('lora_hash', filterLoraHash); - } - // Add multiple hashes filter if present - else if (filterLoraHashes) { + } else if (filterLoraHashes) { try { if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) { params.append('lora_hashes', filterLoraHashes.join(',')); @@ -88,546 +738,24 @@ export async function fetchModelsPage(options = {}) { } } } - - const response = await fetch(`${endpoint}?${params}`); - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.statusText}`); - } - - const data = await response.json(); - - return { - items: data.items, - totalItems: data.total, - totalPages: data.total_pages, - currentPage: page, - hasMore: page < data.total_pages, - folders: data.folders - }; - - } catch (error) { - console.error(`Error fetching ${modelType}s:`, error); - showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error'); - throw error; } } -/** - * Reset and reload models using virtual scrolling - * @param {Object} options - Operation options - * @returns {Promise} The fetch result - */ -export async function resetAndReloadWithVirtualScroll(options = {}) { - const { - modelType = 'lora', - updateFolders = false, - fetchPageFunction - } = options; - - const pageState = getCurrentPageState(); - - try { - pageState.isLoading = true; - - // Reset page counter - pageState.currentPage = 1; - - // Fetch the first page - const result = await fetchPageFunction(1, pageState.pageSize || 50); - - // Update the virtual scroller - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page will be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error(`Error reloading ${modelType}s:`, error); - showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); - throw error; - } finally { - pageState.isLoading = false; +// Export factory functions and utilities +export function createModelApiClient(modelType = null) { + return new ModelApiClient(modelType); +} + +let _singletonClient = null; + +export function getModelApiClient() { + if (!_singletonClient) { + _singletonClient = new ModelApiClient(); } + _singletonClient.setModelType(state.currentPageType); + return _singletonClient; } -/** - * Load more models using virtual scrolling - * @param {Object} options - Operation options - * @returns {Promise} The fetch result - */ -export async function loadMoreWithVirtualScroll(options = {}) { - const { - modelType = 'lora', - resetPage = false, - updateFolders = false, - fetchPageFunction - } = options; - - const pageState = getCurrentPageState(); - - try { - // Start loading state - pageState.isLoading = true; - - // Reset to first page if requested - if (resetPage) { - pageState.currentPage = 1; - } - - // Fetch the first page of data - const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); - - // Update virtual scroller with the new data - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page to load would be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error(`Error loading ${modelType}s:`, error); - showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); - throw error; - } finally { - pageState.isLoading = false; - } -} - -// Update folder tags in the UI -export function updateFolderTags(folders) { - const folderTagsContainer = document.querySelector('.folder-tags'); - if (!folderTagsContainer) return; - - // Keep track of currently selected folder - const pageState = getCurrentPageState(); - const currentFolder = pageState.activeFolder; - - // Create HTML for folder tags - const tagsHTML = folders.map(folder => { - const isActive = folder === currentFolder; - return `
${folder}
`; - }).join(''); - - // Update the container - folderTagsContainer.innerHTML = tagsHTML; - - // Reattach click handlers and ensure the active tag is visible - const tags = folderTagsContainer.querySelectorAll('.tag'); - tags.forEach(tag => { - if (typeof toggleFolder === 'function') { - tag.addEventListener('click', toggleFolder); - } - if (tag.dataset.folder === currentFolder) { - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); -} - -// Generic function to replace a model preview -export function replaceModelPreview(filePath, modelType = 'lora') { - // Open file picker - const input = document.createElement('input'); - input.type = 'file'; - input.accept ='image/*,video/mp4'; - - input.onchange = async function() { - if (!input.files || !input.files[0]) return; - - const file = input.files[0]; - await uploadPreview(filePath, file, modelType); - }; - - input.click(); -} - -// Delete a model (generic) -export async function deleteModel(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading(`Deleting ${modelType}...`); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/delete' - : '/api/loras/delete'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath - }) - }); - - if (!response.ok) { - throw new Error(`Failed to delete ${modelType}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // If virtual scroller exists, update its data - if (state.virtualScroller) { - state.virtualScroller.removeItemByFilePath(filePath); - } else { - // Legacy approach: remove the card from UI directly - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - } - - showToast(`${modelType} deleted successfully`, 'success'); - return true; - } else { - throw new Error(data.error || `Failed to delete ${modelType}`); - } - } catch (error) { - console.error(`Error deleting ${modelType}:`, error); - showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); - return false; - } finally { - state.loadingManager.hide(); - } -} - -// Generic function to refresh models -export async function refreshModels(options = {}) { - const { - modelType = 'lora', - scanEndpoint = '/api/loras/scan', - resetAndReloadFunction, - fullRebuild = false // New parameter with default value false - } = options; - - try { - state.loadingManager.showSimpleLoading(`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${modelType}s...`); - - // Add fullRebuild parameter to the request - const url = new URL(scanEndpoint, window.location.origin); - url.searchParams.append('full_rebuild', fullRebuild); - - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`); - } - - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(true); // update folders - } - - showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success'); - } catch (error) { - console.error(`Refresh failed:`, error); - showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error'); - } finally { - state.loadingManager.hide(); - state.loadingManager.restoreProgressBar(); - } -} - -// Generic fetch from Civitai -export async function fetchCivitaiMetadata(options = {}) { - const { - modelType = 'lora', - fetchEndpoint = '/api/fetch-all-civitai', - resetAndReloadFunction - } = options; - - let ws = null; - - await state.loadingManager.showWithProgress(async (loading) => { - try { - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); - - const operationComplete = new Promise((resolve, reject) => { - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch(data.status) { - case 'started': - loading.setStatus('Starting metadata fetch...'); - break; - - case 'processing': - const percent = ((data.processed / data.total) * 100).toFixed(1); - loading.setProgress(percent); - loading.setStatus( - `Processing (${data.processed}/${data.total}) ${data.current_name}` - ); - break; - - case 'completed': - loading.setProgress(100); - loading.setStatus( - `Completed: Updated ${data.success} of ${data.processed} ${modelType}s` - ); - resolve(); - break; - - case 'error': - reject(new Error(data.error)); - break; - } - }; - - ws.onerror = (error) => { - reject(new Error('WebSocket error: ' + error.message)); - }; - }); - - await new Promise((resolve, reject) => { - ws.onopen = resolve; - ws.onerror = reject; - }); - - const requestBody = modelType === 'checkpoint' - ? JSON.stringify({ model_type: 'checkpoint' }) - : JSON.stringify({}); - - const response = await fetch(fetchEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: requestBody - }); - - if (!response.ok) { - throw new Error('Failed to fetch metadata'); - } - - await operationComplete; - - if (typeof resetAndReloadFunction === 'function') { - await resetAndReloadFunction(); - } - - } catch (error) { - console.error('Error fetching metadata:', error); - showToast('Failed to fetch metadata: ' + error.message, 'error'); - } finally { - if (ws) { - ws.close(); - } - } - }, { - initialMessage: 'Connecting...', - completionMessage: 'Metadata update complete' - }); -} - -// Generic function to refresh single model metadata -export async function refreshSingleModelMetadata(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading('Refreshing metadata...'); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/fetch-civitai' - : '/api/loras/fetch-civitai'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ file_path: filePath }) - }); - - if (!response.ok) { - throw new Error('Failed to refresh metadata'); - } - - const data = await response.json(); - - if (data.success) { - // Use the returned metadata to update just this single item - if (data.metadata && state.virtualScroller) { - state.virtualScroller.updateSingleItem(filePath, data.metadata); - } - - showToast('Metadata refreshed successfully', 'success'); - return true; - } else { - throw new Error(data.error || 'Failed to refresh metadata'); - } - } catch (error) { - console.error('Error refreshing metadata:', error); - showToast(error.message, 'error'); - return false; - } finally { - state.loadingManager.hide(); - state.loadingManager.restoreProgressBar(); - } -} - -// Generic function to exclude a model -export async function excludeModel(filePath, modelType = 'lora') { - try { - state.loadingManager.showSimpleLoading(`Excluding ${modelType}...`); - - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/exclude' - : '/api/loras/exclude'; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath - }) - }); - - if (!response.ok) { - throw new Error(`Failed to exclude ${modelType}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // If virtual scroller exists, update its data - if (state.virtualScroller) { - state.virtualScroller.removeItemByFilePath(filePath); - } else { - // Legacy approach: remove the card from UI directly - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - } - - showToast(`${modelType} excluded successfully`, 'success'); - return true; - } else { - throw new Error(data.error || `Failed to exclude ${modelType}`); - } - } catch (error) { - console.error(`Error excluding ${modelType}:`, error); - showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error'); - return false; - } finally { - state.loadingManager.hide(); - } -} - -// Upload a preview image -export async function uploadPreview(filePath, file, modelType = 'lora', nsfwLevel = 0) { - try { - state.loadingManager.showSimpleLoading('Uploading preview...'); - - const formData = new FormData(); - - // Prepare common form data - formData.append('preview_file', file); - formData.append('model_path', filePath); - formData.append('nsfw_level', nsfwLevel.toString()); // Add nsfw_level parameter - - // Set endpoint based on model type - const endpoint = modelType === 'checkpoint' - ? '/api/checkpoints/replace-preview' - : '/api/loras/replace_preview'; - - const response = await fetch(endpoint, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error('Upload failed'); - } - - const data = await response.json(); - - // Get the current page's previewVersions Map based on model type - const pageType = modelType === 'checkpoint' ? 'checkpoints' : 'loras'; - const previewVersions = state.pages[pageType].previewVersions; - - // Update the version timestamp - const timestamp = Date.now(); - if (previewVersions) { - previewVersions.set(filePath, timestamp); - - // Save the updated Map to localStorage - const storageKey = modelType === 'checkpoint' ? 'checkpoint_preview_versions' : 'lora_preview_versions'; - saveMapToStorage(storageKey, previewVersions); - } - - const updateData = { - preview_url: data.preview_url, - preview_nsfw_level: data.preview_nsfw_level // Include nsfw level in update data - }; - - state.virtualScroller.updateSingleItem(filePath, updateData); - - showToast('Preview updated successfully', 'success'); - } catch (error) { - console.error('Error uploading preview:', error); - showToast('Failed to upload preview image', 'error'); - } finally { - state.loadingManager.hide(); - } -} - -// Private methods - -// Private function to perform the delete operation -async function performDelete(filePath, modelType = 'lora') { - try { - showToast(`Deleting ${modelType}...`, 'info'); - - const response = await fetch('/api/model/delete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_path: filePath, - model_type: modelType - }) - }); - - if (!response.ok) { - throw new Error(`Failed to delete ${modelType}: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - // Remove the card from UI - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (card) { - card.remove(); - } - - showToast(`${modelType} deleted successfully`, 'success'); - } else { - throw new Error(data.error || `Failed to delete ${modelType}`); - } - } catch (error) { - console.error(`Error deleting ${modelType}:`, error); - showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); - } +export async function resetAndReload(updateFolders = false) { + return getModelApiClient().loadMoreWithVirtualScroll(true, updateFolders); } \ No newline at end of file diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js deleted file mode 100644 index a595b2aa..00000000 --- a/static/js/api/checkpointApi.js +++ /dev/null @@ -1,165 +0,0 @@ -import { - fetchModelsPage, - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll, - refreshModels as baseRefreshModels, - deleteModel as baseDeleteModel, - replaceModelPreview, - fetchCivitaiMetadata, - refreshSingleModelMetadata, - excludeModel as baseExcludeModel -} from './baseModelApi.js'; -import { state } from '../state/index.js'; - -/** - * Fetch checkpoints with pagination for virtual scrolling - * @param {number} page - Page number to fetch - * @param {number} pageSize - Number of items per page - * @returns {Promise} Object containing items, total count, and pagination info - */ -export async function fetchCheckpointsPage(page = 1, pageSize = 100) { - return fetchModelsPage({ - modelType: 'checkpoint', - page, - pageSize, - endpoint: '/api/checkpoints' - }); -} - -/** - * Load more checkpoints with pagination - updated to work with VirtualScroller - * @param {boolean} resetPage - Whether to reset to the first page - * @param {boolean} updateFolders - Whether to update folder tags - * @returns {Promise} - */ -export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) { - return loadMoreWithVirtualScroll({ - modelType: 'checkpoint', - resetPage, - updateFolders, - fetchPageFunction: fetchCheckpointsPage - }); -} - -// Reset and reload checkpoints -export async function resetAndReload(updateFolders = false) { - return resetAndReloadWithVirtualScroll({ - modelType: 'checkpoint', - updateFolders, - fetchPageFunction: fetchCheckpointsPage - }); -} - -// Refresh checkpoints -export async function refreshCheckpoints(fullRebuild = false) { - return baseRefreshModels({ - modelType: 'checkpoint', - scanEndpoint: '/api/checkpoints/scan', - resetAndReloadFunction: resetAndReload, - fullRebuild: fullRebuild - }); -} - -// Delete a checkpoint -export function deleteCheckpoint(filePath) { - return baseDeleteModel(filePath, 'checkpoint'); -} - -// Replace checkpoint preview -export function replaceCheckpointPreview(filePath) { - return replaceModelPreview(filePath, 'checkpoint'); -} - -// Fetch metadata from Civitai for checkpoints -export async function fetchCivitai() { - return fetchCivitaiMetadata({ - modelType: 'checkpoint', - fetchEndpoint: '/api/checkpoints/fetch-all-civitai', - resetAndReloadFunction: resetAndReload - }); -} - -// Refresh single checkpoint metadata -export async function refreshSingleCheckpointMetadata(filePath) { - await refreshSingleModelMetadata(filePath, 'checkpoint'); -} - -/** - * Save model metadata to the server - * @param {string} filePath - Path to the model file - * @param {Object} data - Metadata to save - * @returns {Promise} - Promise that resolves with the server response - */ -export async function saveModelMetadata(filePath, data) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Saving metadata...'); - - const response = await fetch('/api/checkpoints/save-metadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - ...data - }) - }); - - if (!response.ok) { - throw new Error('Failed to save metadata'); - } - - // Update the virtual scroller with the new metadata - state.virtualScroller.updateSingleItem(filePath, data); - - return response.json(); - } finally { - // Always hide the loading indicator when done - state.loadingManager.hide(); - } -} - -/** - * Exclude a checkpoint model from being shown in the UI - * @param {string} filePath - File path of the checkpoint to exclude - * @returns {Promise} Promise resolving to success status - */ -export function excludeCheckpoint(filePath) { - return baseExcludeModel(filePath, 'checkpoint'); -} - -/** - * Rename a checkpoint file - * @param {string} filePath - Current file path - * @param {string} newFileName - New file name (without path) - * @returns {Promise} - Promise that resolves with the server response - */ -export async function renameCheckpointFile(filePath, newFileName) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Renaming checkpoint file...'); - - const response = await fetch('/api/checkpoints/rename', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - new_file_name: newFileName - }) - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error('Error renaming checkpoint file:', error); - throw error; - } finally { - state.loadingManager.hide(); - } -} \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js deleted file mode 100644 index 9d5dd1bc..00000000 --- a/static/js/api/loraApi.js +++ /dev/null @@ -1,175 +0,0 @@ -import { - fetchModelsPage, - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll, - refreshModels as baseRefreshModels, - deleteModel as baseDeleteModel, - replaceModelPreview, - fetchCivitaiMetadata, - refreshSingleModelMetadata, - excludeModel as baseExcludeModel -} from './baseModelApi.js'; -import { state } from '../state/index.js'; - -/** - * Save model metadata to the server - * @param {string} filePath - File path - * @param {Object} data - Data to save - * @returns {Promise} Promise of the save operation - */ -export async function saveModelMetadata(filePath, data) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Saving metadata...'); - - const response = await fetch('/api/loras/save-metadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - ...data - }) - }); - - if (!response.ok) { - throw new Error('Failed to save metadata'); - } - - // Update the virtual scroller with the new data - state.virtualScroller.updateSingleItem(filePath, data); - - return response.json(); - } finally { - // Always hide the loading indicator when done - state.loadingManager.hide(); - } -} - -/** - * Exclude a lora model from being shown in the UI - * @param {string} filePath - File path of the model to exclude - * @returns {Promise} Promise resolving to success status - */ -export async function excludeLora(filePath) { - return baseExcludeModel(filePath, 'lora'); -} - -/** - * Load more loras with pagination - updated to work with VirtualScroller - * @param {boolean} resetPage - Whether to reset to the first page - * @param {boolean} updateFolders - Whether to update folder tags - * @returns {Promise} - */ -export async function loadMoreLoras(resetPage = false, updateFolders = false) { - return loadMoreWithVirtualScroll({ - modelType: 'lora', - resetPage, - updateFolders, - fetchPageFunction: fetchLorasPage - }); -} - -/** - * Fetch loras with pagination for virtual scrolling - * @param {number} page - Page number to fetch - * @param {number} pageSize - Number of items per page - * @returns {Promise} Object containing items, total count, and pagination info - */ -export async function fetchLorasPage(page = 1, pageSize = 100) { - return fetchModelsPage({ - modelType: 'lora', - page, - pageSize, - endpoint: '/api/loras' - }); -} - -export async function fetchCivitai() { - return fetchCivitaiMetadata({ - modelType: 'lora', - fetchEndpoint: '/api/loras/fetch-all-civitai', - resetAndReloadFunction: resetAndReload - }); -} - -export async function deleteModel(filePath) { - return baseDeleteModel(filePath, 'lora'); -} - -export async function replacePreview(filePath) { - return replaceModelPreview(filePath, 'lora'); -} - -export async function resetAndReload(updateFolders = false) { - return resetAndReloadWithVirtualScroll({ - modelType: 'lora', - updateFolders, - fetchPageFunction: fetchLorasPage - }); -} - -export async function refreshLoras(fullRebuild = false) { - return baseRefreshModels({ - modelType: 'lora', - scanEndpoint: '/api/loras/scan', - resetAndReloadFunction: resetAndReload, - fullRebuild: fullRebuild - }); -} - -export async function refreshSingleLoraMetadata(filePath) { - await refreshSingleModelMetadata(filePath, 'lora'); -} - -export async function fetchModelDescription(modelId, filePath) { - try { - const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); - - if (!response.ok) { - throw new Error(`Failed to fetch model description: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error('Error fetching model description:', error); - throw error; - } -} - -/** - * Rename a LoRA file - * @param {string} filePath - Current file path - * @param {string} newFileName - New file name (without path) - * @returns {Promise} - Promise that resolves with the server response - */ -export async function renameLoraFile(filePath, newFileName) { - try { - // Show loading indicator - state.loadingManager.showSimpleLoading('Renaming LoRA file...'); - - const response = await fetch('/api/loras/rename', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - new_file_name: newFileName - }) - }); - - if (!response.ok) { - throw new Error(`Server returned ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error('Error renaming LoRA file:', error); - throw error; - } finally { - // Hide loading indicator - state.loadingManager.hide(); - } -} \ No newline at end of file diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 0072879d..e9e83ca7 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -1,8 +1,4 @@ import { RecipeCard } from '../components/RecipeCard.js'; -import { - resetAndReloadWithVirtualScroll, - loadMoreWithVirtualScroll -} from './baseModelApi.js'; import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; @@ -98,6 +94,98 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } } +/** + * Reset and reload models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function resetAndReloadWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + pageState.isLoading = true; + + // Reset page counter + pageState.currentPage = 1; + + // Fetch the first page + const result = await fetchPageFunction(1, pageState.pageSize || 50); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page will be 2 + + return result; + } catch (error) { + console.error(`Error reloading ${modelType}s:`, error); + showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + } +} + +/** + * Load more models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function loadMoreWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + resetPage = false, + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + // Start loading state + pageState.isLoading = true; + + // Reset to first page if requested + if (resetPage) { + pageState.currentPage = 1; + } + + // Fetch the first page of data + const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); + + // Update virtual scroller with the new data + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page to load would be 2 + + return result; + } catch (error) { + console.error(`Error loading ${modelType}s:`, error); + showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + } +} + /** * Reset and reload recipes using virtual scrolling * @param {boolean} updateFolders - Whether to update folder tags diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 68b5cb8b..89fab5b3 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -1,22 +1,18 @@ import { appCore } from './core.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { createPageControls } from './components/controls/index.js'; -import { loadMoreCheckpoints } from './api/checkpointApi.js'; -import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { MODEL_TYPES } from './api/apiConfig.js'; // Initialize the Checkpoints page class CheckpointsPageManager { constructor() { // Initialize page controls - this.pageControls = createPageControls('checkpoints'); - - // Initialize checkpoint download manager - window.checkpointDownloadManager = new CheckpointDownloadManager(); + this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT); // Initialize the ModelDuplicatesManager - this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints'); + this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.CHECKPOINT); // Expose only necessary functions to global scope this._exposeRequiredGlobalFunctions(); @@ -29,11 +25,6 @@ class CheckpointsPageManager { window.confirmExclude = confirmExclude; window.closeExcludeModal = closeExcludeModal; - // Add loadCheckpoints function to window for FilterManager compatibility - window.checkpointManager = { - loadCheckpoints: (reset) => loadMoreCheckpoints(reset) - }; - // Expose duplicates manager window.modelDuplicatesManager = this.duplicatesManager; } diff --git a/static/js/components/CheckpointCard.js b/static/js/components/CheckpointCard.js deleted file mode 100644 index 3a2292c9..00000000 --- a/static/js/components/CheckpointCard.js +++ /dev/null @@ -1,14 +0,0 @@ -// Legacy CheckpointCard.js - now using shared ModelCard component -import { - createModelCard, - setupModelCardEventDelegation -} from './shared/ModelCard.js'; - -// Re-export functions with original names for backwards compatibility -export function createCheckpointCard(checkpoint) { - return createModelCard(checkpoint, 'checkpoint'); -} - -export function setupCheckpointCardEventDelegation() { - setupModelCardEventDelegation('checkpoint'); -} \ No newline at end of file diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 16ab4b44..20eccea2 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -1,12 +1,12 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; -import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview, resetAndReload } from '../../api/checkpointApi.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; import { showToast } from '../../utils/uiHelpers.js'; -import { showExcludeModal } from '../../utils/modalUtils.js'; +import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; export class CheckpointContextMenu extends BaseContextMenu { constructor() { - super('checkpointContextMenu', '.lora-card'); + super('checkpointContextMenu', '.model-card'); this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.modelType = 'checkpoint'; this.resetAndReload = resetAndReload; @@ -19,7 +19,7 @@ export class CheckpointContextMenu extends BaseContextMenu { // Implementation needed by the mixin async saveModelMetadata(filePath, data) { - return saveModelMetadata(filePath, data); + return getModelApiClient().saveModelMetadata(filePath, data); } handleMenuAction(action) { @@ -28,6 +28,8 @@ export class CheckpointContextMenu extends BaseContextMenu { return; } + const apiClient = getModelApiClient(); + // Otherwise handle checkpoint-specific actions switch(action) { case 'details': @@ -36,13 +38,10 @@ export class CheckpointContextMenu extends BaseContextMenu { break; case 'replace-preview': // Add new action for replacing preview images - replaceCheckpointPreview(this.currentCard.dataset.filepath); + apiClient.replaceModelPreview(this.currentCard.dataset.filepath); break; case 'delete': - // Delete checkpoint - if (this.currentCard.querySelector('.fa-trash')) { - this.currentCard.querySelector('.fa-trash').click(); - } + showDeleteModal(this.currentCard.dataset.filepath); break; case 'copyname': // Copy checkpoint name @@ -52,14 +51,14 @@ export class CheckpointContextMenu extends BaseContextMenu { break; case 'refresh-metadata': // Refresh metadata from CivitAI - refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); + apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); break; case 'move': // Move to folder (placeholder) showToast('Move to folder feature coming soon', 'info'); break; case 'exclude': - showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint'); + showExcludeModal(this.currentCard.dataset.filepath); break; } } diff --git a/static/js/components/ContextMenu/EmbeddingContextMenu.js b/static/js/components/ContextMenu/EmbeddingContextMenu.js new file mode 100644 index 00000000..c0ef079b --- /dev/null +++ b/static/js/components/ContextMenu/EmbeddingContextMenu.js @@ -0,0 +1,68 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; + +export class EmbeddingContextMenu extends BaseContextMenu { + constructor() { + super('embeddingContextMenu', '.model-card'); + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + this.modelType = 'embedding'; + this.resetAndReload = resetAndReload; + + // Initialize NSFW Level Selector events + if (this.nsfwSelector) { + this.initNSFWSelector(); + } + } + + // Implementation needed by the mixin + async saveModelMetadata(filePath, data) { + return getModelApiClient().saveModelMetadata(filePath, data); + } + + handleMenuAction(action) { + // First try to handle with common actions + if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { + return; + } + + const apiClient = getModelApiClient(); + + // Otherwise handle embedding-specific actions + switch(action) { + case 'details': + // Show embedding details + this.currentCard.click(); + break; + case 'replace-preview': + // Add new action for replacing preview images + apiClient.replaceModelPreview(this.currentCard.dataset.filepath); + break; + case 'delete': + showDeleteModal(this.currentCard.dataset.filepath); + break; + case 'copyname': + // Copy embedding name + if (this.currentCard.querySelector('.fa-copy')) { + this.currentCard.querySelector('.fa-copy').click(); + } + break; + case 'refresh-metadata': + // Refresh metadata from CivitAI + apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); + break; + case 'move': + // Move to folder (placeholder) + showToast('Move to folder feature coming soon', 'info'); + break; + case 'exclude': + showExcludeModal(this.currentCard.dataset.filepath); + break; + } + } +} + +// Mix in shared methods +Object.assign(EmbeddingContextMenu.prototype, ModelContextMenuMixin); diff --git a/static/js/components/ContextMenu/LoraContextMenu.js b/static/js/components/ContextMenu/LoraContextMenu.js index 72ac7519..61ba4837 100644 --- a/static/js/components/ContextMenu/LoraContextMenu.js +++ b/static/js/components/ContextMenu/LoraContextMenu.js @@ -1,12 +1,12 @@ import { BaseContextMenu } from './BaseContextMenu.js'; import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; -import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview, resetAndReload } from '../../api/loraApi.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; import { copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js'; import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js'; export class LoraContextMenu extends BaseContextMenu { constructor() { - super('loraContextMenu', '.lora-card'); + super('loraContextMenu', '.model-card'); this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.modelType = 'lora'; this.resetAndReload = resetAndReload; @@ -19,7 +19,7 @@ export class LoraContextMenu extends BaseContextMenu { // Use the saveModelMetadata implementation from loraApi async saveModelMetadata(filePath, data) { - return saveModelMetadata(filePath, data); + return getModelApiClient().saveModelMetadata(filePath, data); } handleMenuAction(action, menuItem) { @@ -48,7 +48,7 @@ export class LoraContextMenu extends BaseContextMenu { break; case 'replace-preview': // Add a new action for replacing preview images - replacePreview(this.currentCard.dataset.filepath); + getModelApiClient().replaceModelPreview(this.currentCard.dataset.filepath); break; case 'delete': // Call showDeleteModal directly instead of clicking the trash button @@ -58,7 +58,7 @@ export class LoraContextMenu extends BaseContextMenu { moveManager.showMoveModal(this.currentCard.dataset.filepath); break; case 'refresh-metadata': - refreshSingleLoraMetadata(this.currentCard.dataset.filepath); + getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath); break; case 'exclude': showExcludeModal(this.currentCard.dataset.filepath); diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 63d5795a..953d380a 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -7,7 +7,7 @@ import { state } from '../../state/index.js'; export class RecipeContextMenu extends BaseContextMenu { constructor() { - super('recipeContextMenu', '.lora-card'); + super('recipeContextMenu', '.model-card'); this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.modelType = 'recipe'; @@ -209,9 +209,9 @@ export class RecipeContextMenu extends BaseContextMenu { // Determine which endpoint to use based on available data if (lora.modelVersionId) { - endpoint = `/api/civitai/model/version/${lora.modelVersionId}`; + endpoint = `/api/loras/civitai/model/version/${lora.modelVersionId}`; } else if (lora.hash) { - endpoint = `/api/civitai/model/hash/${lora.hash}`; + endpoint = `/api/loras/civitai/model/hash/${lora.hash}`; } else { console.error("Missing both hash and modelVersionId for lora:", lora); return null; diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index 6b7f165b..af539a53 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -1,4 +1,5 @@ export { LoraContextMenu } from './LoraContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js'; export { CheckpointContextMenu } from './CheckpointContextMenu.js'; +export { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; \ No newline at end of file diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index 477e7cd7..49fb22b2 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -243,7 +243,7 @@ export class DuplicatesManager { checkboxes.forEach(checkbox => { checkbox.checked = !allSelected; const recipeId = checkbox.dataset.recipeId; - const card = checkbox.closest('.lora-card'); + const card = checkbox.closest('.model-card'); if (!allSelected) { this.selectedForDeletion.add(recipeId); @@ -268,7 +268,7 @@ export class DuplicatesManager { checkboxes.forEach(checkbox => { checkbox.checked = true; this.selectedForDeletion.add(checkbox.dataset.recipeId); - checkbox.closest('.lora-card').classList.add('duplicate-selected'); + checkbox.closest('.model-card').classList.add('duplicate-selected'); }); // Update the button text @@ -299,7 +299,7 @@ export class DuplicatesManager { if (checkbox) { checkbox.checked = true; this.selectedForDeletion.add(recipeId); - checkbox.closest('.lora-card').classList.add('duplicate-selected'); + checkbox.closest('.model-card').classList.add('duplicate-selected'); } } @@ -310,7 +310,7 @@ export class DuplicatesManager { if (latestCheckbox) { latestCheckbox.checked = false; this.selectedForDeletion.delete(latestId); - latestCheckbox.closest('.lora-card').classList.remove('duplicate-selected'); + latestCheckbox.closest('.model-card').classList.remove('duplicate-selected'); } this.updateSelectedCount(); diff --git a/static/js/components/Header.js b/static/js/components/Header.js index f95e3053..a4722a28 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -26,6 +26,7 @@ export class HeaderManager { const path = window.location.pathname; if (path.includes('/loras/recipes')) return 'recipes'; if (path.includes('/checkpoints')) return 'checkpoints'; + if (path.includes('/embeddings')) return 'embeddings'; if (path.includes('/statistics')) return 'statistics'; if (path.includes('/loras')) return 'loras'; return 'unknown'; diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js deleted file mode 100644 index e200724f..00000000 --- a/static/js/components/LoraCard.js +++ /dev/null @@ -1,17 +0,0 @@ -// Legacy LoraCard.js - now using shared ModelCard component -import { - createModelCard, - setupModelCardEventDelegation, - updateCardsForBulkMode -} from './shared/ModelCard.js'; - -// Re-export functions with original names for backwards compatibility -export function createLoraCard(lora) { - return createModelCard(lora, 'lora'); -} - -export function setupLoraCardEventDelegation() { - setupModelCardEventDelegation('lora'); -} - -export { updateCardsForBulkMode }; \ No newline at end of file diff --git a/static/js/components/ModelDuplicatesManager.js b/static/js/components/ModelDuplicatesManager.js index 13725a86..06596642 100644 --- a/static/js/components/ModelDuplicatesManager.js +++ b/static/js/components/ModelDuplicatesManager.js @@ -2,8 +2,7 @@ import { showToast } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { formatDate } from '../utils/formatters.js'; -import { resetAndReload as resetAndReloadLoras } from '../api/loraApi.js'; -import { resetAndReload as resetAndReloadCheckpoints } from '../api/checkpointApi.js'; +import { resetAndReload} from '../api/baseModelApi.js'; import { LoadingManager } from '../managers/LoadingManager.js'; export class ModelDuplicatesManager { @@ -331,7 +330,7 @@ export class ModelDuplicatesManager { renderModelCard(model, groupHash) { // Create basic card structure const card = document.createElement('div'); - card.className = 'lora-card duplicate'; + card.className = 'model-card duplicate'; card.dataset.hash = model.sha256; card.dataset.filePath = model.file_path; @@ -550,7 +549,7 @@ export class ModelDuplicatesManager { checkboxes.forEach(checkbox => { checkbox.checked = !allSelected; const filePath = checkbox.dataset.filePath; - const card = checkbox.closest('.lora-card'); + const card = checkbox.closest('.model-card'); if (!allSelected) { this.selectedForDeletion.add(filePath); @@ -622,12 +621,7 @@ export class ModelDuplicatesManager { // If models were successfully deleted if (data.total_deleted > 0) { - // Reload model data with updated folders - if (this.modelType === 'loras') { - await resetAndReloadLoras(true); - } else { - await resetAndReloadCheckpoints(true); - } + await resetAndReload(true); // Check if there are still duplicates try { diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 84edc925..d8fd9b56 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -17,7 +17,7 @@ class RecipeCard { createCardElement() { const card = document.createElement('div'); - card.className = 'lora-card'; + card.className = 'model-card'; card.dataset.filepath = this.recipe.file_path; card.dataset.title = this.recipe.title; card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0; diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index ce98a11c..00ad0b0b 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -831,9 +831,9 @@ class RecipeModal { // Determine which endpoint to use based on available data if (lora.modelVersionId) { - endpoint = `/api/civitai/model/version/${lora.modelVersionId}`; + endpoint = `/api/loras/civitai/model/version/${lora.modelVersionId}`; } else if (lora.hash) { - endpoint = `/api/civitai/model/hash/${lora.hash}`; + endpoint = `/api/loras/civitai/model/hash/${lora.hash}`; } else { console.error("Missing both hash and modelVersionId for lora:", lora); return null; diff --git a/static/js/components/alphabet/AlphabetBar.js b/static/js/components/alphabet/AlphabetBar.js index be1fa0f3..44f8803d 100644 --- a/static/js/components/alphabet/AlphabetBar.js +++ b/static/js/components/alphabet/AlphabetBar.js @@ -1,7 +1,7 @@ // AlphabetBar.js - Component for alphabet filtering -import { getCurrentPageState, setCurrentPageType } from '../../state/index.js'; +import { getCurrentPageState } from '../../state/index.js'; import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js'; -import { resetAndReload } from '../../api/loraApi.js'; +import { resetAndReload } from '../../api/baseModelApi.js'; /** * AlphabetBar class - Handles the alphabet filtering UI and interactions @@ -227,7 +227,7 @@ export class AlphabetBar { this.updateToggleIndicator(); // Trigger a reload with the new filter - resetAndReload(true); + resetAndReload(false); } /** diff --git a/static/js/components/checkpointModal/index.js b/static/js/components/checkpointModal/index.js deleted file mode 100644 index 8feaf8a2..00000000 --- a/static/js/components/checkpointModal/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * CheckpointModal - Main entry point - * - * Legacy CheckpointModal - now using shared ModelModal component - */ -import { showModelModal } from '../shared/ModelModal.js'; - -// Re-export function with original name for backwards compatibility -export function showCheckpointModal(checkpoint) { - return showModelModal(checkpoint, 'checkpoint'); -} \ No newline at end of file diff --git a/static/js/components/controls/CheckpointsControls.js b/static/js/components/controls/CheckpointsControls.js index f5af38a8..492434dc 100644 --- a/static/js/components/controls/CheckpointsControls.js +++ b/static/js/components/controls/CheckpointsControls.js @@ -1,8 +1,8 @@ // CheckpointsControls.js - Specific implementation for the Checkpoints page import { PageControls } from './PageControls.js'; -import { loadMoreCheckpoints, resetAndReload, refreshCheckpoints, fetchCivitai } from '../../api/checkpointApi.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; import { showToast } from '../../utils/uiHelpers.js'; -import { CheckpointDownloadManager } from '../../managers/CheckpointDownloadManager.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * CheckpointsControls class - Extends PageControls for Checkpoint-specific functionality @@ -12,9 +12,6 @@ export class CheckpointsControls extends PageControls { // Initialize with 'checkpoints' page type super('checkpoints'); - // Initialize checkpoint download manager - this.downloadManager = new CheckpointDownloadManager(); - // Register API methods specific to the Checkpoints page this.registerCheckpointsAPI(); } @@ -26,7 +23,7 @@ export class CheckpointsControls extends PageControls { const checkpointsAPI = { // Core API functions loadMoreModels: async (resetPage = false, updateFolders = false) => { - return await loadMoreCheckpoints(resetPage, updateFolders); + return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders); }, resetAndReload: async (updateFolders = false) => { @@ -34,17 +31,17 @@ export class CheckpointsControls extends PageControls { }, refreshModels: async (fullRebuild = false) => { - return await refreshCheckpoints(fullRebuild); + return await getModelApiClient().refreshModels(fullRebuild); }, // Add fetch from Civitai functionality for checkpoints fetchFromCivitai: async () => { - return await fetchCivitai(); + return await getModelApiClient().fetchCivitaiMetadata(); }, // Add show download modal functionality showDownloadModal: () => { - this.downloadManager.showDownloadModal(); + downloadManager.showDownloadModal(); }, // No clearCustomFilter implementation is needed for checkpoints diff --git a/static/js/components/controls/EmbeddingsControls.js b/static/js/components/controls/EmbeddingsControls.js new file mode 100644 index 00000000..c5b618e4 --- /dev/null +++ b/static/js/components/controls/EmbeddingsControls.js @@ -0,0 +1,57 @@ +// EmbeddingsControls.js - Specific implementation for the Embeddings page +import { PageControls } from './PageControls.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; + +/** + * EmbeddingsControls class - Extends PageControls for Embedding-specific functionality + */ +export class EmbeddingsControls extends PageControls { + constructor() { + // Initialize with 'embeddings' page type + super('embeddings'); + + // Register API methods specific to the Embeddings page + this.registerEmbeddingsAPI(); + } + + /** + * Register Embedding-specific API methods + */ + registerEmbeddingsAPI() { + const embeddingsAPI = { + // Core API functions + loadMoreModels: async (resetPage = false, updateFolders = false) => { + return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders); + }, + + resetAndReload: async (updateFolders = false) => { + return await resetAndReload(updateFolders); + }, + + refreshModels: async (fullRebuild = false) => { + return await getModelApiClient().refreshModels(fullRebuild); + }, + + // Add fetch from Civitai functionality for embeddings + fetchFromCivitai: async () => { + return await getModelApiClient().fetchCivitaiMetadata(); + }, + + // Add show download modal functionality + showDownloadModal: () => { + downloadManager.showDownloadModal(); + }, + + // No clearCustomFilter implementation is needed for embeddings + // as custom filters are currently only used for LoRAs + clearCustomFilter: async () => { + showToast('No custom filter to clear', 'info'); + } + }; + + // Register the API + this.registerAPI(embeddingsAPI); + } +} diff --git a/static/js/components/controls/LorasControls.js b/static/js/components/controls/LorasControls.js index 22f768d4..8ec51983 100644 --- a/static/js/components/controls/LorasControls.js +++ b/static/js/components/controls/LorasControls.js @@ -1,8 +1,9 @@ // LorasControls.js - Specific implementation for the LoRAs page import { PageControls } from './PageControls.js'; -import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; +import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { createAlphabetBar } from '../alphabet/index.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; /** * LorasControls class - Extends PageControls for LoRA-specific functionality @@ -29,7 +30,7 @@ export class LorasControls extends PageControls { const lorasAPI = { // Core API functions loadMoreModels: async (resetPage = false, updateFolders = false) => { - return await loadMoreLoras(resetPage, updateFolders); + return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders); }, resetAndReload: async (updateFolders = false) => { @@ -37,20 +38,16 @@ export class LorasControls extends PageControls { }, refreshModels: async (fullRebuild = false) => { - return await refreshLoras(fullRebuild); + return await getModelApiClient().refreshModels(fullRebuild); }, // LoRA-specific API functions fetchFromCivitai: async () => { - return await fetchCivitai(); + return await getModelApiClient().fetchCivitaiMetadata(); }, showDownloadModal: () => { - if (window.downloadManager) { - window.downloadManager.showDownloadModal(); - } else { - console.error('Download manager not available'); - } + downloadManager.showDownloadModal(); }, toggleBulkMode: () => { diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 53a2d8ef..b39a8f57 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -242,7 +242,7 @@ export class PageControls { * @param {string} folderPath - Folder path to filter by */ filterByFolder(folderPath) { - const cardSelector = this.pageType === 'loras' ? '.lora-card' : '.checkpoint-card'; + const cardSelector = this.pageType === 'loras' ? '.model-card' : '.checkpoint-card'; document.querySelectorAll(cardSelector).forEach(card => { card.style.display = card.dataset.folder === folderPath ? '' : 'none'; }); @@ -374,7 +374,7 @@ export class PageControls { openCivitai(modelName) { // Get card selector based on page type const cardSelector = this.pageType === 'loras' - ? `.lora-card[data-name="${modelName}"]` + ? `.model-card[data-name="${modelName}"]` : `.checkpoint-card[data-name="${modelName}"]`; const card = document.querySelector(cardSelector); diff --git a/static/js/components/controls/index.js b/static/js/components/controls/index.js index c767c62f..97f1ca91 100644 --- a/static/js/components/controls/index.js +++ b/static/js/components/controls/index.js @@ -2,13 +2,14 @@ import { PageControls } from './PageControls.js'; import { LorasControls } from './LorasControls.js'; import { CheckpointsControls } from './CheckpointsControls.js'; +import { EmbeddingsControls } from './EmbeddingsControls.js'; // Export the classes -export { PageControls, LorasControls, CheckpointsControls }; +export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls }; /** * Factory function to create the appropriate controls based on page type - * @param {string} pageType - The type of page ('loras' or 'checkpoints') + * @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings') * @returns {PageControls} - The appropriate controls instance */ export function createPageControls(pageType) { @@ -16,6 +17,8 @@ export function createPageControls(pageType) { return new LorasControls(); } else if (pageType === 'checkpoints') { return new CheckpointsControls(); + } else if (pageType === 'embeddings') { + return new EmbeddingsControls(); } else { console.error(`Unknown page type: ${pageType}`); return null; diff --git a/static/js/components/initialization.js b/static/js/components/initialization.js index 78c48016..e7b6818f 100644 --- a/static/js/components/initialization.js +++ b/static/js/components/initialization.js @@ -4,7 +4,6 @@ */ import { appCore } from '../core.js'; import { getSessionItem, setSessionItem } from '../utils/storageHelpers.js'; -import { state, getCurrentPageState } from '../state/index.js'; class InitializationManager { constructor() { diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js deleted file mode 100644 index 5a2467de..00000000 --- a/static/js/components/loraModal/index.js +++ /dev/null @@ -1,7 +0,0 @@ -// Legacy LoraModal - now using shared ModelModal component -import { showModelModal } from '../shared/ModelModal.js'; - -// Re-export function with original name for backwards compatibility -export function showLoraModal(lora) { - return showModelModal(lora, 'lora'); -} \ No newline at end of file diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index d0f5a125..84cad5fa 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -4,8 +4,7 @@ import { showModelModal } from './ModelModal.js'; import { bulkManager } from '../../managers/BulkManager.js'; import { modalManager } from '../../managers/ModalManager.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; -import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { showDeleteModal } from '../../utils/modalUtils.js'; // Add global event delegation handlers @@ -29,7 +28,7 @@ export function setupModelCardEventDelegation(modelType) { // Event delegation handler for all model card events function handleModelCardEvent_internal(event, modelType) { // Find the closest card element - const card = event.target.closest('.lora-card'); + const card = event.target.closest('.model-card'); if (!card) return; // Handle specific elements within the card @@ -47,7 +46,7 @@ function handleModelCardEvent_internal(event, modelType) { if (event.target.closest('.fa-star')) { event.stopPropagation(); - toggleFavorite(card, modelType); + toggleFavorite(card); return; } @@ -73,13 +72,13 @@ function handleModelCardEvent_internal(event, modelType) { if (event.target.closest('.fa-trash')) { event.stopPropagation(); - showDeleteModal(card.dataset.filepath, modelType); + showDeleteModal(card.dataset.filepath); return; } if (event.target.closest('.fa-image')) { event.stopPropagation(); - handleReplacePreview(card.dataset.filepath, modelType); + getModelApiClient().replaceModelPreview(card.dataset.filepath); return; } @@ -130,15 +129,13 @@ function showBlurredContent(card) { } } -async function toggleFavorite(card, modelType) { +async function toggleFavorite(card) { const starIcon = card.querySelector('.fa-star'); const isFavorite = starIcon.classList.contains('fas'); const newFavoriteState = !isFavorite; try { - // Use the appropriate save function based on model type - const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; - await saveFunction(card.dataset.filepath, { + await getModelApiClient().saveModelMetadata(card.dataset.filepath, { favorite: newFavoriteState }); @@ -154,7 +151,7 @@ async function toggleFavorite(card, modelType) { } function handleSendToWorkflow(card, replaceMode, modelType) { - if (modelType === 'lora') { + if (modelType === 'loras') { const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); const strength = usageTips.strength || 1; const loraSyntax = ``; @@ -166,28 +163,23 @@ function handleSendToWorkflow(card, replaceMode, modelType) { } function handleCopyAction(card, modelType) { - if (modelType === 'lora') { + if (modelType === 'loras') { const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); const strength = usageTips.strength || 1; const loraSyntax = ``; copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard'); - } else { + } else if (modelType === 'checkpoints') { // Checkpoint copy functionality - copy checkpoint name const checkpointName = card.dataset.file_name; copyToClipboard(checkpointName, 'Checkpoint name copied'); + } else if (modelType === 'embeddings') { + const embeddingName = card.dataset.file_name; + copyToClipboard(embeddingName, 'Embedding name copied'); } } function handleReplacePreview(filePath, modelType) { - if (modelType === 'lora') { - replacePreview(filePath); - } else { - if (window.replaceCheckpointPreview) { - window.replaceCheckpointPreview(filePath); - } else { - apiReplaceCheckpointPreview(filePath); - } - } + apiClient.replaceModelPreview(filePath); } async function handleExampleImagesAccess(card, modelType) { @@ -225,7 +217,7 @@ function handleCardClick(card, modelType) { function showModelModalFromCard(card, modelType) { // Get the appropriate preview versions map - const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints'; + const previewVersionsKey = modelType; const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map(); const version = previewVersions.get(card.dataset.filepath); const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png'; @@ -370,7 +362,7 @@ function showExampleAccessModal(card, modelType) { export function createModelCard(model, modelType) { const card = document.createElement('div'); - card.className = 'lora-card'; // Reuse the same class for styling + card.className = 'model-card'; // Reuse the same class for styling card.dataset.sha256 = model.sha256; card.dataset.filepath = model.file_path; card.dataset.name = model.model_name; @@ -380,11 +372,11 @@ export function createModelCard(model, modelType) { card.dataset.file_size = model.file_size; card.dataset.from_civitai = model.from_civitai; card.dataset.notes = model.notes || ''; - card.dataset.base_model = model.base_model || (modelType === 'checkpoint' ? 'Unknown' : ''); + card.dataset.base_model = model.base_model || 'Unknown'; card.dataset.favorite = model.favorite ? 'true' : 'false'; // LoRA specific data - if (modelType === 'lora') { + if (modelType === 'loras') { card.dataset.usage_tips = model.usage_tips; } @@ -413,12 +405,12 @@ export function createModelCard(model, modelType) { } // Apply selection state if in bulk mode and this card is in the selected set (LoRA only) - if (modelType === 'lora' && state.bulkMode && state.selectedLoras.has(model.file_path)) { + if (modelType === 'loras' && state.bulkMode && state.selectedLoras.has(model.file_path)) { card.classList.add('selected'); } // Get the appropriate preview versions map - const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints'; + const previewVersionsKey = modelType; const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map(); const version = previewVersions.get(model.file_path); const previewUrl = model.preview_url || '/loras_static/images/no-preview.png'; @@ -443,8 +435,8 @@ export function createModelCard(model, modelType) { const isFavorite = model.favorite === true; // Generate action icons based on model type - const actionIcons = modelType === 'lora' ? - ` - ` : - ` - - - - - - `; card.innerHTML = ` @@ -537,7 +516,7 @@ export function updateCardsForBulkMode(isBulkMode) { document.body.classList.toggle('bulk-mode', isBulkMode); // Get all lora cards - this can now be from the DOM or through the virtual scroller - const loraCards = document.querySelectorAll('.lora-card'); + const loraCards = document.querySelectorAll('.model-card'); loraCards.forEach(card => { // Get all action containers for this card diff --git a/static/js/components/shared/ModelDescription.js b/static/js/components/shared/ModelDescription.js index 05acc224..a7179805 100644 --- a/static/js/components/shared/ModelDescription.js +++ b/static/js/components/shared/ModelDescription.js @@ -40,65 +40,4 @@ export function setupTabSwitching() { } }); }); -} - -/** - * Load model description - General version supports both LoRA and Checkpoint - * @param {string} modelId - Model ID - * @param {string} filePath - File path - */ -export async function loadModelDescription(modelId, filePath) { - try { - const descriptionContainer = document.querySelector('.model-description-content'); - const loadingElement = document.querySelector('.model-description-loading'); - - if (!descriptionContainer || !loadingElement) return; - - // Show loading indicator - loadingElement.classList.remove('hidden'); - descriptionContainer.classList.add('hidden'); - - // Determine API endpoint based on file path or context - let apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`; - - // Try to get model description from API - const response = await fetch(apiEndpoint); - - if (!response.ok) { - throw new Error(`Failed to fetch model description: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success && data.description) { - // Update the description content - descriptionContainer.innerHTML = data.description; - - // Process any links in the description to open in new tab - const links = descriptionContainer.querySelectorAll('a'); - links.forEach(link => { - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer'); - }); - - // Show the description and hide loading indicator - descriptionContainer.classList.remove('hidden'); - loadingElement.classList.add('hidden'); - } else { - throw new Error(data.error || 'No description available'); - } - } catch (error) { - console.error('Error loading model description:', error); - const loadingElement = document.querySelector('.model-description-loading'); - if (loadingElement) { - loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; - } - - // Show empty state message in the description container - const descriptionContainer = document.querySelector('.model-description-content'); - if (descriptionContainer) { - descriptionContainer.innerHTML = '
No model description available
'; - descriptionContainer.classList.remove('hidden'); - } - } } \ No newline at end of file diff --git a/static/js/components/shared/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js index d6cdf1f4..17b33817 100644 --- a/static/js/components/shared/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -4,9 +4,7 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; -import { state } from '../../state/index.js'; -import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js'; -import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Set up model name editing functionality @@ -114,9 +112,7 @@ export function setupModelNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - - await saveFunction(filePath, { model_name: newModelName }); + await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName }); showToast('Model name updated successfully', 'success'); } catch (error) { @@ -295,9 +291,7 @@ async function saveBaseModel(filePath, originalValue) { } try { - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - - await saveFunction(filePath, { base_model: newBaseModel }); + await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel }); showToast('Base model updated successfully', 'success'); } catch (error) { @@ -417,29 +411,7 @@ export function setupFileNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - let result; - - if (state.currentPageType === 'checkpoints') { - result = await renameCheckpointFile(filePath, newFileName); - } else { - // Use LoRA rename function - result = await renameLoraFile(filePath, newFileName); - } - - if (result.success) { - showToast('File name updated successfully', 'success'); - - // Update virtual scroller if available (mainly for LoRAs) - if (state.virtualScroller && typeof state.virtualScroller.updateSingleItem === 'function') { - const newFilePath = filePath.replace(originalValue, newFileName); - state.virtualScroller.updateSingleItem(filePath, { - file_name: newFileName, - file_path: newFilePath - }); - } - } else { - throw new Error(result.error || 'Unknown error'); - } + await getModelApiClient().renameModelFile(filePath, newFileName); } catch (error) { console.error('Error renaming file:', error); this.textContent = originalValue; // Restore original file name diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 6fcdf419..d6246487 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -6,15 +6,14 @@ import { scrollToTop, loadExampleImages } from './showcase/ShowcaseView.js'; -import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; +import { setupTabSwitching } from './ModelDescription.js'; import { setupModelNameEditing, setupBaseModelEditing, setupFileNameEditing } from './ModelMetadata.js'; import { setupTagEditMode } from './ModelTags.js'; -import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; @@ -30,21 +29,29 @@ export function showModelModal(model, modelType) { const modalTitle = model.model_name; // Prepare LoRA specific data - const escapedWords = modelType === 'lora' && model.civitai?.trainedWords?.length ? + const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && model.civitai?.trainedWords?.length ? model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; // Generate model type specific content - const typeSpecificContent = modelType === 'lora' ? renderLoraSpecificContent(model, escapedWords) : ''; + // const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : ''; + let typeSpecificContent; + if (modelType === 'loras') { + typeSpecificContent = renderLoraSpecificContent(model, escapedWords); + } else if (modelType === 'embeddings') { + typeSpecificContent = renderEmbeddingSpecificContent(model, escapedWords); + } else { + typeSpecificContent = ''; + } // Generate tabs based on model type - const tabsContent = modelType === 'lora' ? + const tabsContent = modelType === 'loras' ? ` ` : ` `; - const tabPanesContent = modelType === 'lora' ? + const tabPanesContent = modelType === 'loras' ? `
Loading example images... @@ -144,7 +151,7 @@ export function showModelModal(model, modelType) {
- ${model.base_model || (modelType === 'checkpoint' ? 'Unknown' : 'N/A')} + ${model.base_model || 'Unknown'} @@ -207,16 +214,13 @@ export function showModelModal(model, modelType) { setupEventHandlers(model.file_path); // LoRA specific setup - if (modelType === 'lora') { + if (modelType === 'loras' || modelType === 'embeddings') { setupTriggerWordsEditMode(); - // Load recipes for this LoRA - loadRecipesForLora(model.model_name, model.sha256); - } - - // If we have a model ID but no description, fetch it - if (model.civitai?.modelId && !model.modelDescription) { - loadModelDescription(model.civitai.modelId, model.file_path); + if (modelType == 'loras') { + // Load recipes for this LoRA + loadRecipesForLora(model.model_name, model.sha256); + } } // Load example images asynchronously - merge regular and custom images @@ -252,6 +256,10 @@ function renderLoraSpecificContent(lora, escapedWords) { `; } +function renderEmbeddingSpecificContent(embedding, escapedWords) { + return `${renderTriggerWords(escapedWords, embedding.file_path)}`; +} + /** * Sets up event handlers using event delegation for LoRA modal * @param {string} filePath - Path to the model file @@ -298,7 +306,7 @@ function setupEventHandlers(filePath) { /** * Set up editable fields (notes and usage tips) in the model modal * @param {string} filePath - The full file path of the model - * @param {string} modelType - Type of model ('lora' or 'checkpoint') + * @param {string} modelType - Type of model ('loras' or 'checkpoints' or 'embeddings') */ function setupEditableFields(filePath, modelType) { const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); @@ -329,13 +337,13 @@ function setupEditableFields(filePath, modelType) { return; } e.preventDefault(); - await saveNotes(filePath, modelType); + await saveNotes(filePath); } }); } // LoRA specific field setup - if (modelType === 'lora') { + if (modelType === 'loras') { setupLoraSpecificFields(filePath); } } @@ -372,15 +380,13 @@ function setupLoraSpecificFields(filePath) { if (!key || !value) return; - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`); const currentPresets = parsePresets(loraCard?.dataset.usage_tips); currentPresets[key] = parseFloat(value); const newPresetsJson = JSON.stringify(currentPresets); - await saveLoraMetadata(filePath, { - usage_tips: newPresetsJson - }); + await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson }); presetTags.innerHTML = renderPresetTags(currentPresets); @@ -401,13 +407,11 @@ function setupLoraSpecificFields(filePath) { /** * Save model notes * @param {string} filePath - Path to the model file - * @param {string} modelType - Type of model ('lora' or 'checkpoint') */ -async function saveNotes(filePath, modelType) { +async function saveNotes(filePath) { const content = document.querySelector('.notes-content').textContent; try { - const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; - await saveFunction(filePath, { notes: content }); + await getModelApiClient().saveModelMetadata(filePath, { notes: content }); showToast('Notes saved successfully', 'success'); } catch (error) { @@ -422,35 +426,4 @@ const modelModal = { scrollToTop }; -export { modelModal }; - -// Define global functions for use in HTML -window.toggleShowcase = function(element) { - toggleShowcase(element); -}; - -window.scrollToTopModel = function(button) { - scrollToTop(button); -}; - -// Legacy global functions for backward compatibility -window.scrollToTopLora = function(button) { - scrollToTop(button); -}; - -window.scrollToTopCheckpoint = function(button) { - scrollToTop(button); -}; - -window.saveModelNotes = function(filePath, modelType) { - saveNotes(filePath, modelType); -}; - -// Legacy functions -window.saveLoraNotes = function(filePath) { - saveNotes(filePath, 'lora'); -}; - -window.saveCheckpointNotes = function(filePath) { - saveNotes(filePath, 'checkpoint'); -}; \ No newline at end of file +export { modelModal }; \ No newline at end of file diff --git a/static/js/components/shared/ModelTags.js b/static/js/components/shared/ModelTags.js index 60da2244..c58889d2 100644 --- a/static/js/components/shared/ModelTags.js +++ b/static/js/components/shared/ModelTags.js @@ -3,9 +3,7 @@ * Module for handling model tag editing functionality - 共享版本 */ import { showToast } from '../../utils/uiHelpers.js'; -import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; -import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; -import { state } from '../../state/index.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; // Preset tag suggestions const PRESET_TAGS = [ @@ -165,10 +163,8 @@ async function saveTags() { } try { - const saveFunction = state.currentPageType === 'checkpoints' ? saveCheckpointMetadata : saveLoraMetadata; - // Save tags metadata - await saveFunction(filePath, { tags: tags }); + await getModelApiClient().saveModelMetadata(filePath, { tags: tags }); // Set flag to skip restoring original tags when exiting edit mode editBtn.dataset.skipRestore = "true"; diff --git a/static/js/components/shared/PresetTags.js b/static/js/components/shared/PresetTags.js index 12d75c1f..fb682ee8 100644 --- a/static/js/components/shared/PresetTags.js +++ b/static/js/components/shared/PresetTags.js @@ -2,7 +2,7 @@ * PresetTags.js * Handles LoRA model preset parameter tags - Shared version */ -import { saveModelMetadata } from '../../api/loraApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Parse preset parameters @@ -52,13 +52,13 @@ window.removePreset = async function(key) { .querySelector('.file-path').textContent + document.querySelector('#modelModal .modal-content') .querySelector('#file-name').textContent + '.safetensors'; - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`); const currentPresets = parsePresets(loraCard.dataset.usage_tips); delete currentPresets[key]; const newPresetsJson = JSON.stringify(currentPresets); - await saveModelMetadata(filePath, { + await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson }); diff --git a/static/js/components/shared/RecipeTab.js b/static/js/components/shared/RecipeTab.js index 661d4169..17e38597 100644 --- a/static/js/components/shared/RecipeTab.js +++ b/static/js/components/shared/RecipeTab.js @@ -96,7 +96,7 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) { // Create card element matching the structure in recipes.html const card = document.createElement('div'); - card.className = 'lora-card'; + card.className = 'model-card'; card.dataset.filePath = recipe.file_path || ''; card.dataset.title = recipe.title || ''; card.dataset.created = recipe.created_date || ''; diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js index e011f700..f27db73d 100644 --- a/static/js/components/shared/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -4,7 +4,7 @@ * Moved to shared directory for consistency */ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; -import { saveModelMetadata } from '../../api/loraApi.js'; +import { getModelApiClient } from '../../api/baseModelApi.js'; /** * Fetch trained words for a model @@ -610,7 +610,7 @@ async function saveTriggerWords() { try { // Special format for updating nested civitai.trainedWords - await saveModelMetadata(filePath, { + await getModelApiClient().saveModelMetadata(filePath, { civitai: { trainedWords: words } }); diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js index e7580262..5a19749f 100644 --- a/static/js/components/shared/showcase/MediaUtils.js +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -5,7 +5,7 @@ */ import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; -import { uploadPreview } from '../../../api/baseModelApi.js'; +import { getModelApiClient } from '../../../api/baseModelApi.js'; /** * Try to load local image first, fall back to remote if local fails @@ -515,6 +515,7 @@ function initSetPreviewHandlers(container) { // Get local file path if available const useLocalFile = mediaElement.dataset.localSrc && !mediaElement.dataset.localSrc.includes('undefined'); + const apiClient = getModelApiClient(); if (useLocalFile) { // We have a local file, use it directly @@ -523,7 +524,7 @@ function initSetPreviewHandlers(container) { const file = new File([blob], 'preview.jpg', { type: blob.type }); // Use the existing baseModelApi uploadPreview method with nsfw level - await uploadPreview(modelFilePath, file, modelType, nsfwLevel); + await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); } else { // We need to download the remote file first const response = await fetch(mediaElement.src); @@ -531,7 +532,7 @@ function initSetPreviewHandlers(container) { const file = new File([blob], 'preview.jpg', { type: blob.type }); // Use the existing baseModelApi uploadPreview method with nsfw level - await uploadPreview(modelFilePath, file, modelType, nsfwLevel); + await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); } } catch (error) { console.error('Error setting preview:', error); diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index 16b67753..d57725e8 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -112,7 +112,7 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
` : ''; return ` -
+
Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples
@@ -479,6 +479,16 @@ export function initShowcaseContent(carousel) { initMetadataPanelHandlers(carousel); initMediaControlHandlers(carousel); positionAllMediaControls(carousel); + + // Bind scroll-indicator click to toggleShowcase + const scrollIndicator = carousel.previousElementSibling; + if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) { + // Remove previous click listeners to avoid duplicates + scrollIndicator.onclick = null; + scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler); + scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator); + scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler); + } // Add window resize handler const resizeHandler = () => positionAllMediaControls(carousel); diff --git a/static/js/core.js b/static/js/core.js index 674b484a..9a268217 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -68,7 +68,7 @@ export class AppCore { const pageType = this.getPageType(); // Initialize virtual scroll for pages that need it - if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { + if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) { initializeInfiniteScroll(pageType); } diff --git a/static/js/embeddings.js b/static/js/embeddings.js new file mode 100644 index 00000000..f32fd670 --- /dev/null +++ b/static/js/embeddings.js @@ -0,0 +1,55 @@ +import { appCore } from './core.js'; +import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; +import { createPageControls } from './components/controls/index.js'; +import { EmbeddingContextMenu } from './components/ContextMenu/index.js'; +import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { MODEL_TYPES } from './api/apiConfig.js'; + +// Initialize the Embeddings page +class EmbeddingsPageManager { + constructor() { + // Initialize page controls + this.pageControls = createPageControls(MODEL_TYPES.EMBEDDING); + + // Initialize the ModelDuplicatesManager + this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.EMBEDDING); + + // Expose only necessary functions to global scope + this._exposeRequiredGlobalFunctions(); + } + + _exposeRequiredGlobalFunctions() { + // Minimal set of functions that need to remain global + window.confirmDelete = confirmDelete; + window.closeDeleteModal = closeDeleteModal; + window.confirmExclude = confirmExclude; + window.closeExcludeModal = closeExcludeModal; + + // Expose duplicates manager + window.modelDuplicatesManager = this.duplicatesManager; + } + + async initialize() { + // Initialize page-specific components + this.pageControls.restoreFolderFilter(); + this.pageControls.initFolderTagsVisibility(); + + // Initialize context menu + new EmbeddingContextMenu(); + + // Initialize common page features + appCore.initializePageFeatures(); + + console.log('Embeddings Manager initialized'); + } +} + +// Initialize everything when DOM is ready +document.addEventListener('DOMContentLoaded', async () => { + // Initialize core application + await appCore.initialize(); + + // Initialize embeddings page + const embeddingsPage = new EmbeddingsPageManager(); + await embeddingsPage.initialize(); +}); diff --git a/static/js/loras.js b/static/js/loras.js index c5e2f77a..c26a25c6 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -1,9 +1,7 @@ import { appCore } from './core.js'; import { state } from './state/index.js'; -import { loadMoreLoras } from './api/loraApi.js'; -import { updateCardsForBulkMode } from './components/LoraCard.js'; +import { updateCardsForBulkMode } from './components/shared/ModelCard.js'; import { bulkManager } from './managers/BulkManager.js'; -import { DownloadManager } from './managers/DownloadManager.js'; import { moveManager } from './managers/MoveManager.js'; import { LoraContextMenu } from './components/ContextMenu/index.js'; import { createPageControls } from './components/controls/index.js'; @@ -17,9 +15,6 @@ class LoraPageManager { state.bulkMode = false; state.selectedLoras = new Set(); - // Initialize managers - this.downloadManager = new DownloadManager(); - // Initialize page controls this.pageControls = createPageControls('loras'); @@ -34,12 +29,10 @@ class LoraPageManager { _exposeRequiredGlobalFunctions() { // Only expose what's still needed globally // Most functionality is now handled by the PageControls component - window.loadMoreLoras = loadMoreLoras; window.confirmDelete = confirmDelete; window.closeDeleteModal = closeDeleteModal; window.confirmExclude = confirmExclude; window.closeExcludeModal = closeExcludeModal; - window.downloadManager = this.downloadManager; window.moveManager = moveManager; // Bulk operations diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index fe96d971..2ce9b0b2 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -1,6 +1,6 @@ import { state } from '../state/index.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; -import { updateCardsForBulkMode } from '../components/LoraCard.js'; +import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; export class BulkManager { @@ -102,9 +102,10 @@ export class BulkManager { if (!state.bulkMode) { this.clearSelection(); + // TODO: fix this, no DOM manipulation should be done here // Force a lightweight refresh of the cards to ensure proper display // This is less disruptive than a full resetAndReload() - document.querySelectorAll('.lora-card').forEach(card => { + document.querySelectorAll('.model-card').forEach(card => { // Re-apply normal display mode to all card actions const actions = card.querySelectorAll('.card-actions, .card-button'); actions.forEach(action => action.style.display = 'flex'); @@ -113,7 +114,7 @@ export class BulkManager { } clearSelection() { - document.querySelectorAll('.lora-card.selected').forEach(card => { + document.querySelectorAll('.model-card.selected').forEach(card => { card.classList.remove('selected'); }); state.selectedLoras.clear(); @@ -189,7 +190,7 @@ export class BulkManager { applySelectionState() { if (!state.bulkMode) return; - document.querySelectorAll('.lora-card').forEach(card => { + document.querySelectorAll('.model-card').forEach(card => { const filepath = card.dataset.filepath; if (state.selectedLoras.has(filepath)) { card.classList.add('selected'); @@ -501,7 +502,7 @@ export class BulkManager { deselectItem(filepath) { // Find and deselect the corresponding card if it's in the DOM - const card = document.querySelector(`.lora-card[data-filepath="${filepath}"]`); + const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`); if (card) { card.classList.remove('selected'); } diff --git a/static/js/managers/CheckpointDownloadManager.js b/static/js/managers/CheckpointDownloadManager.js deleted file mode 100644 index 7545e617..00000000 --- a/static/js/managers/CheckpointDownloadManager.js +++ /dev/null @@ -1,463 +0,0 @@ -import { modalManager } from './ModalManager.js'; -import { showToast } from '../utils/uiHelpers.js'; -import { LoadingManager } from './LoadingManager.js'; -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/checkpointApi.js'; -import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; - -export class CheckpointDownloadManager { - constructor() { - this.currentVersion = null; - this.versions = []; - this.modelInfo = null; - this.modelVersionId = null; - - this.initialized = false; - this.selectedFolder = ''; - - this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; - this.updateTargetPath = this.updateTargetPath.bind(this); - } - - showDownloadModal() { - console.log('Showing checkpoint download modal...'); - if (!this.initialized) { - const modal = document.getElementById('checkpointDownloadModal'); - if (!modal) { - console.error('Checkpoint download modal element not found'); - return; - } - this.initialized = true; - } - - modalManager.showModal('checkpointDownloadModal', null, () => { - // Cleanup handler when modal closes - this.cleanupFolderBrowser(); - }); - this.resetSteps(); - - // Auto-focus on the URL input - setTimeout(() => { - const urlInput = document.getElementById('checkpointUrl'); - if (urlInput) { - urlInput.focus(); - } - }, 100); // Small delay to ensure the modal is fully displayed - } - - resetSteps() { - document.querySelectorAll('#checkpointDownloadModal .download-step').forEach(step => step.style.display = 'none'); - document.getElementById('cpUrlStep').style.display = 'block'; - document.getElementById('checkpointUrl').value = ''; - document.getElementById('cpUrlError').textContent = ''; - - // Clear new folder input - const newFolderInput = document.getElementById('cpNewFolder'); - if (newFolderInput) { - newFolderInput.value = ''; - } - - this.currentVersion = null; - this.versions = []; - this.modelInfo = null; - this.modelId = null; - this.modelVersionId = null; - - // Clear selected folder and remove selection from UI - this.selectedFolder = ''; - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (folderBrowser) { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - } - } - - async validateAndFetchVersions() { - const url = document.getElementById('checkpointUrl').value.trim(); - const errorElement = document.getElementById('cpUrlError'); - - try { - this.loadingManager.showSimpleLoading('Fetching model versions...'); - - this.modelId = this.extractModelId(url); - if (!this.modelId) { - throw new Error('Invalid Civitai URL format'); - } - - const response = await fetch(`/api/checkpoints/civitai/versions/${this.modelId}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { - throw new Error('This model is not a Checkpoint. Please switch to the LoRAs page to download LoRA models.'); - } - throw new Error('Failed to fetch model versions'); - } - - this.versions = await response.json(); - if (!this.versions.length) { - throw new Error('No versions available for this model'); - } - - // If we have a version ID from URL, pre-select it - if (this.modelVersionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); - } - - this.showVersionStep(); - } catch (error) { - errorElement.textContent = error.message; - } finally { - this.loadingManager.hide(); - } - } - - extractModelId(url) { - const modelMatch = url.match(/civitai\.com\/models\/(\d+)/); - const versionMatch = url.match(/modelVersionId=(\d+)/); - - if (modelMatch) { - this.modelVersionId = versionMatch ? versionMatch[1] : null; - return modelMatch[1]; - } - return null; - } - - showVersionStep() { - document.getElementById('cpUrlStep').style.display = 'none'; - document.getElementById('cpVersionStep').style.display = 'block'; - - const versionList = document.getElementById('cpVersionList'); - versionList.innerHTML = this.versions.map(version => { - const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); - const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - - // Use version-level size or fallback to first file - const fileSize = version.modelSizeKB ? - (version.modelSizeKB / 1024).toFixed(2) : - (version.files[0]?.sizeKB / 1024).toFixed(2); - - // Use version-level existsLocally flag - const existsLocally = version.existsLocally; - const localPath = version.localPath; - - // Check if this is an early access version - const isEarlyAccess = version.availability === 'EarlyAccess'; - - // Create early access badge if needed - let earlyAccessBadge = ''; - if (isEarlyAccess) { - earlyAccessBadge = ` -
- Early Access -
- `; - } - - // Status badge for local models - const localStatus = existsLocally ? - `
- In Library -
${localPath || ''}
-
` : ''; - - return ` -
-
- Version preview -
-
-
-

${version.name}

- ${localStatus} -
-
- ${version.baseModel ? `
${version.baseModel}
` : ''} - ${earlyAccessBadge} -
-
- ${new Date(version.createdAt).toLocaleDateString()} - ${fileSize} MB -
-
-
- `; - }).join(''); - - // Auto-select the version if there's only one - if (this.versions.length === 1 && !this.currentVersion) { - this.selectVersion(this.versions[0].id.toString()); - } - - // Update Next button state based on initial selection - this.updateNextButtonState(); - } - - selectVersion(versionId) { - this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); - if (!this.currentVersion) return; - - document.querySelectorAll('#cpVersionList .version-item').forEach(item => { - item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); - }); - - // Update Next button state after selection - this.updateNextButtonState(); - } - - updateNextButtonState() { - const nextButton = document.querySelector('#cpVersionStep .primary-btn'); - if (!nextButton) return; - - const existsLocally = this.currentVersion?.existsLocally; - - if (existsLocally) { - nextButton.disabled = true; - nextButton.classList.add('disabled'); - nextButton.textContent = 'Already in Library'; - } else { - nextButton.disabled = false; - nextButton.classList.remove('disabled'); - nextButton.textContent = 'Next'; - } - } - - async proceedToLocation() { - if (!this.currentVersion) { - showToast('Please select a version', 'error'); - return; - } - - // Double-check if the version exists locally - const existsLocally = this.currentVersion.existsLocally; - if (existsLocally) { - showToast('This version already exists in your library', 'info'); - return; - } - - document.getElementById('cpVersionStep').style.display = 'none'; - document.getElementById('cpLocationStep').style.display = 'block'; - - try { - // Use checkpoint roots endpoint instead of lora roots - const response = await fetch('/api/checkpoints/roots'); - if (!response.ok) { - throw new Error('Failed to fetch checkpoint roots'); - } - - const data = await response.json(); - const checkpointRoot = document.getElementById('checkpointRoot'); - checkpointRoot.innerHTML = data.roots.map(root => - `` - ).join(''); - - // Set default checkpoint root if available - const defaultRoot = getStorageItem('settings', {}).default_checkpoint_root; - if (defaultRoot && data.roots.includes(defaultRoot)) { - checkpointRoot.value = defaultRoot; - } - - // Initialize folder browser after loading roots - this.initializeFolderBrowser(); - } catch (error) { - showToast(error.message, 'error'); - } - } - - backToUrl() { - document.getElementById('cpVersionStep').style.display = 'none'; - document.getElementById('cpUrlStep').style.display = 'block'; - } - - backToVersions() { - document.getElementById('cpLocationStep').style.display = 'none'; - document.getElementById('cpVersionStep').style.display = 'block'; - } - - async startDownload() { - const checkpointRoot = document.getElementById('checkpointRoot').value; - const newFolder = document.getElementById('cpNewFolder').value.trim(); - - if (!checkpointRoot) { - showToast('Please select a checkpoint root directory', 'error'); - return; - } - - // Construct relative path - let targetFolder = ''; - if (this.selectedFolder) { - targetFolder = this.selectedFolder; - } - if (newFolder) { - targetFolder = targetFolder ? - `${targetFolder}/${newFolder}` : newFolder; - } - - try { - // Show enhanced loading with progress details - const updateProgress = this.loadingManager.showDownloadProgress(1); - updateProgress(0, 0, this.currentVersion.name); - - // Generate a unique ID for this download - const downloadId = Date.now().toString(); - - // Setup WebSocket for progress updates using download-specific endpoint - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - // Handle download ID confirmation - if (data.type === 'download_id') { - console.log(`Connected to checkpoint download progress with ID: ${data.download_id}`); - return; - } - - // Only process progress updates for our download - if (data.status === 'progress' && data.download_id === downloadId) { - // Update progress display with current progress - updateProgress(data.progress, 0, this.currentVersion.name); - - // Add more detailed status messages based on progress - if (data.progress < 3) { - this.loadingManager.setStatus(`Preparing download...`); - } else if (data.progress === 3) { - this.loadingManager.setStatus(`Downloaded preview image`); - } else if (data.progress > 3 && data.progress < 100) { - this.loadingManager.setStatus(`Downloading checkpoint file`); - } else { - this.loadingManager.setStatus(`Finalizing download...`); - } - } - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - // Continue with download even if WebSocket fails - }; - - // Start download using checkpoint download endpoint with download ID - const response = await fetch('/api/download-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model_id: this.modelId, - model_version_id: this.currentVersion.id, - model_root: checkpointRoot, - relative_path: targetFolder, - download_id: downloadId - }) - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - showToast('Download completed successfully', 'success'); - modalManager.closeModal('checkpointDownloadModal'); - - // Update state specifically for the checkpoints page - state.pages.checkpoints.activeFolder = targetFolder; - - // Save the active folder preference to storage - setStorageItem('checkpoints_activeFolder', targetFolder); - - // Update UI to show the folder as selected - document.querySelectorAll('.folder-tags .tag').forEach(tag => { - const isActive = tag.dataset.folder === targetFolder; - tag.classList.toggle('active', isActive); - if (isActive && !tag.parentNode.classList.contains('collapsed')) { - // Scroll the tag into view if folder tags are not collapsed - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); - - await resetAndReload(true); // Pass true to update folders - - } catch (error) { - showToast(error.message, 'error'); - } finally { - this.loadingManager.hide(); - } - } - - initializeFolderBrowser() { - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (!folderBrowser) return; - - // Cleanup existing handler if any - this.cleanupFolderBrowser(); - - // Create new handler - this.folderClickHandler = (event) => { - const folderItem = event.target.closest('.folder-item'); - if (!folderItem) return; - - if (folderItem.classList.contains('selected')) { - folderItem.classList.remove('selected'); - this.selectedFolder = ''; - } else { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - folderItem.classList.add('selected'); - this.selectedFolder = folderItem.dataset.folder; - } - - // Update path display after folder selection - this.updateTargetPath(); - }; - - // Add the new handler - folderBrowser.addEventListener('click', this.folderClickHandler); - - // Add event listeners for path updates - const checkpointRoot = document.getElementById('checkpointRoot'); - const newFolder = document.getElementById('cpNewFolder'); - - checkpointRoot.addEventListener('change', this.updateTargetPath); - newFolder.addEventListener('input', this.updateTargetPath); - - // Update initial path - this.updateTargetPath(); - } - - cleanupFolderBrowser() { - if (this.folderClickHandler) { - const folderBrowser = document.getElementById('cpFolderBrowser'); - if (folderBrowser) { - folderBrowser.removeEventListener('click', this.folderClickHandler); - this.folderClickHandler = null; - } - } - - // Remove path update listeners - const checkpointRoot = document.getElementById('checkpointRoot'); - const newFolder = document.getElementById('cpNewFolder'); - - if (checkpointRoot) checkpointRoot.removeEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); - } - - updateTargetPath() { - const pathDisplay = document.getElementById('cpTargetPathDisplay'); - const checkpointRoot = document.getElementById('checkpointRoot').value; - const newFolder = document.getElementById('cpNewFolder').value.trim(); - - let fullPath = checkpointRoot || 'Select a checkpoint root directory'; - - if (checkpointRoot) { - if (this.selectedFolder) { - fullPath += '/' + this.selectedFolder; - } - if (newFolder) { - fullPath += '/' + newFolder; - } - } - - pathDisplay.innerHTML = `${fullPath}`; - } -} \ No newline at end of file diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index b3d0433e..5ddafb9a 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -1,60 +1,111 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { LoadingManager } from './LoadingManager.js'; -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/loraApi.js'; -import { getStorageItem } from '../utils/storageHelpers.js'; +import { getModelApiClient, resetAndReload } from '../api/baseModelApi.js'; +import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; + export class DownloadManager { constructor() { this.currentVersion = null; this.versions = []; this.modelInfo = null; - this.modelVersionId = null; // Add new property for initial version ID + this.modelVersionId = null; + this.modelId = null; - // Add initialization check this.initialized = false; this.selectedFolder = ''; + this.apiClient = null; - // Add LoadingManager instance this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; // Add this line + this.folderClickHandler = null; this.updateTargetPath = this.updateTargetPath.bind(this); + + // Bound methods for event handling + this.handleValidateAndFetchVersions = this.validateAndFetchVersions.bind(this); + this.handleProceedToLocation = this.proceedToLocation.bind(this); + this.handleStartDownload = this.startDownload.bind(this); + this.handleBackToUrl = this.backToUrl.bind(this); + this.handleBackToVersions = this.backToVersions.bind(this); + this.handleCloseModal = this.closeModal.bind(this); } showDownloadModal() { - console.log('Showing download modal...'); // Add debug log + console.log('Showing unified download modal...'); + + // Get API client for current page type + this.apiClient = getModelApiClient(); + const config = this.apiClient.apiConfig.config; + if (!this.initialized) { - // Check if modal exists const modal = document.getElementById('downloadModal'); if (!modal) { - console.error('Download modal element not found'); + console.error('Unified download modal element not found'); return; } + this.initializeEventHandlers(); this.initialized = true; } + // Update modal title and labels based on model type + this.updateModalLabels(); + modalManager.showModal('downloadModal', null, () => { - // Cleanup handler when modal closes this.cleanupFolderBrowser(); }); this.resetSteps(); // Auto-focus on the URL input setTimeout(() => { - const urlInput = document.getElementById('loraUrl'); + const urlInput = document.getElementById('modelUrl'); if (urlInput) { urlInput.focus(); } - }, 100); // Small delay to ensure the modal is fully displayed + }, 100); + } + + initializeEventHandlers() { + // Button event handlers + document.getElementById('nextFromUrl').addEventListener('click', this.handleValidateAndFetchVersions); + document.getElementById('nextFromVersion').addEventListener('click', this.handleProceedToLocation); + document.getElementById('startDownloadBtn').addEventListener('click', this.handleStartDownload); + document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl); + document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions); + document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal); + } + + updateModalLabels() { + const config = this.apiClient.apiConfig.config; + + // Update modal title + document.getElementById('downloadModalTitle').textContent = `Download ${config.displayName} from URL`; + + // Update URL label + document.getElementById('modelUrlLabel').textContent = 'Civitai URL:'; + + // Update root selection label + document.getElementById('modelRootLabel').textContent = `Select ${config.displayName} Root:`; + + // Update path preview labels + const pathLabels = document.querySelectorAll('.path-preview label'); + pathLabels.forEach(label => { + if (label.textContent.includes('Location Preview')) { + label.textContent = 'Download Location Preview:'; + } + }); + + // Update initial path text + const pathText = document.querySelector('#targetPathDisplay .path-text'); + if (pathText) { + pathText.textContent = `Select a ${config.displayName} root directory`; + } } resetSteps() { document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none'); document.getElementById('urlStep').style.display = 'block'; - document.getElementById('loraUrl').value = ''; + document.getElementById('modelUrl').value = ''; document.getElementById('urlError').textContent = ''; - // Clear new folder input const newFolderInput = document.getElementById('newFolder'); if (newFolderInput) { newFolderInput.value = ''; @@ -66,7 +117,6 @@ export class DownloadManager { this.modelId = null; this.modelVersionId = null; - // Clear selected folder and remove selection from UI this.selectedFolder = ''; const folderBrowser = document.getElementById('folderBrowser'); if (folderBrowser) { @@ -76,7 +126,7 @@ export class DownloadManager { } async validateAndFetchVersions() { - const url = document.getElementById('loraUrl').value.trim(); + const url = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); try { @@ -87,16 +137,8 @@ export class DownloadManager { throw new Error('Invalid Civitai URL format'); } - const response = await fetch(`/api/loras/civitai/versions/${this.modelId}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { - throw new Error('This model is not a LoRA. Please switch to the Checkpoints page to download checkpoint models.'); - } - throw new Error('Failed to fetch model versions'); - } + this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId); - this.versions = await response.json(); if (!this.versions.length) { throw new Error('No versions available for this model'); } @@ -134,19 +176,14 @@ export class DownloadManager { const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; - // Use version-level size or fallback to first file const fileSize = version.modelSizeKB ? (version.modelSizeKB / 1024).toFixed(2) : (version.files[0]?.sizeKB / 1024).toFixed(2); - // Use version-level existsLocally flag const existsLocally = version.existsLocally; const localPath = version.localPath; - - // Check if this is an early access version const isEarlyAccess = version.availability === 'EarlyAccess'; - // Create early access badge if needed let earlyAccessBadge = ''; if (isEarlyAccess) { earlyAccessBadge = ` @@ -155,10 +192,7 @@ export class DownloadManager {
`; } - - console.log(earlyAccessBadge); - // Status badge for local models const localStatus = existsLocally ? `
In Library @@ -169,7 +203,7 @@ export class DownloadManager {
+ data-version-id="${version.id}">
Version preview
@@ -191,12 +225,19 @@ export class DownloadManager { `; }).join(''); + // Add click handlers for version selection + versionList.addEventListener('click', (event) => { + const versionItem = event.target.closest('.version-item'); + if (versionItem) { + this.selectVersion(versionItem.dataset.versionId); + } + }); + // Auto-select the version if there's only one if (this.versions.length === 1 && !this.currentVersion) { this.selectVersion(this.versions[0].id.toString()); } - // Update Next button state based on initial selection this.updateNextButtonState(); } @@ -204,23 +245,15 @@ export class DownloadManager { this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); if (!this.currentVersion) return; - // Remove the toast notification - it's redundant with the visual indicator - // const existsLocally = this.currentVersion.files[0]?.existsLocally; - // if (existsLocally) { - // showToast('This version already exists in your library', 'info'); - // } - document.querySelectorAll('.version-item').forEach(item => { - item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); + item.classList.toggle('selected', item.dataset.versionId === versionId); }); - // Update Next button state after selection this.updateNextButtonState(); } - // Update this method to use version-level existsLocally updateNextButtonState() { - const nextButton = document.querySelector('#versionStep .primary-btn'); + const nextButton = document.getElementById('nextFromVersion'); if (!nextButton) return; const existsLocally = this.currentVersion?.existsLocally; @@ -242,7 +275,6 @@ export class DownloadManager { return; } - // Double-check if the version exists locally const existsLocally = this.currentVersion.existsLocally; if (existsLocally) { showToast('This version already exists in your library', 'info'); @@ -253,39 +285,30 @@ export class DownloadManager { document.getElementById('locationStep').style.display = 'block'; try { - // Fetch LoRA roots - const rootsResponse = await fetch('/api/loras/roots'); - if (!rootsResponse.ok) { - throw new Error('Failed to fetch LoRA roots'); - } + const config = this.apiClient.apiConfig.config; - const rootsData = await rootsResponse.json(); - const loraRoot = document.getElementById('loraRoot'); - loraRoot.innerHTML = rootsData.roots.map(root => + // Fetch model roots + const rootsData = await this.apiClient.fetchModelRoots(); + const modelRoot = document.getElementById('modelRoot'); + modelRoot.innerHTML = rootsData.roots.map(root => `` ).join(''); - // Set default lora root if available - const defaultRoot = getStorageItem('settings', {}).default_loras_root; + // Set default root if available + const defaultRootKey = `default_${this.apiClient.modelType}_root`; + const defaultRoot = getStorageItem('settings', {})[defaultRootKey]; if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - loraRoot.value = defaultRoot; + modelRoot.value = defaultRoot; } - // Fetch folders dynamically - const foldersResponse = await fetch('/api/loras/folders'); - if (!foldersResponse.ok) { - throw new Error('Failed to fetch folders'); - } - - const foldersData = await foldersResponse.json(); + // Fetch folders + const foldersData = await this.apiClient.fetchModelFolders(); const folderBrowser = document.getElementById('folderBrowser'); - // Update folder browser with dynamic content folderBrowser.innerHTML = foldersData.folders.map(folder => `
${folder}
` ).join(''); - // Initialize folder browser after loading roots and folders this.initializeFolderBrowser(); } catch (error) { showToast(error.message, 'error'); @@ -302,12 +325,17 @@ export class DownloadManager { document.getElementById('versionStep').style.display = 'block'; } + closeModal() { + modalManager.closeModal('downloadModal'); + } + async startDownload() { - const loraRoot = document.getElementById('loraRoot').value; + const modelRoot = document.getElementById('modelRoot').value; const newFolder = document.getElementById('newFolder').value.trim(); + const config = this.apiClient.apiConfig.config; - if (!loraRoot) { - showToast('Please select a LoRA root directory', 'error'); + if (!modelRoot) { + showToast(`Please select a ${config.displayName} root directory`, 'error'); return; } @@ -322,38 +350,32 @@ export class DownloadManager { } try { - // Show enhanced loading with progress details const updateProgress = this.loadingManager.showDownloadProgress(1); updateProgress(0, 0, this.currentVersion.name); - // Generate a unique ID for this download const downloadId = Date.now().toString(); - // Setup WebSocket for progress updates - use download-specific endpoint + // Setup WebSocket for progress updates const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`); ws.onmessage = (event) => { const data = JSON.parse(event.data); - // Handle download ID confirmation if (data.type === 'download_id') { console.log(`Connected to download progress with ID: ${data.download_id}`); return; } - // Only process progress updates for our download if (data.status === 'progress' && data.download_id === downloadId) { - // Update progress display with current progress updateProgress(data.progress, 0, this.currentVersion.name); - // Add more detailed status messages based on progress if (data.progress < 3) { this.loadingManager.setStatus(`Preparing download...`); } else if (data.progress === 3) { this.loadingManager.setStatus(`Downloaded preview image`); } else if (data.progress > 3 && data.progress < 100) { - this.loadingManager.setStatus(`Downloading LoRA file`); + this.loadingManager.setStatus(`Downloading ${config.singularName} file`); } else { this.loadingManager.setStatus(`Finalizing download...`); } @@ -362,35 +384,39 @@ export class DownloadManager { ws.onerror = (error) => { console.error('WebSocket error:', error); - // Continue with download even if WebSocket fails }; - // Start download with our download ID - const response = await fetch('/api/download-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model_id: this.modelId, - model_version_id: this.currentVersion.id, - model_root: loraRoot, - relative_path: targetFolder, - download_id: downloadId - }) - }); - - if (!response.ok) { - throw new Error(await response.text()); - } + // Start download + await this.apiClient.downloadModel( + this.modelId, + this.currentVersion.id, + modelRoot, + targetFolder, + downloadId + ); showToast('Download completed successfully', 'success'); modalManager.closeModal('downloadModal'); - // Close WebSocket after download completes ws.close(); - // Update state and trigger reload with folder update - state.activeFolder = targetFolder; - await resetAndReload(true); // Pass true to update folders + // Update state and trigger reload + const pageState = this.apiClient.getPageState(); + pageState.activeFolder = targetFolder; + + // Save the active folder preference + setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); + + // Update UI folder selection + document.querySelectorAll('.folder-tags .tag').forEach(tag => { + 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); } catch (error) { showToast(error.message, 'error'); @@ -399,15 +425,12 @@ export class DownloadManager { } } - // Add new method to handle folder selection initializeFolderBrowser() { const folderBrowser = document.getElementById('folderBrowser'); if (!folderBrowser) return; - // Cleanup existing handler if any this.cleanupFolderBrowser(); - // Create new handler this.folderClickHandler = (event) => { const folderItem = event.target.closest('.folder-item'); if (!folderItem) return; @@ -422,21 +445,17 @@ export class DownloadManager { this.selectedFolder = folderItem.dataset.folder; } - // Update path display after folder selection this.updateTargetPath(); }; - // Add the new handler folderBrowser.addEventListener('click', this.folderClickHandler); - // Add event listeners for path updates - const loraRoot = document.getElementById('loraRoot'); + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - loraRoot.addEventListener('change', this.updateTargetPath); + modelRoot.addEventListener('change', this.updateTargetPath); newFolder.addEventListener('input', this.updateTargetPath); - // Update initial path this.updateTargetPath(); } @@ -449,23 +468,22 @@ export class DownloadManager { } } - // Remove path update listeners - const loraRoot = document.getElementById('loraRoot'); + const modelRoot = document.getElementById('modelRoot'); const newFolder = document.getElementById('newFolder'); - loraRoot.removeEventListener('change', this.updateTargetPath); - newFolder.removeEventListener('input', this.updateTargetPath); + if (modelRoot) modelRoot.removeEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); } - // Add new method to update target path updateTargetPath() { const pathDisplay = document.getElementById('targetPathDisplay'); - const loraRoot = document.getElementById('loraRoot').value; + const modelRoot = document.getElementById('modelRoot').value; const newFolder = document.getElementById('newFolder').value.trim(); + const config = this.apiClient.apiConfig.config; - let fullPath = loraRoot || 'Select a LoRA root directory'; + let fullPath = modelRoot || `Select a ${config.displayName} root directory`; - if (loraRoot) { + if (modelRoot) { if (this.selectedFolder) { fullPath += '/' + this.selectedFolder; } @@ -477,3 +495,6 @@ export class DownloadManager { pathDisplay.innerHTML = `${fullPath}`; } } + +// Create global instance +export const downloadManager = new DownloadManager(); diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 86a880b2..1ac8c44c 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -1,8 +1,7 @@ import { BASE_MODEL_CLASSES } from '../utils/constants.js'; import { getCurrentPageState } from '../state/index.js'; import { showToast, updatePanelPositions } from '../utils/uiHelpers.js'; -import { loadMoreLoras } from '../api/loraApi.js'; -import { loadMoreCheckpoints } from '../api/checkpointApi.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; export class FilterManager { @@ -73,6 +72,8 @@ export class FilterManager { tagsEndpoint = '/api/recipes/top-tags?limit=20'; } else if (this.currentPage === 'checkpoints') { tagsEndpoint = '/api/checkpoints/top-tags?limit=20'; + } else if (this.currentPage === 'embeddings') { + tagsEndpoint = '/api/embeddings/top-tags?limit=20'; } const response = await fetch(tagsEndpoint); @@ -148,6 +149,8 @@ export class FilterManager { apiEndpoint = '/api/recipes/base-models'; } else if (this.currentPage === 'checkpoints') { apiEndpoint = '/api/checkpoints/base-models'; + } else if (this.currentPage === 'embeddings') { + apiEndpoint = '/api/embeddings/base-models'; } else { return; } @@ -161,11 +164,7 @@ export class FilterManager { data.base_models.forEach(model => { const tag = document.createElement('div'); - // Add base model classes only for the loras page - const baseModelClass = (this.currentPage === 'loras' && BASE_MODEL_CLASSES[model.name]) - ? BASE_MODEL_CLASSES[model.name] - : ''; - tag.className = `filter-tag base-model-tag ${baseModelClass}`; + tag.className = `filter-tag base-model-tag`; tag.dataset.baseModel = model.name; tag.innerHTML = `${model.name} ${model.count}`; @@ -281,11 +280,9 @@ export class FilterManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras') { - // For loras page, reset the page and reload - await loadMoreLoras(true, true); - } else if (this.currentPage === 'checkpoints' && window.checkpointManager) { - await loadMoreCheckpoints(true); + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { + // For models page, reset the page and reload + await getModelApiClient().loadMoreWithVirtualScroll(true, false); } // Update filter button to show active state @@ -339,10 +336,8 @@ export class FilterManager { // Reload data using the appropriate method for the current page if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras') { - await loadMoreLoras(true, true); - } else if (this.currentPage === 'checkpoints') { - await loadMoreCheckpoints(true); + } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { + await getModelApiClient().loadMoreWithVirtualScroll(true, true); } showToast(`Filters cleared`, 'info'); diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index e72c8274..5f14ea1e 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -1,8 +1,8 @@ -import { showToast } from '../utils/uiHelpers.js'; +import { showToast, updateFolderTags } from '../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; -import { updateFolderTags } from '../api/baseModelApi.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; class MoveManager { constructor() { @@ -146,45 +146,46 @@ class MoveManager { targetPath = `${targetPath}/${newFolder}`; } + const apiClient = getModelApiClient(); + try { if (this.bulkFilePaths) { // Bulk move mode - await this.moveBulkModels(this.bulkFilePaths, targetPath); + const movedFilePaths = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath); // Update virtual scroller if in active folder view const pageState = getCurrentPageState(); if (pageState.activeFolder !== null && state.virtualScroller) { - // Remove moved items from virtual scroller instead of reloading - this.bulkFilePaths.forEach(filePath => { - state.virtualScroller.removeItemByFilePath(filePath); + // Remove only successfully moved items + movedFilePaths.forEach(newFilePath => { + // Find original filePath by matching filename + const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1); + const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename)); + if (originalFilePath) { + state.virtualScroller.removeItemByFilePath(originalFilePath); + } }); } else { // Update the model cards' filepath in the DOM - this.bulkFilePaths.forEach(filePath => { - // Extract filename from original path - const filename = filePath.substring(filePath.lastIndexOf('/') + 1); - // Construct new filepath - const newFilePath = `${targetPath}/${filename}`; - - state.virtualScroller.updateSingleItem(filePath, {file_path: newFilePath}); + movedFilePaths.forEach(newFilePath => { + const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1); + const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename)); + if (originalFilePath) { + state.virtualScroller.updateSingleItem(originalFilePath, {file_path: newFilePath}); + } }); } } else { // Single move mode - await this.moveSingleModel(this.currentFilePath, targetPath); - - // Update virtual scroller if in active folder view - const pageState = getCurrentPageState(); - if (pageState.activeFolder !== null && state.virtualScroller) { - // Remove moved item from virtual scroller instead of reloading - state.virtualScroller.removeItemByFilePath(this.currentFilePath); - } else { - // Extract filename from original path - const filename = this.currentFilePath.substring(this.currentFilePath.lastIndexOf('/') + 1); - // Construct new filepath - const newFilePath = `${targetPath}/${filename}`; + const newFilePath = await apiClient.moveSingleModel(this.currentFilePath, targetPath); - state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); + const pageState = getCurrentPageState(); + if (newFilePath) { + if (pageState.activeFolder !== null && state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(this.currentFilePath); + } else { + state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath}); + } } } @@ -211,102 +212,6 @@ class MoveManager { showToast('Failed to move model(s): ' + error.message, 'error'); } } - - async moveSingleModel(filePath, targetPath) { - // show toast if current path is same as target path - if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { - showToast('Model is already in the selected folder', 'info'); - return; - } - - const response = await fetch('/api/move_model', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_path: filePath, - target_path: targetPath - }) - }); - - const result = await response.json(); - - if (!response.ok) { - if (result && result.error) { - throw new Error(result.error); - } - throw new Error('Failed to move model'); - } - - if (result && result.message) { - showToast(result.message, 'info'); - } else { - showToast('Model moved successfully', 'success'); - } - } - - async moveBulkModels(filePaths, targetPath) { - // Filter out models already in the target path - const movedPaths = filePaths.filter(path => { - return path.substring(0, path.lastIndexOf('/')) !== targetPath; - }); - - if (movedPaths.length === 0) { - showToast('All selected models are already in the target folder', 'info'); - return; - } - - const response = await fetch('/api/move_models_bulk', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - file_paths: movedPaths, - target_path: targetPath - }) - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error('Failed to move models'); - } - - // Display results with more details - if (result.success) { - if (result.failure_count > 0) { - // Some files failed to move - showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning'); - - // Log details about failures - console.log('Move operation results:', result.results); - - // Get list of failed files with reasons - const failedFiles = result.results - .filter(r => !r.success) - .map(r => { - const fileName = r.path.substring(r.path.lastIndexOf('/') + 1); - return `${fileName}: ${r.message}`; - }); - - // Show first few failures in a toast - if (failedFiles.length > 0) { - const failureMessage = failedFiles.length <= 3 - ? failedFiles.join('\n') - : failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`; - - showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000); - } - } else { - // All files moved successfully - showToast(`Successfully moved ${result.success_count} models`, 'success'); - } - } else { - throw new Error(result.message || 'Failed to move models'); - } - } } export const moveManager = new MoveManager(); diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index 46c9a247..81663791 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -1,5 +1,6 @@ import { updatePanelPositions } from "../utils/uiHelpers.js"; import { getCurrentPageState } from "../state/index.js"; +import { getModelApiClient } from "../api/baseModelApi.js"; import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js"; /** * SearchManager - Handles search functionality across different pages @@ -312,7 +313,7 @@ export class SearchManager { loraName: options.loraName || false, loraModel: options.loraModel || false }; - } else if (this.currentPage === 'loras') { + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings') { pageState.searchOptions = { filename: options.filename || false, modelname: options.modelname || false, @@ -332,15 +333,9 @@ export class SearchManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { window.recipeManager.loadRecipes(true); // true to reset pagination - } else if (this.currentPage === 'loras' && window.loadMoreLoras) { - // Reset loras page and reload - if (pageState) { - pageState.currentPage = 1; - pageState.hasMore = true; - } - window.loadMoreLoras(true); // true to reset pagination - } else if (this.currentPage === 'checkpoints' && window.checkpointManager) { - window.checkpointManager.loadCheckpoints(true); // true to reset pagination + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { + // For models page, reset the page and reload + getModelApiClient().loadMoreWithVirtualScroll(true, false); } } } \ No newline at end of file diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index c0f0f662..edaf4578 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1,7 +1,7 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; -import { resetAndReload } from '../api/loraApi.js'; +import { resetAndReload } from '../api/baseModelApi.js'; import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js'; @@ -664,7 +664,7 @@ export class SettingsManager { await window.recipeManager.loadRecipes(); } else if (this.currentPage === 'checkpoints') { // Reload the checkpoints without updating folders - await window.checkpointsManager.loadCheckpoints(); + await resetAndReload(false); } } @@ -734,7 +734,7 @@ export class SettingsManager { applyFrontendSettings() { // Apply blur setting to existing content const blurSetting = state.global.settings.blurMatureContent; - document.querySelectorAll('.lora-card[data-nsfw="true"] .card-image').forEach(img => { + document.querySelectorAll('.model-card[data-nsfw="true"] .card-image').forEach(img => { if (blurSetting) { img.classList.add('nsfw-blur'); } else { diff --git a/static/js/state/index.js b/static/js/state/index.js index 8343692d..ac77bbc2 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -1,16 +1,18 @@ // Create the new hierarchical state structure import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; +import { MODEL_TYPES } from '../api/apiConfig.js'; // Load settings from localStorage or use defaults const savedSettings = getStorageItem('settings', { blurMatureContent: true, show_only_sfw: false, - cardInfoDisplay: 'always' // Add default value for card info display + cardInfoDisplay: 'always' }); -// Load preview versions from localStorage +// Load preview versions from localStorage for each model type const loraPreviewVersions = getMapFromStorage('lora_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoint_preview_versions'); +const embeddingPreviewVersions = getMapFromStorage('embedding_preview_versions'); export const state = { // Global state @@ -22,13 +24,13 @@ export const state = { // Page-specific states pages: { - loras: { + [MODEL_TYPES.LORA]: { currentPage: 1, isLoading: false, hasMore: true, sortBy: 'name', activeFolder: null, - activeLetterFilter: null, // New property for letter filtering + activeLetterFilter: null, previewVersions: loraPreviewVersions, searchManager: null, searchOptions: { @@ -67,10 +69,10 @@ export const state = { }, pageSize: 20, showFavoritesOnly: false, - duplicatesMode: false, // Add flag for duplicates mode + duplicatesMode: false, }, - checkpoints: { + [MODEL_TYPES.CHECKPOINT]: { currentPage: 1, isLoading: false, hasMore: true, @@ -89,11 +91,34 @@ export const state = { }, showFavoritesOnly: false, duplicatesMode: false, + }, + + [MODEL_TYPES.EMBEDDING]: { + currentPage: 1, + isLoading: false, + hasMore: true, + sortBy: 'name', + activeFolder: null, + activeLetterFilter: null, + previewVersions: embeddingPreviewVersions, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + recursive: false + }, + filters: { + baseModel: [], + tags: [] + }, + showFavoritesOnly: false, + duplicatesMode: false, } }, - // Current active page - currentPageType: 'loras', + // Current active page - use MODEL_TYPES constants + currentPageType: MODEL_TYPES.LORA, // Backward compatibility - proxy properties get currentPage() { return this.pages[this.currentPageType].currentPage; }, diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index bbdc73b6..0d614211 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -164,23 +164,6 @@ export class VirtualScroller { // Calculate the left offset to center the grid within the content area this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); - - // Log layout info - console.log('Virtual Scroll Layout:', { - containerWidth, - availableContentWidth, - actualGridWidth, - columnsCount: this.columnsCount, - itemWidth: this.itemWidth, - itemHeight: this.itemHeight, - leftOffset: this.leftOffset, - paddingLeft, - paddingRight, - displayDensity, - maxColumns, - baseCardWidth, - rowGap: this.rowGap - }); // Update grid element max-width to match available width this.gridElement.style.maxWidth = `${actualGridWidth}px`; diff --git a/static/js/utils/cardUpdater.js b/static/js/utils/cardUpdater.js index 938ee288..490ed83b 100644 --- a/static/js/utils/cardUpdater.js +++ b/static/js/utils/cardUpdater.js @@ -9,7 +9,7 @@ */ export function updateRecipeCard(recipeId, updates) { // Find the card with matching recipe ID - const recipeCard = document.querySelector(`.lora-card[data-id="${recipeId}"]`); + const recipeCard = document.querySelector(`.model-card[data-id="${recipeId}"]`); if (!recipeCard) return; // Get the recipe card component instance diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 48876487..01f732e3 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -1,16 +1,12 @@ import { state, getCurrentPageState } from '../state/index.js'; import { VirtualScroller } from './VirtualScroller.js'; -import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js'; -import { createCheckpointCard, setupCheckpointCardEventDelegation } from '../components/CheckpointCard.js'; -import { fetchLorasPage } from '../api/loraApi.js'; -import { fetchCheckpointsPage } from '../api/checkpointApi.js'; +import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; import { showToast } from './uiHelpers.js'; // Function to dynamically import the appropriate card creator based on page type async function getCardCreator(pageType) { - if (pageType === 'loras') { - return createLoraCard; - } else if (pageType === 'recipes') { + if (pageType === 'recipes') { // Import the RecipeCard module const { RecipeCard } = await import('../components/RecipeCard.js'); @@ -23,22 +19,21 @@ async function getCardCreator(pageType) { }); return recipeCard.element; }; - } else if (pageType === 'checkpoints') { - return createCheckpointCard; } - return null; + + // For other page types, use the shared ModelCard creator + return (model) => createModelCard(model, pageType); + } // Function to get the appropriate data fetcher based on page type async function getDataFetcher(pageType) { - if (pageType === 'loras') { - return fetchLorasPage; + if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints') { + return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize); } else if (pageType === 'recipes') { // Import the recipeApi module and use the fetchRecipesPage function const { fetchRecipesPage } = await import('../api/recipeApi.js'); return fetchRecipesPage; - } else if (pageType === 'checkpoints') { - return fetchCheckpointsPage; } return null; } @@ -65,12 +60,8 @@ export async function initializeInfiniteScroll(pageType = 'loras') { // Use virtual scrolling for all page types await initializeVirtualScroll(pageType); - // Setup event delegation for lora cards if on the loras page - if (pageType === 'loras') { - setupLoraCardEventDelegation(); - } else if (pageType === 'checkpoints') { - setupCheckpointCardEventDelegation(); - } + // Setup event delegation for model cards based on page type + setupModelCardEventDelegation(pageType); } async function initializeVirtualScroll(pageType) { diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js index 041a8ee3..25af1fbc 100644 --- a/static/js/utils/modalUtils.js +++ b/static/js/utils/modalUtils.js @@ -1,17 +1,15 @@ import { modalManager } from '../managers/ModalManager.js'; -import { excludeLora, deleteModel as deleteLora } from '../api/loraApi.js'; -import { excludeCheckpoint, deleteCheckpoint } from '../api/checkpointApi.js'; +import { getModelApiClient } from '../api/baseModelApi.js'; + +const apiClient = getModelApiClient(); let pendingDeletePath = null; -let pendingModelType = null; let pendingExcludePath = null; -let pendingExcludeModelType = null; -export function showDeleteModal(filePath, modelType = 'lora') { +export function showDeleteModal(filePath) { pendingDeletePath = filePath; - pendingModelType = modelType; - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); const modal = modalManager.getModal('deleteModal').element; const modelInfo = modal.querySelector('.delete-model-info'); @@ -29,12 +27,7 @@ export async function confirmDelete() { if (!pendingDeletePath) return; try { - // Use appropriate delete function based on model type - if (pendingModelType === 'checkpoint') { - await deleteCheckpoint(pendingDeletePath); - } else { - await deleteLora(pendingDeletePath); - } + await apiClient.deleteModel(pendingDeletePath); closeDeleteModal(); @@ -50,15 +43,13 @@ export async function confirmDelete() { export function closeDeleteModal() { modalManager.closeModal('deleteModal'); pendingDeletePath = null; - pendingModelType = null; } // Functions for the exclude modal -export function showExcludeModal(filePath, modelType = 'lora') { +export function showExcludeModal(filePath) { pendingExcludePath = filePath; - pendingExcludeModelType = modelType; - const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`); const modelName = card ? card.dataset.name : filePath.split('/').pop(); const modal = modalManager.getModal('excludeModal').element; const modelInfo = modal.querySelector('.exclude-model-info'); @@ -75,19 +66,13 @@ export function showExcludeModal(filePath, modelType = 'lora') { export function closeExcludeModal() { modalManager.closeModal('excludeModal'); pendingExcludePath = null; - pendingExcludeModelType = null; } export async function confirmExclude() { if (!pendingExcludePath) return; try { - // Use appropriate exclude function based on model type - if (pendingExcludeModelType === 'checkpoint') { - await excludeCheckpoint(pendingExcludePath); - } else { - await excludeLora(pendingExcludePath); - } + await apiClient.excludeModel(pendingExcludePath); closeExcludeModal(); diff --git a/static/js/utils/routes.js b/static/js/utils/routes.js deleted file mode 100644 index 9dba2b2c..00000000 --- a/static/js/utils/routes.js +++ /dev/null @@ -1,55 +0,0 @@ -// API routes configuration -export const apiRoutes = { - // LoRA routes - loras: { - list: '/api/loras', - detail: (id) => `/api/loras/${id}`, - delete: (id) => `/api/loras/${id}`, - update: (id) => `/api/loras/${id}`, - civitai: (id) => `/api/loras/${id}/civitai`, - download: '/api/download-model', - move: '/api/move-lora', - scan: '/api/scan-loras' - }, - - // Recipe routes - recipes: { - list: '/api/recipes', - detail: (id) => `/api/recipes/${id}`, - delete: (id) => `/api/recipes/${id}`, - update: (id) => `/api/recipes/${id}`, - analyze: '/api/analyze-recipe-image', - save: '/api/save-recipe' - }, - - // Checkpoint routes - checkpoints: { - list: '/api/checkpoints', - detail: (id) => `/api/checkpoints/${id}`, - delete: (id) => `/api/checkpoints/${id}`, - update: (id) => `/api/checkpoints/${id}` - }, - - // WebSocket routes - ws: { - fetchProgress: (protocol) => `${protocol}://${window.location.host}/ws/fetch-progress` - } -}; - -// Page routes -export const pageRoutes = { - loras: '/loras', - recipes: '/loras/recipes', - checkpoints: '/checkpoints', - statistics: '/statistics' -}; - -// Helper function to get current page type -export function getCurrentPageType() { - const path = window.location.pathname; - if (path.includes('/loras/recipes')) return 'recipes'; - if (path.includes('/checkpoints')) return 'checkpoints'; - if (path.includes('/statistics')) return 'statistics'; - if (path.includes('/loras')) return 'loras'; - return 'unknown'; -} \ No newline at end of file diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index adaf3b66..8a4866c8 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -1,7 +1,6 @@ -import { state } from '../state/index.js'; -import { resetAndReload } from '../api/loraApi.js'; +import { getCurrentPageState } from '../state/index.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js'; -import { NODE_TYPES, NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js'; +import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js'; /** * Utility function to copy text to clipboard with fallback for older browsers @@ -168,33 +167,14 @@ function updateThemeToggleIcons(theme) { themeToggle.classList.add(`theme-${theme}`); } -export function toggleFolder(tag) { - const tagElement = (tag instanceof HTMLElement) ? tag : this; - const folder = tagElement.dataset.folder; - const wasActive = tagElement.classList.contains('active'); - - document.querySelectorAll('.folder-tags .tag').forEach(t => { - t.classList.remove('active'); - }); - - if (!wasActive) { - tagElement.classList.add('active'); - state.activeFolder = folder; - } else { - state.activeFolder = null; - } - - resetAndReload(); -} - function filterByFolder(folderPath) { - document.querySelectorAll('.lora-card').forEach(card => { + document.querySelectorAll('.model-card').forEach(card => { card.style.display = card.dataset.folder === folderPath ? '' : 'none'; }); } export function openCivitai(filePath) { - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`); if (!loraCard) return; const metaData = JSON.parse(loraCard.dataset.meta); @@ -615,4 +595,32 @@ export async function openExampleImagesFolder(modelHash) { showToast('Failed to open example images folder', 'error'); return false; } +} + +/** + * Update the folder tags display with new folder list + * @param {Array} folders - List of folder names + */ +export function updateFolderTags(folders) { + const folderTagsContainer = document.querySelector('.folder-tags'); + if (!folderTagsContainer) return; + + // Keep track of currently selected folder + const pageState = getCurrentPageState(); + const currentFolder = pageState.activeFolder; + + // Create HTML for folder tags + const tagsHTML = folders.map(folder => { + const isActive = folder === currentFolder; + return `
${folder}
`; + }).join(''); + + // Update the container + folderTagsContainer.innerHTML = tagsHTML; + + // Scroll active folder into view (no need to reattach click handlers) + const activeTag = folderTagsContainer.querySelector(`.tag[data-folder="${currentFolder}"]`); + if (activeTag) { + activeTag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } } \ No newline at end of file diff --git a/templates/checkpoints.html b/templates/checkpoints.html index 47f8295a..2e00b7a1 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -12,11 +12,8 @@ {% block init_check_url %}/api/checkpoints?page=1&page_size=1{% endblock %} {% block additional_components %} -{% include 'components/checkpoint_modals.html' %}