Compare commits

...

92 Commits

Author SHA1 Message Date
Will Miao
82a2a6e669 chore: update version to 0.8.19 and add release notes for new features and enhancements 2025-06-28 08:04:16 +08:00
Will Miao
6376d60af5 Add temp debug console logging 2025-06-27 17:47:19 +08:00
Will Miao
b1e2e3831f fix: enhance model processing logic to skip already processed models only if their directories contain files. See #259 2025-06-27 13:09:19 +08:00
Will Miao
5de1c8aa82 feat: add node selector header with action mode indicator and instructions for improved user guidance 2025-06-27 12:39:20 +08:00
Will Miao
63dc5c2bdb fix: change overflow-y property to scroll for consistent vertical scrolling behavior 2025-06-27 11:44:43 +08:00
Will Miao
7f2d1670a0 feat: add startExpanded option to renderShowcaseContent for improved showcase interaction 2025-06-27 10:12:17 +08:00
Will Miao
53c8c337fc fix: remove unnecessary variable assignment for trigger words section in edit mode 2025-06-27 09:58:24 +08:00
Will Miao
5b4ec1b2a2 feat: implement disabled state for header search on statistics page with appropriate styling and functionality adjustments 2025-06-27 09:45:48 +08:00
Will Miao
64dd2ed141 feat: enhance node registration and management with support for multiple nodes and improved UI elements. Fixes #220 2025-06-26 23:00:55 +08:00
Will Miao
eb57e04e95 feat: implement thread-safe node registry and registration endpoints for Lora nodes 2025-06-26 18:31:14 +08:00
Will Miao
ae905c8630 fix: correct extension name format and update initialization method in usage stats 2025-06-26 16:57:26 +08:00
Will Miao
c157e794f0 feat: implement event delegation for checkpoint cards and enhance Civitai link handling 2025-06-26 11:42:43 +08:00
Will Miao
ed9bae6f6a feat: enhance recipe metadata handling with NSFW level updates and context menu actions. FIxes #247 2025-06-26 11:04:51 +08:00
Will Miao
9fe1ce19ad feat: add Patreon support section to the support modal with styling 2025-06-26 09:54:07 +08:00
Will Miao
6148236cbd fix: add missing patreon entry in FUNDING.yml 2025-06-26 08:23:12 +08:00
Will Miao
2471eb518a fix: correct key reference in process_trigger_words and update comment for widget values. Fixes #254 2025-06-25 20:57:12 +08:00
Will Miao
8931b41c76 feat: refactor API routes for renaming models and update related functions 2025-06-25 19:38:38 +08:00
Will Miao
7f523f167d fix: correct indentation for appending lora_entry in CivitaiApiMetadataParser. Fixes #253 2025-06-25 15:57:14 +08:00
Will Miao
446b6d6158 feat: sync saved example images path with backend on path update. Fixes #250 2025-06-25 15:34:25 +08:00
Will Miao
2ee057e19b feat: update metadata saving to ensure backup creation and support nested civitai structure 2025-06-25 11:50:10 +08:00
Will Miao
afc810f21f feat: prevent Ctrl+A behavior when search input is focused. See #251 2025-06-24 22:12:53 +08:00
pixelpaws
357052a903 Merge pull request #252 from willmiao/stats-page
Add statistics page with metrics, charts, and insights functionality
2025-06-24 21:37:06 +08:00
Will Miao
39d6d8d04a Add statistics page with metrics, charts, and insights functionality
- Implemented CSS styles for the statistics page layout and components.
- Developed JavaScript functionality for managing statistics, including data fetching, chart rendering, and tab navigation.
- Created HTML template for the statistics page, integrating dynamic content for metrics, charts, and insights.
- Added responsive design adjustments and loading states for better user experience.
2025-06-24 21:36:20 +08:00
Will Miao
888896c0c0 feat: add card info display setting with options for always visible or reveal on hover 2025-06-24 17:41:52 +08:00
Will Miao
ceee482ecc feat: refactor Lora handling by introducing chainCallback for improved node initialization and widget management. Fixes #176 2025-06-24 16:36:15 +08:00
Will Miao
d0ed1213d8 feat: enhance LoRA metadata handling by adding model IDs and updating recipe data structure. Fixes #246 2025-06-24 11:12:21 +08:00
Will Miao
f6ef428008 feat: update preview URL handling in RecipeRoutes and optimize recipe refresh logic in RecipeModal. Fixes #244 2025-06-23 15:29:22 +08:00
Will Miao
e726c4f442 feat: enhance metadata extraction for TSC samplers with vae_decode handling 2025-06-23 10:55:27 +08:00
Will Miao
402318e586 feat: enhance metadata processing and extraction for Efficient nodes with improved prompt handling and conditioning outputs. 2025-06-22 13:21:31 +08:00
Will Miao
b198cc2a6e feat: enhance metadata enrichment process to update file paths and preview URLs dynamically. See #113 2025-06-21 21:24:22 +08:00
Will Miao
c3dd4da11b feat: enhance theme toggle functionality with auto theme support and icon updates. Fix #243 2025-06-21 20:43:44 +08:00
Will Miao
ba2e42b06e feat: enhance LoraModal with notes hint and cleanup functionality on close 2025-06-21 20:04:57 +08:00
Will Miao
fa0902dc74 feat: add AdvancedCLIPTextEncode to NODE_EXTRACTORS for enhanced metadata extraction. See #234 2025-06-21 06:22:33 +08:00
Will Miao
8fcb6083dc feat: update release notes and version to 0.8.18 with new features and improvements 2025-06-20 18:25:15 +08:00
Will Miao
1ef88140e3 fix: adjust widget heights and padding for improved layout and text alignment 2025-06-20 17:21:31 +08:00
Will Miao
aa34c4c84c refactor: streamline prompt matching logic in MetadataProcessor 2025-06-20 17:00:23 +08:00
Will Miao
32d12bb334 feat: update API routes for version info and enhance version fetching functionality 2025-06-20 16:38:11 +08:00
Will Miao
1b2a02cb1a feat: add git information display in update modals and enhance version check functionality 2025-06-20 15:22:07 +08:00
Will Miao
2ff11a16c4 feat: implement DebugMetadata node with metadata display and update functionality 2025-06-20 14:17:39 +08:00
Will Miao
441af82dbd fix: update EXIF metadata extraction method for better compatibility with non-JPEG formats 2025-06-20 11:15:05 +08:00
Will Miao
e09c09af6f feat: support GIF format for preview images. Fixes #236 2025-06-20 10:51:52 +08:00
Will Miao
3721fe226f Remove unused code 2025-06-20 10:43:02 +08:00
Will Miao
8ace0e11cf Update find_preview_file to include example extension from Civitai Helper for A1111. Fixes #225 2025-06-20 10:41:42 +08:00
Will Miao
5e249b0b59 fix: Update from_civitai flag to True in metadata creation for checkpoints and LoraMetadata. Fixes #238 2025-06-20 05:48:28 +08:00
Will Miao
4889955ecf feat: Add conditioning matching to prompts and update metadata handling in node extractors. See #235 2025-06-20 00:04:02 +08:00
pixelpaws
d840fd53da Merge pull request #231 from PredatorIWD/fix-crash-on-symlinks
Don't crash completely if a symlink resolve fails
2025-06-19 18:34:03 +08:00
pixelpaws
a61819cdb3 Merge branch 'main' into fix-crash-on-symlinks 2025-06-19 18:33:40 +08:00
Will Miao
e986fbb5fb refactor: Streamline progress file handling and enhance metadata extraction for images 2025-06-19 18:12:16 +08:00
Will Miao
8f4d575ec8 refactor: Improve metadata handling and streamline example image loading in modals 2025-06-19 17:07:28 +08:00
Will Miao
605a06317b feat: Enhance media handling by adding NSFW level support and improving preview image management 2025-06-19 15:19:24 +08:00
Will Miao
a7304ccf47 feat: Add deepMerge method for improved object merging in VirtualScroller 2025-06-19 12:46:50 +08:00
Will Miao
374e2bd4b9 refactor: Add MediaRenderers, MediaUtils, MetadataPanel, and ShowcaseView components for enhanced media handling in showcase
- Implemented MediaRenderers.js to generate HTML for video and image wrappers, including NSFW handling and media controls.
- Created MediaUtils.js for utility functions to manage media loading, lazy loading, and metadata panel interactions.
- Developed MetadataPanel.js to generate metadata panels for media items, including prompts and generation parameters.
- Introduced ShowcaseView.js to render showcase content, manage media items, and handle file imports with drag-and-drop support.
2025-06-19 11:21:32 +08:00
Will Miao
09a3246ddb Add delete functionality for custom example images with API endpoint 2025-06-19 11:21:00 +08:00
Will Miao
a615603866 Prevent Ctrl+A behavior in modals by checking for open modals before handling the key event 2025-06-18 18:43:11 +08:00
Will Miao
1ca05808e1 Enhance preview image upload by deleting existing previews and updating UI state management 2025-06-18 18:37:13 +08:00
Will Miao
5febc2a805 Add update indicator and animation for updated cards in VirtualScroller 2025-06-18 17:30:49 +08:00
Will Miao
3c047bee58 Refactor example images handling by introducing migration logic, updating metadata structure, and enhancing image loading in the UI 2025-06-18 17:14:49 +08:00
Will Miao
022c6c157a Refactor example images code 2025-06-18 09:28:00 +08:00
Will Miao
fa587d5678 Refactor modal components by removing unused imports and commenting out cache management section in modals.html 2025-06-17 21:06:01 +08:00
Will Miao
afa5a42f5a Refactor metadata handling by introducing MetadataManager for centralized operations and improving error handling 2025-06-17 21:01:48 +08:00
Will Miao
71df8ba3e2 Refactor metadata handling by removing direct UI updates from saveModelMetadata and related functions 2025-06-17 20:25:39 +08:00
Will Miao
8764998e8c Update example images optimization message to clarify metadata preservation 2025-06-16 23:26:55 +08:00
Will Miao
2cb4f3aac8 Add example images access modal and API integration for checking image availability. Fixes #183 and #209 2025-06-16 21:33:49 +08:00
Will Miao
1ccaf33aac Refactor example images management by removing centralized examples settings and migration functionality 2025-06-16 18:29:37 +08:00
Will Miao
cb0a8e0413 Implement example image import functionality with UI and backend integration 2025-06-16 18:14:53 +08:00
Luka Celebic
8674168df4 Don't crash completely if a symlink resolve fails 2025-06-15 20:00:21 +02:00
Will Miao
2221653801 Add bulk selection functionality and limit thumbnail display in BulkManager. See #229 2025-06-15 22:21:21 +08:00
Will Miao
78bcdcef5d Enhance CivitAI metadata fetch handling and update virtual scroller item management. See #227 2025-06-15 08:34:22 +08:00
Will Miao
672fbe2ac0 Remove unused and outdated code to improve clarity 2025-06-15 06:18:47 +08:00
Will Miao
56a5970b44 Adjust NSFW warning styles for medium and compact density modes 2025-06-14 19:49:54 +08:00
Will Miao
a66cef7cfe Increase max-height for model names in medium and compact density modes to prevent text cutoff 2025-06-14 19:30:46 +08:00
Will Miao
c0b1c2e099 Remove commented-out Civitai context menu item from checkpoints and context menu templates 2025-06-14 18:13:37 +08:00
Will Miao
9e553bb87b Refactor card update functions to unify model and Lora card handling; remove unused metadata path update logic. See #228 2025-06-14 09:39:59 +08:00
Will Miao
f966514bc7 Add tag editing functionality and update compact tags rendering 2025-06-13 20:42:44 +08:00
Will Miao
dc0a49f96d Refactor trigger words and metadata editing styles
- Removed outdated styles from trigger words CSS and consolidated into a new shared edit-metadata CSS file.
- Updated JavaScript components for trigger words and model tags to utilize the new metadata styles.
- Adjusted class names and structure in the HTML to align with the new styling conventions.
- Enhanced the UI for editing tags and trigger words, ensuring consistency across components.
2025-06-13 20:19:10 +08:00
Will Miao
65c783c024 Refactor lora-modal.css into modular components 2025-06-13 15:10:26 +08:00
Will Miao
6395836fbb Add styles for empty tags and update tag rendering logic to always display container 2025-06-13 07:11:07 +08:00
Will Miao
a7207084ef Remove unused monitor cleanup logic from LoraManager and DownloadManager 2025-06-13 05:52:52 +08:00
Will Miao
27ef1f1e71 Refactor tag editing setup: improve event handler management for edit and save buttons 2025-06-13 05:46:53 +08:00
Will Miao
68fdb14cd6 Remove unused lora monitor retrieval and ignore path logic from ApiRoutes, DownloadManager, and ModelScanner. Fixes #226 2025-06-13 05:46:22 +08:00
Will Miao
c2af282a85 Add tag editing functionality: implement UI for editing model tags, including save and delete options, and integrate with existing modal structure. 2025-06-12 21:00:17 +08:00
Will Miao
92d48335cb Add endpoints and functionality for verifying duplicates in Lora and Checkpoints
- Implemented `/api/loras/verify-duplicates` and `/api/checkpoints/verify-duplicates` endpoints.
- Added `handle_verify_duplicates` method in `ModelRouteUtils` to process duplicate verification requests.
- Enhanced `ModelDuplicatesManager` to manage verification state and display results.
- Updated CSS for verification badges and hash mismatch indicators. Fixes #221
2025-06-12 12:06:01 +08:00
Will Miao
78cac2edc2 Add DoRA type support. move VALID_LORA_TYPES to utils.constants and update imports in recipe parsers and API routes. 2025-06-12 09:25:00 +08:00
Will Miao
26d105c439 Enhance Civitai model handling: add get_model_version method for detailed metadata retrieval, update routes to utilize new method, and improve URL handling in context menu for model re-linking. 2025-06-11 22:06:16 +08:00
Will Miao
7fec107b98 Refactor context menus to use ModelContextMenuMixin for shared functionality
- Introduced ModelContextMenuMixin to encapsulate shared methods for Lora and Checkpoint context menus.
- Updated CheckpointContextMenu to utilize the mixin for common actions and NSFW level handling.
- Simplified LoraContextMenu by integrating the mixin, removing redundant methods.
- Removed duplicated NSFW handling logic and centralized it in the mixin.
- Adjusted import/export statements to reflect the new structure and ensure proper functionality.
2025-06-11 20:52:45 +08:00
Will Miao
eb01ad3af9 Refactor model response inclusion to only include groups with multiple models; update model removal logic to accept hash value. See #221 2025-06-11 19:52:44 +08:00
Will Miao
e0d9880b32 Remove duplicate hash entries with a single path in get_duplicate_hashes method 2025-06-11 17:33:13 +08:00
Will Miao
e81e96f0ab Refactor file monitoring and model scanning; remove unused monitors and streamline model file deletion process. 2025-06-11 17:02:10 +08:00
Will Miao
06d5bd259c Refactor model file processing in ModelScanner to determine root paths and enhance error logging for missing roots. 2025-06-11 15:53:35 +08:00
Will Miao
14238b8d62 Update preview URL handling in load_metadata function to reflect model location changes. See #113 2025-06-11 15:43:12 +08:00
Will Miao
3b51886927 Add cache file control to ModelScanner; implement flags to enable/disable cache usage and clear cache files accordingly. See #222 2025-06-11 09:17:10 +08:00
Will Miao
a295ff2e06 Refactor video embed implementation to enhance privacy and user experience; replace iframe with a privacy-friendly video container and add external link buttons for YouTube access. 2025-06-10 06:44:08 +08:00
130 changed files with 12729 additions and 7910 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,5 @@
# These are supported funding model platforms
patreon: PixelPawsAI
ko_fi: pixelpawsai
custom: ['paypal.me/pixelpawsai']

View File

@@ -22,6 +22,21 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes
### v0.8.19
* **Analytics Dashboard** - Added new Statistics page providing comprehensive visual analysis of model collection and usage patterns for better library insights
* **Target Node Selection** - Enhanced workflow integration with intelligent target choosing when sending LoRAs/recipes to workflows with multiple loader/stacker nodes; a visual selector now appears showing node color, type, ID, and title for precise targeting
* **Enhanced NSFW Controls** - Added support for setting NSFW levels on recipes with automatic content blurring based on user preferences
* **Customizable Card Display** - New display settings allowing users to choose whether card information and action buttons are always visible or only revealed on hover
* **Expanded Compatibility** - Added support for efficiency-nodes-comfyui in Save Recipe and Save Image nodes, plus fixed compatibility with ComfyUI_Custom_Nodes_AlekPet
### v0.8.18
* **Custom Example Images** - Added ability to import your own example images for LoRAs and checkpoints with automatic metadata extraction from embedded information
* **Enhanced Example Management** - New action buttons to set specific examples as previews or delete custom examples
* **Improved Duplicate Detection** - Enhanced "Find Duplicates" with hash verification feature to eliminate false positives when identifying duplicate models
* **Tag Management** - Added tag editing functionality allowing users to customize and manage model tags
* **Advanced Selection Controls** - Implemented Ctrl+A shortcut for quickly selecting all filtered LoRAs, automatically entering bulk mode when needed
* **Note**: Cache file functionality temporarily disabled pending rework
### v0.8.17
* **Duplicate Model Detection** - Added "Find Duplicates" functionality for LoRAs and checkpoints using model file hash detection, enabling convenient viewing and batch deletion of duplicate models
* **Enhanced URL Recipe Imports** - Optimized import recipe via URL functionality using CivitAI API calls instead of web scraping, now supporting all rated images (including NSFW) for recipe imports
@@ -93,13 +108,6 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
- 🚀 **High Performance**
- Fast model loading and browsing
- Smooth scrolling through large collections
- Real-time updates when files change
- 📂 **Advanced Organization**
- Quick search with fuzzy matching
- Folder-based categorization
- Move LoRAs between folders
- Sort by name or date
- 🌐 **Rich Model Integration**
- Direct download from CivitAI
@@ -263,6 +271,8 @@ If you find this project helpful, consider supporting its development:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/pixelpawsai)
[![Patreon](https://img.shields.io/badge/Become%20a%20Patron-F96854.svg?style=for-the-badge&logo=patreon&logoColor=white)](https://patreon.com/PixelPawsAI)
WeChat: [Click to view QR code](https://raw.githubusercontent.com/willmiao/ComfyUI-Lora-Manager/main/static/images/wechat-qr.webp)
## 💬 Community

View File

@@ -1,18 +1,22 @@
import asyncio
import sys
import os
import logging
from pathlib import Path
from server import PromptServer # type: ignore
from .config import config
from .routes.lora_routes import LoraRoutes
from .routes.api_routes import ApiRoutes
from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes
from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes
from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry
from .services.settings_manager import settings
import logging
import sys
import os
from .utils.example_images_migration import ExampleImagesMigration
logger = logging.getLogger(__name__)
@@ -94,10 +98,14 @@ class LoraManager:
route_path = f'/loras_static/link_{link_idx["lora"]}/preview'
link_idx["lora"] += 1
app.router.add_static(route_path, target_path)
logger.info(f"Added static route for link target {route_path} -> {target_path}")
config.add_route_mapping(target_path, route_path)
added_targets.add(target_path)
try:
app.router.add_static(route_path, Path(target_path).resolve(strict=False))
logger.info(f"Added static route for link target {route_path} -> {target_path}")
config.add_route_mapping(target_path, route_path)
added_targets.add(target_path)
except Exception as e:
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
continue
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)
@@ -105,10 +113,12 @@ class LoraManager:
# Setup feature routes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
stats_routes = StatsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
stats_routes.setup_routes(app) # Add statistics routes
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)
@@ -130,26 +140,13 @@ class LoraManager:
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# Initialize CivitaiClient first to ensure it's ready for other services
civitai_client = await ServiceRegistry.get_civitai_client()
# Get file monitors through ServiceRegistry
lora_monitor = await ServiceRegistry.get_lora_monitor()
checkpoint_monitor = await ServiceRegistry.get_checkpoint_monitor()
# Start monitors
lora_monitor.start()
logger.debug("Lora monitor started")
# Make sure checkpoint monitor has paths before starting
await checkpoint_monitor.initialize_paths()
checkpoint_monitor.start()
logger.debug("Checkpoint monitor started")
await ServiceRegistry.get_civitai_client()
# Register DownloadManager with ServiceRegistry
download_manager = await ServiceRegistry.get_download_manager()
await ServiceRegistry.get_download_manager()
# Initialize WebSocket manager
ws_manager = await ServiceRegistry.get_websocket_manager()
await ServiceRegistry.get_websocket_manager()
# Initialize scanners in background
lora_scanner = await ServiceRegistry.get_lora_scanner()
@@ -168,6 +165,8 @@ class LoraManager:
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(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
await ExampleImagesMigration.check_and_run_migrations()
logger.info("LoRA Manager: All services initialized and background tasks scheduled")
@@ -179,17 +178,6 @@ class LoraManager:
"""Cleanup resources using ServiceRegistry"""
try:
logger.info("LoRA Manager: Cleaning up services")
# Get monitors from ServiceRegistry
lora_monitor = await ServiceRegistry.get_service("lora_monitor")
if lora_monitor:
lora_monitor.stop()
logger.info("Stopped LoRA monitor")
checkpoint_monitor = await ServiceRegistry.get_service("checkpoint_monitor")
if checkpoint_monitor:
checkpoint_monitor.stop()
logger.info("Stopped checkpoint monitor")
# Close CivitaiClient gracefully
civitai_client = await ServiceRegistry.get_service("civitai_client")

View File

@@ -1,5 +1,6 @@
import json
import sys
from .constants import IMAGES
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
@@ -18,6 +19,10 @@ class MetadataProcessor:
- metadata: The workflow metadata
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
"""
if downstream_id is None:
if IMAGES in metadata and "first_decode" in metadata[IMAGES]:
downstream_id = metadata[IMAGES]["first_decode"]["node_id"]
# If we have a downstream_id and execution_order, use it to narrow down potential samplers
if downstream_id and "execution_order" in metadata:
execution_order = metadata["execution_order"]
@@ -209,6 +214,52 @@ class MetadataProcessor:
return None
@staticmethod
def match_conditioning_to_prompts(metadata, sampler_id):
"""
Match conditioning objects from a sampler to prompts in metadata
Parameters:
- metadata: The workflow metadata
- sampler_id: ID of the sampler node to match
Returns:
- Dictionary with 'prompt' and 'negative_prompt' if found
"""
result = {
"prompt": "",
"negative_prompt": ""
}
# Check if we have stored conditioning objects for this sampler
if sampler_id in metadata.get(PROMPTS, {}) and (
"pos_conditioning" in metadata[PROMPTS][sampler_id] or
"neg_conditioning" in metadata[PROMPTS][sampler_id]):
pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning")
neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning")
# Try to match conditioning objects with those stored by CLIPTextEncodeExtractor
for prompt_node_id, prompt_data in metadata[PROMPTS].items():
# For nodes with single conditioning output
if "conditioning" in prompt_data:
if pos_conditioning is not None and id(prompt_data["conditioning"]) == id(pos_conditioning):
result["prompt"] = prompt_data.get("text", "")
if neg_conditioning is not None and id(prompt_data["conditioning"]) == id(neg_conditioning):
result["negative_prompt"] = prompt_data.get("text", "")
# For nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader)
if "positive_encoded" in prompt_data:
if pos_conditioning is not None and id(prompt_data["positive_encoded"]) == id(pos_conditioning):
result["prompt"] = prompt_data.get("positive_text", "")
if "negative_encoded" in prompt_data:
if neg_conditioning is not None and id(prompt_data["negative_encoded"]) == id(neg_conditioning):
result["negative_prompt"] = prompt_data.get("negative_text", "")
return result
@staticmethod
def extract_generation_params(metadata, id=None):
"""
@@ -261,7 +312,6 @@ class MetadataProcessor:
params["sampler"] = sampling_params.get("sampler_name")
params["scheduler"] = sampling_params.get("scheduler")
# Trace connections from the primary sampler
if prompt and primary_sampler_id:
# Check if this is a SamplerCustomAdvanced node
is_custom_advanced = False
@@ -309,29 +359,36 @@ class MetadataProcessor:
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
else:
# Original tracing for standard samplers
# Trace positive prompt - look specifically for CLIPTextEncode
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
else:
# If CLIPTextEncode is not found, try to find CLIPTextEncodeFlux
positive_flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncodeFlux", max_depth=10)
if positive_flux_node_id and positive_flux_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_flux_node_id].get("text", "")
# Trace negative prompt - look specifically for CLIPTextEncode
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
# For standard samplers, match conditioning objects to prompts
prompt_results = MetadataProcessor.match_conditioning_to_prompts(metadata, primary_sampler_id)
params["prompt"] = prompt_results["prompt"]
params["negative_prompt"] = prompt_results["negative_prompt"]
# If prompts were still not found, fall back to tracing connections
if not params["prompt"]:
# Original tracing for standard samplers
# Trace positive prompt - look specifically for CLIPTextEncode
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
else:
# If CLIPTextEncode is not found, try to find CLIPTextEncodeFlux
positive_flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncodeFlux", max_depth=10)
if positive_flux_node_id and positive_flux_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_flux_node_id].get("text", "")
# Trace negative prompt - look specifically for CLIPTextEncode
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
# Size extraction is same for all sampler types
# Check if the sampler itself has size information (from latent_image)
if primary_sampler_id in metadata.get(SIZE, {}):
width = metadata[SIZE][primary_sampler_id].get("width")
height = metadata[SIZE][primary_sampler_id].get("height")
if width and height:
params["size"] = f"{width}x{height}"
# Size extraction is same for all sampler types
# Check if the sampler itself has size information (from latent_image)
if primary_sampler_id in metadata.get(SIZE, {}):
width = metadata[SIZE][primary_sampler_id].get("width")
height = metadata[SIZE][primary_sampler_id].get("height")
if width and height:
params["size"] = f"{width}x{height}"
# Extract LoRAs using the standardized format
lora_parts = []

View File

@@ -35,7 +35,70 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
"type": "checkpoint",
"node_id": node_id
}
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "ckpt_name" not in inputs:
return
model_name = inputs.get("ckpt_name")
if model_name:
metadata[MODELS][node_id] = {
"name": model_name,
"type": "checkpoint",
"node_id": node_id
}
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
active_loras = []
# Process lora_stack if available
if "lora_stack" in inputs:
lora_stack = inputs.get("lora_stack", [])
for lora_path, model_strength, clip_strength in lora_stack:
# Extract lora name from path (following the format in lora_loader.py)
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": model_strength
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
# Extract positive and negative prompt text if available
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
# Store both positive and negative text
metadata[PROMPTS][node_id]["positive_text"] = positive_text
metadata[PROMPTS][node_id]["negative_text"] = negative_text
@staticmethod
def update(node_id, outputs, metadata):
# Handle conditioning outputs from TSC_EfficientLoader
# outputs is a list with [(model, positive_encoded, negative_encoded, {"samples":latent}, vae, clip, dependencies,)]
if outputs and isinstance(outputs, list) and len(outputs) > 0:
first_output = outputs[0]
if isinstance(first_output, tuple) and len(first_output) >= 3:
positive_conditioning = first_output[1]
negative_conditioning = first_output[2]
# Save both conditioning objects in metadata
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -47,6 +110,13 @@ class CLIPTextEncodeExtractor(NodeMetadataExtractor):
"text": text,
"node_id": node_id
}
@staticmethod
def update(node_id, outputs, metadata):
if outputs and isinstance(outputs, list) and len(outputs) > 0:
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
conditioning = outputs[0][0]
metadata[PROMPTS][node_id]["conditioning"] = conditioning
class SamplerExtractor(NodeMetadataExtractor):
@staticmethod
@@ -64,6 +134,18 @@ class SamplerExtractor(NodeMetadataExtractor):
"node_id": node_id,
IS_SAMPLER: True # Add sampler flag
}
# Store the conditioning objects directly in metadata for later matching
pos_conditioning = inputs.get("positive", None)
neg_conditioning = inputs.get("negative", None)
# Save conditioning objects in metadata for later matching
if pos_conditioning is not None or neg_conditioning is not None:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["pos_conditioning"] = pos_conditioning
metadata[PROMPTS][node_id]["neg_conditioning"] = neg_conditioning
# Extract latent image dimensions if available
if "latent_image" in inputs and inputs["latent_image"] is not None:
@@ -103,6 +185,18 @@ class KSamplerAdvancedExtractor(NodeMetadataExtractor):
IS_SAMPLER: True # Add sampler flag
}
# Store the conditioning objects directly in metadata for later matching
pos_conditioning = inputs.get("positive", None)
neg_conditioning = inputs.get("negative", None)
# Save conditioning objects in metadata for later matching
if pos_conditioning is not None or neg_conditioning is not None:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["pos_conditioning"] = pos_conditioning
metadata[PROMPTS][node_id]["neg_conditioning"] = neg_conditioning
# Extract latent image dimensions if available
if "latent_image" in inputs and inputs["latent_image"] is not None:
latent = inputs["latent_image"]
@@ -124,6 +218,81 @@ class KSamplerAdvancedExtractor(NodeMetadataExtractor):
"node_id": node_id
}
class TSCSamplerBaseExtractor(NodeMetadataExtractor):
"""Base extractor for handling TSC sampler node outputs"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Store vae_decode setting for later use in update
if inputs and "vae_decode" in inputs:
if SAMPLING not in metadata:
metadata[SAMPLING] = {}
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
# Store the vae_decode setting
metadata[SAMPLING][node_id]["vae_decode"] = inputs["vae_decode"]
@staticmethod
def update(node_id, outputs, metadata):
# Check if vae_decode was set to "true"
should_save_image = True
if SAMPLING in metadata and node_id in metadata[SAMPLING]:
vae_decode = metadata[SAMPLING][node_id].get("vae_decode")
if vae_decode is not None:
should_save_image = (vae_decode == "true")
# Skip image saving if vae_decode isn't "true"
if not should_save_image:
return
# Ensure IMAGES category exists
if IMAGES not in metadata:
metadata[IMAGES] = {}
# Extract output_images from the TSC sampler format
# outputs = [{"ui": {"images": preview_images}, "result": result}]
# where result = (original_model, original_positive, original_negative, latent_list, optional_vae, output_images,)
if outputs and isinstance(outputs, list) and len(outputs) > 0:
# Get the first item in the list
output_item = outputs[0]
if isinstance(output_item, dict) and "result" in output_item:
result = output_item["result"]
if isinstance(result, tuple) and len(result) >= 6:
# The output_images is the last element in the result tuple
output_images = (result[5],)
# Save image data under node ID index to be captured by caching mechanism
metadata[IMAGES][node_id] = {
"node_id": node_id,
"image": output_images
}
# Only set first_decode if it hasn't been recorded yet
if "first_decode" not in metadata[IMAGES]:
metadata[IMAGES]["first_decode"] = metadata[IMAGES][node_id]
class TSCKSamplerExtractor(SamplerExtractor, TSCSamplerBaseExtractor):
"""Extractor for TSC_KSampler nodes"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Call parent extract methods
SamplerExtractor.extract(node_id, inputs, outputs, metadata)
TSCSamplerBaseExtractor.extract(node_id, inputs, outputs, metadata)
# Update method is inherited from TSCSamplerBaseExtractor
class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtractor):
"""Extractor for TSC_KSamplerAdvanced nodes"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Call parent extract methods
SamplerExtractor.extract(node_id, inputs, outputs, metadata)
TSCSamplerBaseExtractor.extract(node_id, inputs, outputs, metadata)
# Update method is inherited from TSCSamplerBaseExtractor
class LoraLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -376,6 +545,13 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id]["parameters"]["guidance"] = guidance_value
@staticmethod
def update(node_id, outputs, metadata):
if outputs and isinstance(outputs, list) and len(outputs) > 0:
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
conditioning = outputs[0][0]
metadata[PROMPTS][node_id]["conditioning"] = conditioning
class CFGGuiderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -394,16 +570,21 @@ class CFGGuiderExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id]["parameters"]["cfg"] = cfg_value
# Registry of node-specific extractors
# Keys are node class names
NODE_EXTRACTORS = {
# Sampling
"KSampler": SamplerExtractor,
"KSamplerAdvanced": KSamplerAdvancedExtractor,
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor, # Updated to use dedicated extractor
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
# Sampling Selectors
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
"LoraLoader": LoraLoaderExtractor,
@@ -412,6 +593,7 @@ NODE_EXTRACTORS = {
"CLIPTextEncode": CLIPTextEncodeExtractor,
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
# Latent
"EmptyLatentImage": ImageSizeExtractor,
# Flux

View File

@@ -1,4 +1,5 @@
import logging
from server import PromptServer # type: ignore
from ..metadata_collector.metadata_processor import MetadataProcessor
logger = logging.getLogger(__name__)
@@ -7,6 +8,7 @@ class DebugMetadata:
NAME = "Debug Metadata (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Debug node to verify metadata_processor functionality"
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
@@ -19,8 +21,7 @@ class DebugMetadata:
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("metadata_json",)
RETURN_TYPES = ()
FUNCTION = "process_metadata"
def process_metadata(self, images, id):
@@ -32,7 +33,13 @@ class DebugMetadata:
# Use the MetadataProcessor to convert it to JSON string
metadata_json = MetadataProcessor.to_json(metadata, id)
return (metadata_json,)
# Send metadata to frontend for display
PromptServer.instance.send_sync("metadata_update", {
"id": id,
"metadata": metadata_json
})
except Exception as e:
logger.error(f"Error processing metadata: {e}")
return ("{}",) # Return empty JSON object in case of error
return ()

View File

@@ -50,15 +50,9 @@ class TriggerWordToggle:
def process_trigger_words(self, id, group_mode, default_active, **kwargs):
# Handle both old and new formats for trigger_words
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
trigger_words_data = self._get_toggle_data(kwargs, 'orinalMessage')
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
# Send trigger words to frontend
# PromptServer.instance.send_sync("trigger_word_update", {
# "id": id,
# "message": trigger_words
# })
filtered_triggers = trigger_words
# Get toggle data with support for both formats

View File

@@ -7,7 +7,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod
from ..config import config
from .constants import VALID_LORA_TYPES
from ..utils.constants import VALID_LORA_TYPES
logger = logging.getLogger(__name__)
@@ -79,6 +79,9 @@ class RecipeMetadataParser(ABC):
if 'model' in civitai_info and 'name' in civitai_info['model']:
lora_entry['name'] = civitai_info['model']['name']
lora_entry['id'] = civitai_info.get('id')
lora_entry['modelId'] = civitai_info.get('modelId')
# Update version if available
if 'name' in civitai_info:
lora_entry['version'] = civitai_info.get('name', '')

View File

@@ -1,5 +1,8 @@
"""Constants used across recipe parsers."""
# Import VALID_LORA_TYPES from utils.constants
from ..utils.constants import VALID_LORA_TYPES
# Constants for generation parameters
GEN_PARAM_KEYS = [
'prompt',
@@ -11,6 +14,3 @@ GEN_PARAM_KEYS = [
'size',
'clip_skip',
]
# Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon']

View File

@@ -184,8 +184,8 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
# Initialize lora entry
lora_entry = {
'id': str(resource.get("modelVersionId")),
'modelId': str(resource.get("modelId")) if resource.get("modelId") else None,
'id': resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""),
'type': resource.get("type", "lora"),

View File

@@ -141,8 +141,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
# Initialize lora entry with the same structure as in automatic.py
lora_entry = {
'id': str(resource.get("modelVersionId")),
'modelId': str(resource.get("modelId")) if resource.get("modelId") else None,
'id': resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""),
'type': resource.get("type", "lora"),
@@ -235,7 +235,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
except Exception as e:
logger.error(f"Error fetching Civitai info for model ID {model_id}: {e}")
result["loras"].append(lora_entry)
result["loras"].append(lora_entry)
# If base model wasn't found earlier, use the most common one from LoRAs
if not result["base_model"] and base_model_counts:

View File

@@ -43,7 +43,7 @@ class RecipeFormatParser(RecipeMetadataParser):
for lora in recipe_metadata.get('loras', []):
# Convert recipe lora format to frontend format
lora_entry = {
'id': lora.get('modelVersionId', ''),
'id': int(lora.get('modelVersionId', 0)),
'name': lora.get('modelName', ''),
'version': lora.get('modelVersionName', ''),
'type': 'lora',

View File

@@ -10,11 +10,11 @@ from ..nodes.utils import get_lora_info
from ..config import config
from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings
import asyncio
from .update_routes import UpdateRoutes
from ..utils.constants import PREVIEW_EXTENSIONS, CARD_PREVIEW_WIDTH
from ..utils.constants import PREVIEW_EXTENSIONS, CARD_PREVIEW_WIDTH, VALID_LORA_TYPES
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from ..services.service_registry import ServiceRegistry
logger = logging.getLogger(__name__)
@@ -65,7 +65,7 @@ class ApiRoutes:
app.router.add_get('/api/loras/top-tags', routes.get_top_tags) # Add new route for top tags
app.router.add_get('/api/loras/base-models', routes.get_base_models) # Add new route for base models
app.router.add_get('/api/lora-civitai-url', routes.get_lora_civitai_url) # Add new route for Civitai URL
app.router.add_post('/api/rename_lora', routes.rename_lora) # Add new route for renaming LoRA files
app.router.add_post('/api/loras/rename', routes.rename_lora) # Add new route for renaming LoRA files
app.router.add_get('/api/loras/scan', routes.scan_loras) # Add new route for scanning LoRA files
# Add the new trigger words route
@@ -88,6 +88,9 @@ class ApiRoutes:
# Add new endpoint for bulk deleting loras
app.router.add_post('/api/loras/bulk-delete', routes.bulk_delete_loras)
# Add new endpoint for verifying duplicates
app.router.add_post('/api/loras/verify-duplicates', routes.verify_duplicates)
async def delete_model(self, request: web.Request) -> web.Response:
"""Handle model deletion request"""
if self.scanner is None:
@@ -104,7 +107,21 @@ class ApiRoutes:
"""Handle CivitAI metadata fetch request"""
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_fetch_civitai(request, self.scanner)
response = await ModelRouteUtils.handle_fetch_civitai(request, self.scanner)
# If successful, format the metadata before returning
if response.status == 200:
data = json.loads(response.body.decode('utf-8'))
if data.get("success") and data.get("metadata"):
formatted_metadata = self._format_lora_response(data["metadata"])
return web.json_response({
"success": True,
"metadata": formatted_metadata
})
# Otherwise, return the original response
return response
async def replace_preview(self, request: web.Request) -> web.Response:
"""Handle preview image replacement request"""
@@ -229,66 +246,6 @@ class ApiRoutes:
"civitai": ModelRouteUtils.filter_civitai_data(lora.get("civitai", {}))
}
# Private helper methods
async def _read_preview_file(self, reader) -> tuple[bytes, str]:
"""Read preview file and content type from multipart request"""
field = await reader.next()
if field.name != 'preview_file':
raise ValueError("Expected 'preview_file' field")
content_type = field.headers.get('Content-Type', 'image/png')
return await field.read(), content_type
async def _read_model_path(self, reader) -> str:
"""Read model path from multipart request"""
field = await reader.next()
if field.name != 'model_path':
raise ValueError("Expected 'model_path' field")
return (await field.read()).decode()
async def _save_preview_file(self, model_path: str, preview_data: bytes, content_type: str) -> str:
"""Save preview file and return its path"""
base_name = os.path.splitext(os.path.basename(model_path))[0]
folder = os.path.dirname(model_path)
# Determine if content is video or image
if content_type.startswith('video/'):
# For videos, keep original format and use .mp4 extension
extension = '.mp4'
optimized_data = preview_data
else:
# For images, optimize and convert to WebP
optimized_data, _ = ExifUtils.optimize_image(
image_data=preview_data,
target_width=CARD_PREVIEW_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
extension = '.webp' # Use .webp without .preview part
preview_path = os.path.join(folder, base_name + extension).replace(os.sep, '/')
with open(preview_path, 'wb') as f:
f.write(optimized_data)
return preview_path
async def _update_preview_metadata(self, model_path: str, preview_path: str):
"""Update preview path in metadata"""
metadata_path = os.path.splitext(model_path)[0] + '.metadata.json'
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
# Update preview_url directly in the metadata dict
metadata['preview_url'] = preview_path
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Error updating metadata: {e}")
async def fetch_all_civitai(self, request: web.Request) -> web.Response:
"""Fetch CivitAI metadata for all loras in the background"""
try:
@@ -401,8 +358,8 @@ class ApiRoutes:
versions = response.get('modelVersions', [])
model_type = response.get('type', '')
# Check model type - should be LORA or LoCon
if model_type.lower() not in ['lora', 'locon']:
# Check model type - should be LORA, LoCon, or DORA
if model_type.lower() not in VALID_LORA_TYPES:
return web.json_response({
'error': f"Model type mismatch. Expected LORA or LoCon, got {model_type}"
}, status=400)
@@ -624,8 +581,7 @@ class ApiRoutes:
metadata[key] = value
# Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(file_path, metadata)
# Update cache
await self.scanner.update_single_model_cache(file_path, file_path, metadata)
@@ -836,11 +792,13 @@ class ApiRoutes:
metadata['modelDescription'] = description
metadata['tags'] = tags
metadata['creator'] = creator
# Ensure the civitai dict exists
if 'civitai' not in metadata:
metadata['civitai'] = {}
# Store creator in the civitai nested structure
metadata['civitai']['creator'] = creator
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
logger.info(f"Saved model metadata to file for {file_path}")
await MetadataManager.save_metadata(file_path, metadata, True)
except Exception as e:
logger.error(f"Error saving model metadata: {e}")
@@ -915,139 +873,10 @@ class ApiRoutes:
async def rename_lora(self, request: web.Request) -> web.Response:
"""Handle renaming a LoRA file and its associated files"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
if self.download_manager is None:
self.download_manager = await ServiceRegistry.get_download_manager()
data = await request.json()
file_path = data.get('file_path')
new_file_name = data.get('new_file_name')
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
if not file_path or not new_file_name:
return web.json_response({
'success': False,
'error': 'File path and new file name are required'
}, status=400)
# Validate the new file name (no path separators or invalid characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
if any(char in new_file_name for char in invalid_chars):
return web.json_response({
'success': False,
'error': 'Invalid characters in file name'
}, status=400)
# Get the directory and current file name
target_dir = os.path.dirname(file_path)
old_file_name = os.path.splitext(os.path.basename(file_path))[0]
# Check if the target file already exists
new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/')
if os.path.exists(new_file_path):
return web.json_response({
'success': False,
'error': 'A file with this name already exists'
}, status=400)
# Define the patterns for associated files
patterns = [
f"{old_file_name}.safetensors", # Required
f"{old_file_name}.metadata.json",
]
# Add all preview file extensions
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}")
# Find all matching files
existing_files = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
# Get the hash from the main file to update hash index
hash_value = None
metadata = None
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
if os.path.exists(metadata_path):
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
hash_value = metadata.get('sha256')
# Rename all files
renamed_files = []
new_metadata_path = None
# Notify file monitor to ignore these events
main_file_path = os.path.join(target_dir, f"{old_file_name}.safetensors")
if os.path.exists(main_file_path):
# Get lora monitor through ServiceRegistry instead of download_manager
lora_monitor = await ServiceRegistry.get_lora_monitor()
if lora_monitor:
# Add old and new paths to ignore list
file_size = os.path.getsize(main_file_path)
lora_monitor.handler.add_ignore_path(main_file_path, file_size)
lora_monitor.handler.add_ignore_path(new_file_path, file_size)
for old_path, pattern in existing_files:
# Get the file extension like .safetensors or .metadata.json
ext = ModelRouteUtils.get_multipart_ext(pattern)
# Create the new path
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
# Rename the file
os.rename(old_path, new_path)
renamed_files.append(new_path)
# Keep track of metadata path for later update
if ext == '.metadata.json':
new_metadata_path = new_path
# Update the metadata file with new file name and paths
if new_metadata_path and metadata:
# Update file_name, file_path and preview_url in metadata
metadata['file_name'] = new_file_name
metadata['file_path'] = new_file_path
# Update preview_url if it exists
if 'preview_url' in metadata and metadata['preview_url']:
old_preview = metadata['preview_url']
ext = ModelRouteUtils.get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
metadata['preview_url'] = new_preview
# Save updated metadata
with open(new_metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
# Update the scanner cache
if metadata:
await self.scanner.update_single_model_cache(file_path, new_file_path, metadata)
# Update recipe files and cache if hash is available
if hash_value:
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name)
logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed LoRA")
return web.json_response({
'success': True,
'new_file_path': new_file_path,
'renamed_files': renamed_files,
'reload_required': False
})
except Exception as e:
logger.error(f"Error renaming LoRA: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return await ModelRouteUtils.handle_rename_model(request, self.scanner)
async def get_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for specified LoRA models"""
@@ -1209,7 +1038,7 @@ class ApiRoutes:
if primary_model:
group["models"].insert(0, self._format_lora_response(primary_model))
if group["models"]: # Only include if we found models
if len(group["models"]) > 1: # Only include if we found multiple models
result.append(group)
return web.json_response({
@@ -1292,3 +1121,9 @@ class ApiRoutes:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)
async def verify_duplicates(self, request: web.Request) -> web.Response:
"""Handle verification of duplicate lora hashes"""
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_verify_duplicates(request, self.scanner)

View File

@@ -7,6 +7,7 @@ import asyncio
from ..utils.routes_common import ModelRouteUtils
from ..utils.constants import NSFW_LEVELS
from ..utils.metadata_manager import MetadataManager
from ..services.websocket_manager import ws_manager
from ..services.service_registry import ServiceRegistry
from ..config import config
@@ -55,6 +56,7 @@ class CheckpointsRoutes:
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
app.router.add_post('/api/checkpoints/rename', self.rename_checkpoint) # Add new rename endpoint
# Add new WebSocket endpoint for checkpoint progress
app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection)
@@ -66,6 +68,9 @@ class CheckpointsRoutes:
# Add new endpoint for bulk deleting checkpoints
app.router.add_post('/api/checkpoints/bulk-delete', self.bulk_delete_checkpoints)
# Add new endpoint for verifying duplicates
app.router.add_post('/api/checkpoints/verify-duplicates', self.verify_duplicates)
async def get_checkpoints(self, request):
"""Get paginated checkpoint data"""
try:
@@ -518,7 +523,20 @@ class CheckpointsRoutes:
async def fetch_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata fetch request for checkpoints"""
return await ModelRouteUtils.handle_fetch_civitai(request, self.scanner)
response = await ModelRouteUtils.handle_fetch_civitai(request, self.scanner)
# If successful, format the metadata before returning
if response.status == 200:
data = json.loads(response.body.decode('utf-8'))
if data.get("success") and data.get("metadata"):
formatted_metadata = self._format_checkpoint_response(data["metadata"])
return web.json_response({
"success": True,
"metadata": formatted_metadata
})
# Otherwise, return the original response
return response
async def replace_preview(self, request: web.Request) -> web.Response:
"""Handle preview image replacement for checkpoints"""
@@ -634,8 +652,7 @@ class CheckpointsRoutes:
metadata.update(metadata_updates)
# Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(file_path, metadata)
# Update cache
await self.scanner.update_single_model_cache(file_path, file_path, metadata)
@@ -735,7 +752,7 @@ class CheckpointsRoutes:
if primary_model:
group["models"].insert(0, self._format_checkpoint_response(primary_model))
if group["models"]:
if len(group["models"]) > 1: # Only include if we found multiple models
result.append(group)
return web.json_response({
@@ -816,3 +833,11 @@ class CheckpointsRoutes:
async def relink_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID for checkpoints"""
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)
async def verify_duplicates(self, request: web.Request) -> web.Response:
"""Handle verification of duplicate checkpoint hashes"""
return await ModelRouteUtils.handle_verify_duplicates(request, self.scanner)
async def rename_checkpoint(self, request: web.Request) -> web.Response:
"""Handle renaming a checkpoint file and its associated files"""
return await ModelRouteUtils.handle_rename_model(request, self.scanner)

File diff suppressed because it is too large Load Diff

View File

@@ -70,8 +70,7 @@ class LoraRoutes:
# It's initializing if the cache object doesn't exist yet,
# OR if the scanner explicitly says it's initializing (background task running).
is_initializing = (
self.scanner._cache is None or
(hasattr(self.scanner, '_is_initializing') and self.scanner._is_initializing)
self.scanner._cache is None or self.scanner.is_initializing()
)
if is_initializing:

View File

@@ -1,31 +1,83 @@
import logging
import os
import sys
import threading
import asyncio
from server import PromptServer # type: ignore
from aiohttp import web
from ..services.settings_manager import settings
from ..utils.usage_stats import UsageStats
from ..utils.lora_metadata import extract_trained_words
from ..config import config
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS, NODE_TYPES, DEFAULT_NODE_COLOR
import re
logger = logging.getLogger(__name__)
# Download status tracking
download_task = None
is_downloading = False
download_progress = {
'total': 0,
'completed': 0,
'current_model': '',
'status': 'idle', # idle, running, paused, completed, error
'errors': [],
'last_error': None,
'start_time': None,
'end_time': None,
'processed_models': set(), # Track models that have been processed
'refreshed_models': set() # Track models that had metadata refreshed
}
standalone_mode = 'nodes' not in sys.modules
# Node registry for tracking active workflow nodes
class NodeRegistry:
"""Thread-safe registry for tracking Lora nodes in active workflows"""
def __init__(self):
self._lock = threading.RLock()
self._nodes = {} # node_id -> node_info
self._registry_updated = threading.Event()
def register_nodes(self, nodes):
"""Register multiple nodes at once, replacing existing registry"""
with self._lock:
# Clear existing registry
self._nodes.clear()
# Register all new nodes
for node in nodes:
node_id = node['node_id']
node_type = node.get('type', '')
# Convert node type name to integer
type_id = NODE_TYPES.get(node_type, 0) # 0 for unknown types
# Handle null bgcolor with default color
bgcolor = node.get('bgcolor')
if bgcolor is None:
bgcolor = DEFAULT_NODE_COLOR
self._nodes[node_id] = {
'id': node_id,
'bgcolor': bgcolor,
'title': node.get('title'),
'type': type_id,
'type_name': node_type
}
logger.debug(f"Registered {len(nodes)} nodes in registry")
# Signal that registry has been updated
self._registry_updated.set()
def get_registry(self):
"""Get current registry information"""
with self._lock:
return {
'nodes': dict(self._nodes), # Return a copy
'node_count': len(self._nodes)
}
def clear_registry(self):
"""Clear the entire registry"""
with self._lock:
self._nodes.clear()
logger.info("Node registry cleared")
def wait_for_update(self, timeout=1.0):
"""Wait for registry update with timeout"""
self._registry_updated.clear()
return self._registry_updated.wait(timeout)
# Global registry instance
node_registry = NodeRegistry()
class MiscRoutes:
"""Miscellaneous routes for various utility functions"""
@@ -50,6 +102,10 @@ class MiscRoutes:
# Add new route for getting model example files
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
# Node registry endpoints
app.router.add_post('/api/register-nodes', MiscRoutes.register_nodes)
app.router.add_get('/api/get-registry', MiscRoutes.get_registry)
@staticmethod
async def clear_cache(request):
@@ -83,10 +139,6 @@ class MiscRoutes:
'error': f"Failed to delete {filename}: {str(e)}"
}, status=500)
# If we want to completely remove the cache folder too (optional,
# but we'll keep the folder structure in place here)
# shutil.rmtree(cache_folder)
return web.json_response({
'success': True,
'message': f"Successfully cleared {len(deleted_files)} cache files",
@@ -403,3 +455,128 @@ class MiscRoutes:
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def register_nodes(request):
"""
Register multiple Lora nodes at once
Expects a JSON body with:
{
"nodes": [
{
"node_id": 123,
"bgcolor": "#535",
"title": "Lora Loader (LoraManager)"
},
...
]
}
"""
try:
data = await request.json()
# Validate required fields
nodes = data.get('nodes', [])
if not isinstance(nodes, list):
return web.json_response({
'success': False,
'error': 'nodes must be a list'
}, status=400)
# Validate each node
for i, node in enumerate(nodes):
if not isinstance(node, dict):
return web.json_response({
'success': False,
'error': f'Node {i} must be an object'
}, status=400)
node_id = node.get('node_id')
if node_id is None:
return web.json_response({
'success': False,
'error': f'Node {i} missing node_id parameter'
}, status=400)
# Validate node_id is an integer
try:
node['node_id'] = int(node_id)
except (ValueError, TypeError):
return web.json_response({
'success': False,
'error': f'Node {i} node_id must be an integer'
}, status=400)
# Register all nodes
node_registry.register_nodes(nodes)
return web.json_response({
'success': True,
'message': f'{len(nodes)} nodes registered successfully'
})
except Exception as e:
logger.error(f"Failed to register nodes: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_registry(request):
"""Get current node registry information by refreshing from frontend"""
try:
# Check if running in standalone mode
if standalone_mode:
logger.warning("Registry refresh not available in standalone mode")
return web.json_response({
'success': False,
'error': 'Standalone Mode Active',
'message': 'Cannot interact with ComfyUI in standalone mode.'
}, status=503)
# Send message to frontend to refresh registry
try:
PromptServer.instance.send_sync("lora_registry_refresh", {})
logger.debug("Sent registry refresh request to frontend")
except Exception as e:
logger.error(f"Failed to send registry refresh message: {e}")
return web.json_response({
'success': False,
'error': 'Communication Error',
'message': f'Failed to communicate with ComfyUI frontend: {str(e)}'
}, status=500)
# Wait for registry update with timeout
def wait_for_registry():
return node_registry.wait_for_update(timeout=1.0)
# Run the wait in a thread to avoid blocking the event loop
loop = asyncio.get_event_loop()
registry_updated = await loop.run_in_executor(None, wait_for_registry)
if not registry_updated:
logger.warning("Registry refresh timeout after 1 second")
return web.json_response({
'success': False,
'error': 'Timeout Error',
'message': 'Registry refresh timeout - ComfyUI frontend may not be responsive'
}, status=408)
# Get updated registry
registry_info = node_registry.get_registry()
return web.json_response({
'success': True,
'data': registry_info
})
except Exception as e:
logger.error(f"Failed to get registry: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': 'Internal Error',
'message': str(e)
}, status=500)

View File

@@ -648,7 +648,7 @@ class RecipeRoutes:
"file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0] if lora.get("localPath") else "",
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
"strength": float(lora.get("weight", 1.0)),
"modelVersionId": lora.get("id", ""),
"modelVersionId": lora.get("id", 0),
"modelName": lora.get("name", ""),
"modelVersionName": lora.get("version", ""),
"isDeleted": lora.get("isDeleted", False), # Preserve deletion status in saved recipe
@@ -996,7 +996,7 @@ class RecipeRoutes:
else:
latest_image = None
if not latest_image:
if latest_image is None:
return web.json_response({"error": "No recent images found to use for recipe. Try generating an image first."}, status=400)
# Convert the image data to bytes - handle tuple and tensor cases
@@ -1107,7 +1107,7 @@ class RecipeRoutes:
"file_name": lora_name,
"hash": lora_info.get("sha256", "").lower() if lora_info else "",
"strength": float(lora_strength),
"modelVersionId": lora_info.get("civitai", {}).get("id", "") if lora_info else "",
"modelVersionId": lora_info.get("civitai", {}).get("id", 0) if lora_info else 0,
"modelName": lora_info.get("civitai", {}).get("model", {}).get("name", "") if lora_info else lora_name,
"modelVersionName": lora_info.get("civitai", {}).get("name", "") if lora_info else "",
"isDeleted": False
@@ -1266,9 +1266,9 @@ class RecipeRoutes:
data = await request.json()
# Validate required fields
if 'title' not in data and 'tags' not in data and 'source_path' not in data:
if 'title' not in data and 'tags' not in data and 'source_path' not in data and 'preview_nsfw_level' not in data:
return web.json_response({
"error": "At least one field to update must be provided (title or tags or source_path)"
"error": "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)"
}, status=400)
# Use the recipe scanner's update method
@@ -1296,7 +1296,7 @@ class RecipeRoutes:
data = await request.json()
# Validate required fields
required_fields = ['recipe_id', 'lora_data', 'target_name']
required_fields = ['recipe_id', 'lora_index', 'target_name']
for field in required_fields:
if field not in data:
return web.json_response({
@@ -1304,7 +1304,7 @@ class RecipeRoutes:
}, status=400)
recipe_id = data['recipe_id']
lora_data = data['lora_data']
lora_index = int(data['lora_index'])
target_name = data['target_name']
# Get recipe scanner
@@ -1324,46 +1324,27 @@ class RecipeRoutes:
# Load recipe data
with open(recipe_path, 'r', encoding='utf-8') as f:
recipe_data = json.load(f)
# Find the deleted LoRA in the recipe
found = False
updated_lora = None
lora = recipe_data.get("loras", [])[lora_index] if lora_index < len(recipe_data.get('loras', [])) else None
if lora is None:
return web.json_response({"error": "LoRA index out of range in recipe"}, status=404)
# Update LoRA data
lora['isDeleted'] = False
lora['exclude'] = False
lora['file_name'] = target_name
# Identification can be by hash, modelVersionId, or modelName
for i, lora in enumerate(recipe_data.get('loras', [])):
match_found = False
# Try to match by available identifiers
if 'hash' in lora and 'hash' in lora_data and lora['hash'] == lora_data['hash']:
match_found = True
elif 'modelVersionId' in lora and 'modelVersionId' in lora_data and lora['modelVersionId'] == lora_data['modelVersionId']:
match_found = True
elif 'modelName' in lora and 'modelName' in lora_data and lora['modelName'] == lora_data['modelName']:
match_found = True
if match_found:
# Update LoRA data
lora['isDeleted'] = False
lora['file_name'] = target_name
# Update with information from the target LoRA
if 'sha256' in target_lora:
lora['hash'] = target_lora['sha256'].lower()
if target_lora.get("civitai"):
lora['modelName'] = target_lora['civitai']['model']['name']
lora['modelVersionName'] = target_lora['civitai']['name']
lora['modelVersionId'] = target_lora['civitai']['id']
# Keep original fields for identification
# Mark as found and store updated lora
found = True
updated_lora = dict(lora) # Make a copy for response
break
if not found:
return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404)
# Update with information from the target LoRA
if 'sha256' in target_lora:
lora['hash'] = target_lora['sha256'].lower()
if target_lora.get("civitai"):
lora['modelName'] = target_lora['civitai']['model']['name']
lora['modelVersionName'] = target_lora['civitai']['name']
lora['modelVersionId'] = target_lora['civitai']['id']
updated_lora = dict(lora) # Make a copy for response
# Recalculate recipe fingerprint after updating LoRA
from ..utils.utils import calculate_recipe_fingerprint
recipe_data['fingerprint'] = calculate_recipe_fingerprint(recipe_data.get('loras', []))
@@ -1373,7 +1354,7 @@ class RecipeRoutes:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
updated_lora['inLibrary'] = True
updated_lora['preview_url'] = target_lora['preview_url']
updated_lora['preview_url'] = config.get_preview_static_url(target_lora['preview_url'])
updated_lora['localPath'] = target_lora['file_path']
# Update in cache if it exists

438
py/routes/stats_routes.py Normal file
View File

@@ -0,0 +1,438 @@
import os
import json
import jinja2
from aiohttp import web
import logging
from datetime import datetime, timedelta
from collections import defaultdict, Counter
from typing import Dict, List, Any
from ..config import config
from ..services.settings_manager import settings
from ..services.service_registry import ServiceRegistry
from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
class StatsRoutes:
"""Route handlers for Statistics page and API endpoints"""
def __init__(self):
self.lora_scanner = None
self.checkpoint_scanner = None
self.usage_stats = None
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True
)
async def init_services(self):
"""Initialize services from ServiceRegistry"""
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
self.usage_stats = UsageStats()
async def handle_stats_page(self, request: web.Request) -> web.Response:
"""Handle GET /statistics request"""
try:
# Ensure services are initialized
await self.init_services()
# Check if scanners are initializing
lora_initializing = (
self.lora_scanner._cache is None or
(hasattr(self.lora_scanner, 'is_initializing') and self.lora_scanner.is_initializing())
)
checkpoint_initializing = (
self.checkpoint_scanner._cache is None or
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
)
is_initializing = lora_initializing or checkpoint_initializing
template = self.template_env.get_template('statistics.html')
rendered = template.render(
is_initializing=is_initializing,
settings=settings,
request=request
)
return web.Response(
text=rendered,
content_type='text/html'
)
except Exception as e:
logger.error(f"Error handling statistics request: {e}", exc_info=True)
return web.Response(
text="Error loading statistics page",
status=500
)
async def get_collection_overview(self, request: web.Request) -> web.Response:
"""Get collection overview statistics"""
try:
await self.init_services()
# Get LoRA statistics
lora_cache = await self.lora_scanner.get_cached_data()
lora_count = len(lora_cache.raw_data)
lora_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data)
# Get Checkpoint statistics
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
checkpoint_count = len(checkpoint_cache.raw_data)
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
return web.json_response({
'success': True,
'data': {
'total_models': lora_count + checkpoint_count,
'lora_count': lora_count,
'checkpoint_count': checkpoint_count,
'total_size': lora_size + checkpoint_size,
'lora_size': lora_size,
'checkpoint_size': checkpoint_size,
'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
}
})
except Exception as e:
logger.error(f"Error getting collection overview: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_usage_analytics(self, request: web.Request) -> web.Response:
"""Get usage analytics data"""
try:
await self.init_services()
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Get model data for enrichment
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Create hash to model mapping
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
# Prepare top used models
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
# Prepare usage timeline (last 30 days)
timeline = self._get_usage_timeline(usage_data, 30)
return web.json_response({
'success': True,
'data': {
'top_loras': top_loras,
'top_checkpoints': top_checkpoints,
'usage_timeline': timeline,
'total_executions': usage_data.get('total_executions', 0)
}
})
except Exception as e:
logger.error(f"Error getting usage analytics: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_base_model_distribution(self, request: web.Request) -> web.Response:
"""Get base model distribution statistics"""
try:
await self.init_services()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Count by base model
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
return web.json_response({
'success': True,
'data': {
'loras': dict(lora_base_models),
'checkpoints': dict(checkpoint_base_models)
}
})
except Exception as e:
logger.error(f"Error getting base model distribution: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_tag_analytics(self, request: web.Request) -> web.Response:
"""Get tag usage analytics"""
try:
await self.init_services()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Count tag frequencies
all_tags = []
for lora in lora_cache.raw_data:
all_tags.extend(lora.get('tags', []))
for cp in checkpoint_cache.raw_data:
all_tags.extend(cp.get('tags', []))
tag_counts = Counter(all_tags)
# Get top 50 tags
top_tags = [{'tag': tag, 'count': count} for tag, count in tag_counts.most_common(50)]
return web.json_response({
'success': True,
'data': {
'top_tags': top_tags,
'total_unique_tags': len(tag_counts)
}
})
except Exception as e:
logger.error(f"Error getting tag analytics: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_storage_analytics(self, request: web.Request) -> web.Response:
"""Get storage usage analytics"""
try:
await self.init_services()
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Create models with usage data
lora_storage = []
for lora in lora_cache.raw_data:
usage_count = 0
if lora['sha256'] in usage_data.get('loras', {}):
usage_count = usage_data['loras'][lora['sha256']].get('total', 0)
lora_storage.append({
'name': lora['model_name'],
'size': lora.get('size', 0),
'usage_count': usage_count,
'folder': lora.get('folder', ''),
'base_model': lora.get('base_model', 'Unknown')
})
checkpoint_storage = []
for cp in checkpoint_cache.raw_data:
usage_count = 0
if cp['sha256'] in usage_data.get('checkpoints', {}):
usage_count = usage_data['checkpoints'][cp['sha256']].get('total', 0)
checkpoint_storage.append({
'name': cp['model_name'],
'size': cp.get('size', 0),
'usage_count': usage_count,
'folder': cp.get('folder', ''),
'base_model': cp.get('base_model', 'Unknown')
})
# Sort by size
lora_storage.sort(key=lambda x: x['size'], reverse=True)
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
return web.json_response({
'success': True,
'data': {
'loras': lora_storage[:20], # Top 20 by size
'checkpoints': checkpoint_storage[:20]
}
})
except Exception as e:
logger.error(f"Error getting storage analytics: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_insights(self, request: web.Request) -> web.Response:
"""Get smart insights about the collection"""
try:
await self.init_services()
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
insights = []
# Calculate unused models
unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {}))
unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
total_loras = len(lora_cache.raw_data)
total_checkpoints = len(checkpoint_cache.raw_data)
if total_loras > 0:
unused_lora_percent = (unused_loras / total_loras) * 100
if unused_lora_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused LoRAs',
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
})
if total_checkpoints > 0:
unused_checkpoint_percent = (unused_checkpoints / total_checkpoints) * 100
if unused_checkpoint_percent > 30:
insights.append({
'type': 'warning',
'title': 'Unused Checkpoints Detected',
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
'suggestion': 'Review and consider removing checkpoints you no longer need.'
})
# Storage insights
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({
'type': 'info',
'title': 'Large Collection Detected',
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
})
# Recent activity insight
if usage_data.get('total_executions', 0) > 100:
insights.append({
'type': 'success',
'title': 'Active User',
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
'suggestion': 'Keep exploring and creating amazing content with your models.'
})
return web.json_response({
'success': True,
'data': {
'insights': insights
}
})
except Exception as e:
logger.error(f"Error getting insights: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
def _count_unused_models(self, models: List[Dict], usage_data: Dict) -> int:
"""Count models that have never been used"""
used_hashes = set(usage_data.keys())
unused_count = 0
for model in models:
if model.get('sha256') not in used_hashes:
unused_count += 1
return unused_count
def _get_top_used_models(self, usage_data: Dict, model_map: Dict, limit: int) -> List[Dict]:
"""Get top used models with their metadata"""
sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True)
top_models = []
for sha256, usage_info in sorted_usage[:limit]:
if sha256 in model_map:
model = model_map[sha256]
top_models.append({
'name': model['model_name'],
'usage_count': usage_info.get('total', 0),
'base_model': model.get('base_model', 'Unknown'),
'preview_url': config.get_preview_static_url(model.get('preview_url', '')),
'folder': model.get('folder', '')
})
return top_models
def _get_usage_timeline(self, usage_data: Dict, days: int) -> List[Dict]:
"""Get usage timeline for the past N days"""
timeline = []
today = datetime.now()
for i in range(days):
date = today - timedelta(days=i)
date_str = date.strftime('%Y-%m-%d')
lora_usage = 0
checkpoint_usage = 0
# Count usage for this date
for model_usage in usage_data.get('loras', {}).values():
if isinstance(model_usage, dict) and 'history' in model_usage:
lora_usage += model_usage['history'].get(date_str, 0)
for model_usage in usage_data.get('checkpoints', {}).values():
if isinstance(model_usage, dict) and 'history' in model_usage:
checkpoint_usage += model_usage['history'].get(date_str, 0)
timeline.append({
'date': date_str,
'lora_usage': lora_usage,
'checkpoint_usage': checkpoint_usage,
'total_usage': lora_usage + checkpoint_usage
})
return list(reversed(timeline)) # Oldest to newest
def _format_size(self, size_bytes: int) -> str:
"""Format file size in human readable format"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} PB"
def setup_routes(self, app: web.Application):
"""Register routes with the application"""
# Add an app startup handler to initialize services
app.on_startup.append(self._on_startup)
# Register page route
app.router.add_get('/statistics', self.handle_stats_page)
# Register API routes
app.router.add_get('/api/stats/collection-overview', self.get_collection_overview)
app.router.add_get('/api/stats/usage-analytics', self.get_usage_analytics)
app.router.add_get('/api/stats/base-model-distribution', self.get_base_model_distribution)
app.router.add_get('/api/stats/tag-analytics', self.get_tag_analytics)
app.router.add_get('/api/stats/storage-analytics', self.get_storage_analytics)
app.router.add_get('/api/stats/insights', self.get_insights)
async def _on_startup(self, app):
"""Initialize services when the app starts"""
await self.init_services()

View File

@@ -2,6 +2,8 @@ import os
import aiohttp
import logging
import toml
import subprocess
from datetime import datetime
from aiohttp import web
from typing import Dict, Any, List
@@ -13,7 +15,8 @@ class UpdateRoutes:
@staticmethod
def setup_routes(app):
"""Register update check routes"""
app.router.add_get('/loras/api/check-updates', UpdateRoutes.check_updates)
app.router.add_get('/api/check-updates', UpdateRoutes.check_updates)
app.router.add_get('/api/version-info', UpdateRoutes.get_version_info)
@staticmethod
async def check_updates(request):
@@ -24,6 +27,9 @@ class UpdateRoutes:
try:
# Read local version from pyproject.toml
local_version = UpdateRoutes._get_local_version()
# Get git info (commit hash, branch)
git_info = UpdateRoutes._get_git_info()
# Fetch remote version from GitHub
remote_version, changelog = await UpdateRoutes._get_remote_version()
@@ -39,7 +45,8 @@ class UpdateRoutes:
'current_version': local_version,
'latest_version': remote_version,
'update_available': update_available,
'changelog': changelog
'changelog': changelog,
'git_info': git_info
})
except Exception as e:
@@ -49,6 +56,34 @@ class UpdateRoutes:
'error': str(e)
})
@staticmethod
async def get_version_info(request):
"""
Returns the current version in the format 'version-short_hash'
"""
try:
# Read local version from pyproject.toml
local_version = UpdateRoutes._get_local_version().replace('v', '')
# Get git info (commit hash, branch)
git_info = UpdateRoutes._get_git_info()
short_hash = git_info['short_hash']
# Format: version-short_hash
version_string = f"{local_version}-{short_hash}"
return web.json_response({
'success': True,
'version': version_string
})
except Exception as e:
logger.error(f"Failed to get version info: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
})
@staticmethod
def _get_local_version() -> str:
"""Get local plugin version from pyproject.toml"""
@@ -72,6 +107,72 @@ class UpdateRoutes:
logger.error(f"Failed to get local version: {e}", exc_info=True)
return "v0.0.0"
@staticmethod
def _get_git_info() -> Dict[str, str]:
"""Get Git repository information"""
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
git_info = {
'commit_hash': 'unknown',
'short_hash': 'unknown',
'branch': 'unknown',
'commit_date': 'unknown'
}
try:
# Check if we're in a git repository
if not os.path.exists(os.path.join(plugin_root, '.git')):
return git_info
# Get current commit hash
result = subprocess.run(
['git', 'rev-parse', 'HEAD'],
cwd=plugin_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
git_info['commit_hash'] = result.stdout.strip()
git_info['short_hash'] = git_info['commit_hash'][:7]
# Get current branch name
result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=plugin_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
git_info['branch'] = result.stdout.strip()
# Get commit date
result = subprocess.run(
['git', 'show', '-s', '--format=%ci', 'HEAD'],
cwd=plugin_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
commit_date = result.stdout.strip()
# Format the date nicely if possible
try:
date_obj = datetime.strptime(commit_date, '%Y-%m-%d %H:%M:%S %z')
git_info['commit_date'] = date_obj.strftime('%Y-%m-%d')
except:
git_info['commit_date'] = commit_date
except Exception as e:
logger.warning(f"Error getting git info: {e}")
return git_info
@staticmethod
async def _get_remote_version() -> tuple[str, List[str]]:
"""

View File

@@ -224,6 +224,69 @@ class CivitaiClient:
except Exception as e:
logger.error(f"Error fetching model versions: {e}")
return None
async def get_model_version(self, model_id: str, version_id: str = "") -> Optional[Dict]:
"""Get specific model version with additional metadata
Args:
model_id: The Civitai model ID
version_id: Optional specific version ID to retrieve
Returns:
Optional[Dict]: The model version data with additional fields or None if not found
"""
try:
session = await self._ensure_fresh_session()
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Find matching version
matched_version = None
if version_id:
# If version_id provided, find exact match
for version in model_versions:
if str(version.get('id')) == str(version_id):
matched_version = version
break
else:
# If no version_id then use the first version
matched_version = model_versions[0] if model_versions else None
# If no match found, return None
if not matched_version:
return None
# Build result with modified fields
result = matched_version.copy() # Copy to avoid modifying original
# Replace index with modelId
if 'index' in result:
del result['index']
result['modelId'] = model_id
# Add model field with metadata from top level
result['model'] = {
"name": data.get("name"),
"type": data.get("type"),
"nsfw": data.get("nsfw", False),
"poi": data.get("poi", False),
"description": data.get("description"),
"tags": data.get("tags", [])
}
# Add creator field from top level
result['creator'] = data.get("creator")
return result
except Exception as e:
logger.error(f"Error fetching model version: {e}")
return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Fetch model version metadata from Civitai

View File

@@ -6,6 +6,7 @@ from typing import Dict
from ..utils.models import LoraMetadata, CheckpointMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry
# Download to temporary file first
@@ -38,14 +39,6 @@ class DownloadManager:
if self._civitai_client is None:
self._civitai_client = await ServiceRegistry.get_civitai_client()
return self._civitai_client
async def _get_lora_monitor(self):
"""Get the lora file monitor from registry"""
return await ServiceRegistry.get_lora_monitor()
async def _get_checkpoint_monitor(self):
"""Get the checkpoint file monitor from registry"""
return await ServiceRegistry.get_checkpoint_monitor()
async def _get_lora_scanner(self):
"""Get the lora scanner from registry"""
@@ -136,9 +129,6 @@ class DownloadManager:
file_name = file_info['name']
save_path = os.path.join(save_dir, file_name)
# 4. Notify file monitor - use normalized path and file size
# file monitor is despreted, so we don't need to use it
# 5. Prepare metadata based on model type
if model_type == "checkpoint":
metadata = CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
@@ -209,8 +199,6 @@ class DownloadManager:
if await civitai_client.download_preview_image(images[0]['url'], preview_path):
metadata.preview_url = preview_path.replace(os.sep, '/')
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False)
else:
# For images, use WebP format for better performance
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
@@ -237,8 +225,6 @@ class DownloadManager:
# Update metadata
metadata.preview_url = preview_path.replace(os.sep, '/')
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False)
# Remove temporary file
try:
@@ -269,8 +255,7 @@ class DownloadManager:
metadata.update_file_info(save_path)
# 5. Final metadata update
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(save_path, metadata, True)
# 6. Update cache based on model type
if model_type == "checkpoint":

View File

@@ -1,542 +0,0 @@
import os
import logging
import asyncio
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from typing import List, Dict, Set, Optional
from threading import Lock
from ..config import config
from .service_registry import ServiceRegistry
logger = logging.getLogger(__name__)
# Configuration constant to control file monitoring functionality
ENABLE_FILE_MONITORING = False
class BaseFileHandler(FileSystemEventHandler):
"""Base handler for file system events"""
def __init__(self, loop: asyncio.AbstractEventLoop):
self.loop = loop # Store event loop reference
self.pending_changes = set() # Pending changes
self.lock = Lock() # Thread-safe lock
self.update_task = None # Async update task
self._ignore_paths = set() # Paths to ignore
self._min_ignore_timeout = 5 # Minimum timeout in seconds
self._download_speed = 1024 * 1024 # Assume 1MB/s as base speed
# Track modified files with timestamps for debouncing
self.modified_files: Dict[str, float] = {}
self.debounce_timer = None
self.debounce_delay = 3.0 # Seconds to wait after last modification
# Track files already scheduled for processing
self.scheduled_files: Set[str] = set()
# File extensions to monitor - should be overridden by subclasses
self.file_extensions = set()
def _should_ignore(self, path: str) -> bool:
"""Check if path should be ignored"""
real_path = os.path.realpath(path) # Resolve any symbolic links
return real_path.replace(os.sep, '/') in self._ignore_paths
def add_ignore_path(self, path: str, file_size: int = 0):
"""Add path to ignore list with dynamic timeout based on file size"""
real_path = os.path.realpath(path) # Resolve any symbolic links
self._ignore_paths.add(real_path.replace(os.sep, '/'))
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
timeout = 5
self.loop.call_later(
timeout,
self._ignore_paths.discard,
real_path.replace(os.sep, '/')
)
def on_created(self, event):
if event.is_directory:
return
# Handle appropriate files based on extensions
file_ext = os.path.splitext(event.src_path)[1].lower()
if file_ext in self.file_extensions:
if self._should_ignore(event.src_path):
return
# Process this file directly and ignore subsequent modifications
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
if normalized_path not in self.scheduled_files:
logger.info(f"File created: {event.src_path}")
self.scheduled_files.add(normalized_path)
self._schedule_update('add', event.src_path)
# Ignore modifications for a short period after creation
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
normalized_path
)
def on_modified(self, event):
if event.is_directory:
return
# Only process files with supported extensions
file_ext = os.path.splitext(event.src_path)[1].lower()
if file_ext in self.file_extensions:
if self._should_ignore(event.src_path):
return
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
# Skip if this file is already scheduled for processing
if normalized_path in self.scheduled_files:
return
# Update the timestamp for this file
self.modified_files[normalized_path] = time.time()
# Cancel any existing timer
if self.debounce_timer:
self.debounce_timer.cancel()
# Set a new timer to process modified files after debounce period
self.debounce_timer = self.loop.call_later(
self.debounce_delay,
self.loop.call_soon_threadsafe,
self._process_modified_files
)
def _process_modified_files(self):
"""Process files that have been modified after debounce period"""
current_time = time.time()
files_to_process = []
# Find files that haven't been modified for debounce_delay seconds
for file_path, last_modified in list(self.modified_files.items()):
if current_time - last_modified >= self.debounce_delay:
# Only process if not already scheduled
if file_path not in self.scheduled_files:
files_to_process.append(file_path)
self.scheduled_files.add(file_path)
# Auto-remove from scheduled list after reasonable time
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
file_path
)
del self.modified_files[file_path]
# Process stable files
for file_path in files_to_process:
logger.info(f"Processing modified file: {file_path}")
self._schedule_update('add', file_path)
def on_deleted(self, event):
if event.is_directory:
return
file_ext = os.path.splitext(event.src_path)[1].lower()
if file_ext not in self.file_extensions:
return
if self._should_ignore(event.src_path):
return
# Remove from scheduled files if present
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
self.scheduled_files.discard(normalized_path)
logger.info(f"File deleted: {event.src_path}")
self._schedule_update('remove', event.src_path)
def on_moved(self, event):
"""Handle file move/rename events"""
src_ext = os.path.splitext(event.src_path)[1].lower()
dest_ext = os.path.splitext(event.dest_path)[1].lower()
# If destination has supported extension, treat as new file
if dest_ext in self.file_extensions:
if self._should_ignore(event.dest_path):
return
normalized_path = os.path.realpath(event.dest_path).replace(os.sep, '/')
# Only process if not already scheduled
if normalized_path not in self.scheduled_files:
logger.info(f"File renamed/moved to: {event.dest_path}")
self.scheduled_files.add(normalized_path)
self._schedule_update('add', event.dest_path)
# Auto-remove from scheduled list after reasonable time
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
normalized_path
)
# If source was a supported file, treat it as deleted
if src_ext in self.file_extensions:
if self._should_ignore(event.src_path):
return
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
self.scheduled_files.discard(normalized_path)
logger.info(f"File moved/renamed from: {event.src_path}")
self._schedule_update('remove', event.src_path)
def _schedule_update(self, action: str, file_path: str):
"""Schedule a cache update"""
with self.lock:
# Use config method to map path
mapped_path = config.map_path_to_link(file_path)
normalized_path = mapped_path.replace(os.sep, '/')
self.pending_changes.add((action, normalized_path))
self.loop.call_soon_threadsafe(self._create_update_task)
def _create_update_task(self):
"""Create update task in the event loop"""
if self.update_task is None or self.update_task.done():
self.update_task = asyncio.create_task(self._process_changes())
async def _process_changes(self, delay: float = 2.0):
"""Process pending changes with debouncing - should be implemented by subclasses"""
raise NotImplementedError("Subclasses must implement _process_changes")
class LoraFileHandler(BaseFileHandler):
"""Handler for LoRA file system events"""
def __init__(self, loop: asyncio.AbstractEventLoop):
super().__init__(loop)
# Set supported file extensions for LoRAs
self.file_extensions = {'.safetensors'}
async def _process_changes(self, delay: float = 2.0):
"""Process pending changes with debouncing"""
await asyncio.sleep(delay)
try:
with self.lock:
changes = self.pending_changes.copy()
self.pending_changes.clear()
if not changes:
return
logger.info(f"Processing {len(changes)} LoRA file changes")
# Get scanner through ServiceRegistry
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
needs_resort = False
new_folders = set()
for action, file_path in changes:
try:
if action == 'add':
# Check if file already exists in cache
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if existing:
logger.info(f"File {file_path} already in cache, skipping")
continue
# Scan new file
model_data = await scanner.scan_single_model(file_path)
if model_data:
# Update tags count
for tag in model_data.get('tags', []):
scanner._tags_count[tag] = scanner._tags_count.get(tag, 0) + 1
cache.raw_data.append(model_data)
new_folders.add(model_data['folder'])
# Update hash index
if 'sha256' in model_data:
scanner._hash_index.add_entry(
model_data['sha256'],
model_data['file_path']
)
needs_resort = True
elif action == 'remove':
# Find the model to remove so we can update tags count
model_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if model_to_remove:
# Update tags count by reducing counts
for tag in model_to_remove.get('tags', []):
if tag in scanner._tags_count:
scanner._tags_count[tag] = max(0, scanner._tags_count[tag] - 1)
if scanner._tags_count[tag] == 0:
del scanner._tags_count[tag]
# Remove from cache and hash index
logger.info(f"Removing {file_path} from cache")
scanner._hash_index.remove_by_path(file_path)
cache.raw_data = [
item for item in cache.raw_data
if item['file_path'] != file_path
]
needs_resort = True
except Exception as e:
logger.error(f"Error processing {action} for {file_path}: {e}")
if needs_resort:
await cache.resort()
# Update folder list
all_folders = set(cache.folders) | new_folders
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
except Exception as e:
logger.error(f"Error in process_changes for LoRA: {e}")
class CheckpointFileHandler(BaseFileHandler):
"""Handler for checkpoint file system events"""
def __init__(self, loop: asyncio.AbstractEventLoop):
super().__init__(loop)
# Set supported file extensions for checkpoints
self.file_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.sft', '.gguf'}
async def _process_changes(self, delay: float = 2.0):
"""Process pending changes with debouncing for checkpoint files"""
await asyncio.sleep(delay)
try:
with self.lock:
changes = self.pending_changes.copy()
self.pending_changes.clear()
if not changes:
return
logger.info(f"Processing {len(changes)} checkpoint file changes")
# Get scanner through ServiceRegistry
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
needs_resort = False
new_folders = set()
for action, file_path in changes:
try:
if action == 'add':
# Check if file already exists in cache
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if existing:
logger.info(f"File {file_path} already in cache, skipping")
continue
# Scan new file
model_data = await scanner.scan_single_model(file_path)
if model_data:
# Update tags count if applicable
for tag in model_data.get('tags', []):
scanner._tags_count[tag] = scanner._tags_count.get(tag, 0) + 1
cache.raw_data.append(model_data)
new_folders.add(model_data['folder'])
# Update hash index
if 'sha256' in model_data:
scanner._hash_index.add_entry(
model_data['sha256'],
model_data['file_path']
)
needs_resort = True
elif action == 'remove':
# Find the model to remove so we can update tags count
model_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if model_to_remove:
# Update tags count by reducing counts
for tag in model_to_remove.get('tags', []):
if tag in scanner._tags_count:
scanner._tags_count[tag] = max(0, scanner._tags_count[tag] - 1)
if scanner._tags_count[tag] == 0:
del scanner._tags_count[tag]
# Remove from cache and hash index
logger.info(f"Removing {file_path} from checkpoint cache")
scanner._hash_index.remove_by_path(file_path)
cache.raw_data = [
item for item in cache.raw_data
if item['file_path'] != file_path
]
needs_resort = True
except Exception as e:
logger.error(f"Error processing checkpoint {action} for {file_path}: {e}")
if needs_resort:
await cache.resort()
# Update folder list
all_folders = set(cache.folders) | new_folders
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
except Exception as e:
logger.error(f"Error in process_changes for checkpoint: {e}")
class BaseFileMonitor:
"""Base class for file monitoring"""
def __init__(self, monitor_paths: List[str]):
self.observer = Observer()
self.loop = asyncio.get_event_loop()
self.monitor_paths = set()
# Process monitor paths
for path in monitor_paths:
self.monitor_paths.add(os.path.realpath(path).replace(os.sep, '/'))
# Add mapped paths from config
for target_path in config._path_mappings.keys():
self.monitor_paths.add(target_path)
def start(self):
"""Start file monitoring"""
if not ENABLE_FILE_MONITORING:
logger.debug("File monitoring is disabled via ENABLE_FILE_MONITORING setting")
return
for path in self.monitor_paths:
try:
self.observer.schedule(self.handler, path, recursive=True)
logger.info(f"Started monitoring: {path}")
except Exception as e:
logger.error(f"Error monitoring {path}: {e}")
self.observer.start()
def stop(self):
"""Stop file monitoring"""
if not ENABLE_FILE_MONITORING:
return
self.observer.stop()
self.observer.join()
def rescan_links(self):
"""Rescan links when new ones are added"""
if not ENABLE_FILE_MONITORING:
return
# Find new paths not yet being monitored
new_paths = set()
for path in config._path_mappings.keys():
real_path = os.path.realpath(path).replace(os.sep, '/')
if real_path not in self.monitor_paths:
new_paths.add(real_path)
self.monitor_paths.add(real_path)
# Add new paths to monitoring
for path in new_paths:
try:
self.observer.schedule(self.handler, path, recursive=True)
logger.info(f"Added new monitoring path: {path}")
except Exception as e:
logger.error(f"Error adding new monitor for {path}: {e}")
class LoraFileMonitor(BaseFileMonitor):
"""Monitor for LoRA file changes"""
_instance = None
_lock = asyncio.Lock()
def __new__(cls, monitor_paths=None):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, monitor_paths=None):
if not hasattr(self, '_initialized'):
if monitor_paths is None:
from ..config import config
monitor_paths = config.loras_roots
super().__init__(monitor_paths)
self.handler = LoraFileHandler(self.loop)
self._initialized = True
@classmethod
async def get_instance(cls):
"""Get singleton instance with async support"""
async with cls._lock:
if cls._instance is None:
from ..config import config
cls._instance = cls(config.loras_roots)
return cls._instance
class CheckpointFileMonitor(BaseFileMonitor):
"""Monitor for checkpoint file changes"""
_instance = None
_lock = asyncio.Lock()
def __new__(cls, monitor_paths=None):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, monitor_paths=None):
if not hasattr(self, '_initialized'):
if monitor_paths is None:
# Get checkpoint roots from scanner
monitor_paths = []
# We'll initialize monitor paths later when scanner is available
super().__init__(monitor_paths or [])
self.handler = CheckpointFileHandler(self.loop)
self._initialized = True
@classmethod
async def get_instance(cls):
"""Get singleton instance with async support"""
async with cls._lock:
if cls._instance is None:
cls._instance = cls([])
# Now get checkpoint roots from scanner
from .checkpoint_scanner import CheckpointScanner
scanner = await CheckpointScanner.get_instance()
monitor_paths = scanner.get_model_roots()
# Update monitor paths - but don't actually monitor them
for path in monitor_paths:
real_path = os.path.realpath(path).replace(os.sep, '/')
cls._instance.monitor_paths.add(real_path)
return cls._instance
def start(self):
"""Override start to check global enable flag"""
if not ENABLE_FILE_MONITORING:
logger.debug("Checkpoint file monitoring is disabled via ENABLE_FILE_MONITORING setting")
return
logger.debug("Checkpoint file monitoring is temporarily disabled")
# Skip the actual monitoring setup
pass
async def initialize_paths(self):
"""Initialize monitor paths from scanner - currently disabled"""
if not ENABLE_FILE_MONITORING:
logger.debug("Checkpoint path initialization skipped (monitoring disabled)")
return
logger.debug("Checkpoint file path initialization skipped (monitoring disabled)")
pass

View File

@@ -374,32 +374,6 @@ class LoraScanner(ModelScanner):
return letters
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
"""Update file paths in metadata file"""
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
# Update file_path
metadata['file_path'] = lora_path.replace(os.sep, '/')
# Update preview_url if exists
if 'preview_url' in metadata:
preview_dir = os.path.dirname(lora_path)
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
preview_ext = os.path.splitext(metadata['preview_url'])[1]
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
# Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
return metadata
except Exception as e:
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
# Lora-specific hash index functionality
def has_lora_hash(self, sha256: str) -> bool:
"""Check if a LoRA with given hash exists"""

View File

@@ -32,12 +32,13 @@ class ModelCache:
all_folders = set(l['folder'] for l in self.raw_data)
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
async def update_preview_url(self, file_path: str, preview_url: str) -> bool:
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
"""Update preview_url for a specific model in all cached data
Args:
file_path: The file path of the model to update
preview_url: The new preview URL
preview_nsfw_level: The NSFW level of the preview
Returns:
bool: True if the update was successful, False if the model wasn't found
@@ -47,19 +48,9 @@ class ModelCache:
for item in self.raw_data:
if item['file_path'] == file_path:
item['preview_url'] = preview_url
item['preview_nsfw_level'] = preview_nsfw_level
break
else:
return False # Model not found
# Update in sorted lists (references to the same dict objects)
for item in self.sorted_by_name:
if item['file_path'] == file_path:
item['preview_url'] = preview_url
break
for item in self.sorted_by_date:
if item['file_path'] == file_path:
item['preview_url'] = preview_url
break
return True

View File

@@ -63,16 +63,16 @@ class ModelHashIndex:
"""Extract filename without extension from path"""
return os.path.splitext(os.path.basename(file_path))[0]
def remove_by_path(self, file_path: str) -> None:
def remove_by_path(self, file_path: str, hash_val: str = None) -> None:
"""Remove entry by file path"""
filename = self._get_filename_from_path(file_path)
hash_val = None
# Find the hash for this file path
for h, p in self._hash_to_path.items():
if p == file_path:
hash_val = h
break
if hash_val is None:
for h, p in self._hash_to_path.items():
if p == file_path:
hash_val = h
break
# If we didn't find a hash, nothing to do
if not hash_val:
@@ -219,7 +219,7 @@ class ModelHashIndex:
return set(self._filename_to_hash.keys())
def get_duplicate_hashes(self) -> Dict[str, List[str]]:
"""Get dictionary of duplicate hashes and their paths"""
"""Get dictionary of duplicate hashes and their paths"""
return self._duplicate_hashes
def get_duplicate_filenames(self) -> Dict[str, List[str]]:

View File

@@ -9,7 +9,8 @@ import msgpack # Add MessagePack import for efficient serialization
from ..utils.models import BaseModelMetadata
from ..config import config
from ..utils.file_utils import load_metadata, get_file_info, find_preview_file, save_metadata
from ..utils.file_utils import find_preview_file
from ..utils.metadata_manager import MetadataManager
from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex
from ..utils.constants import PREVIEW_EXTENSIONS
@@ -48,10 +49,25 @@ class ModelScanner:
self._is_initializing = False # Flag to track initialization state
self._excluded_models = [] # List to track excluded models
self._dirs_last_modified = {} # Track directory modification times
self._use_cache_files = False # Flag to control cache file usage, default to disabled
# Clear cache files if disabled
if not self._use_cache_files:
self._clear_cache_files()
# Register this service
asyncio.create_task(self._register_service())
def _clear_cache_files(self):
"""Clear existing cache files if they exist"""
try:
cache_path = self._get_cache_file_path()
if cache_path and os.path.exists(cache_path):
os.remove(cache_path)
logger.info(f"Cleared {self.model_type} cache file: {cache_path}")
except Exception as e:
logger.error(f"Error clearing {self.model_type} cache file: {e}")
async def _register_service(self):
"""Register this instance with the ServiceRegistry"""
service_name = f"{self.model_type}_scanner"
@@ -93,6 +109,10 @@ class ModelScanner:
async def _save_cache_to_disk(self) -> bool:
"""Save cache data to disk using MessagePack"""
if not self._use_cache_files:
logger.debug(f"Cache files disabled for {self.model_type}, skipping save")
return False
if self._cache is None or not self._cache.raw_data:
logger.debug(f"No {self.model_type} cache data to save")
return False
@@ -172,24 +192,15 @@ class ModelScanner:
if cache_data.get("model_type") != self.model_type:
logger.info(f"Cache invalid - model type mismatch. Got: {cache_data.get('model_type')}, Expected: {self.model_type}")
return False
# Check if directories have changed
# stored_dirs = cache_data.get("dirs_last_modified", {})
# current_dirs = self._get_dirs_last_modified()
# If directory structure has changed, cache is invalid
# if set(stored_dirs.keys()) != set(current_dirs.keys()):
# logger.info(f"Cache invalid - directory structure changed. Stored: {set(stored_dirs.keys())}, Current: {set(current_dirs.keys())}")
# return False
# Remove the modification time check to make cache validation less strict
# This allows the cache to be valid even when files have changed
# Users can explicitly refresh the cache when needed
return True
async def _load_cache_from_disk(self) -> bool:
"""Load cache data from disk using MessagePack"""
if not self._use_cache_files:
logger.info(f"Cache files disabled for {self.model_type}, skipping load")
return False
start_time = time.time()
cache_path = self._get_cache_file_path()
if not cache_path or not os.path.exists(cache_path):
@@ -659,26 +670,33 @@ class ModelScanner:
batch = new_files[i:i+batch_size]
for path in batch:
try:
model_data = await self.scan_single_model(path)
if model_data:
# Add to cache
self._cache.raw_data.append(model_data)
# Update hash index if available
if 'sha256' in model_data and 'file_path' in model_data:
self._hash_index.add_entry(model_data['sha256'].lower(), model_data['file_path'])
# Update tags count
if 'tags' in model_data and model_data['tags']:
for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
total_added += 1
# Find the appropriate root path for this file
root_path = None
for potential_root in self.get_model_roots():
if path.startswith(potential_root):
root_path = potential_root
break
if root_path:
model_data = await self._process_model_file(path, root_path)
if model_data:
# Add to cache
self._cache.raw_data.append(model_data)
# Update hash index if available
if 'sha256' in model_data and 'file_path' in model_data:
self._hash_index.add_entry(model_data['sha256'].lower(), model_data['file_path'])
# Update tags count
if 'tags' in model_data and model_data['tags']:
for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
total_added += 1
else:
logger.error(f"Could not determine root path for {path}")
except Exception as e:
logger.error(f"Error adding {path} to cache: {e}")
# Yield control after each batch
await asyncio.sleep(0)
# Find missing files (in cache but not in filesystem)
missing_files = cached_paths - found_paths
@@ -731,36 +749,17 @@ class ModelScanner:
"""Scan all model directories and return metadata"""
raise NotImplementedError("Subclasses must implement scan_all_models")
def is_initializing(self) -> bool:
"""Check if the scanner is currently initializing"""
return self._is_initializing
def get_model_roots(self) -> List[str]:
"""Get model root directories"""
raise NotImplementedError("Subclasses must implement get_model_roots")
async def scan_single_model(self, file_path: str) -> Optional[Dict]:
"""Scan a single model file and return its metadata"""
try:
if not os.path.exists(os.path.realpath(file_path)):
return None
# Get basic file info
metadata = await self._get_file_info(file_path)
if not metadata:
return None
folder = self._calculate_folder(file_path)
# Ensure folder field exists
metadata_dict = metadata.to_dict()
metadata_dict['folder'] = folder or ''
return metadata_dict
except Exception as e:
logger.error(f"Error scanning {file_path}: {e}")
return None
async def _get_file_info(self, file_path: str) -> Optional[BaseModelMetadata]:
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
"""Get model file info and metadata (extensible for different model types)"""
return await get_file_info(file_path, self.model_class)
return await MetadataManager.create_default_metadata(file_path, self.model_class)
def _calculate_folder(self, file_path: str) -> str:
"""Calculate the folder path for a model file"""
@@ -773,7 +772,7 @@ class ModelScanner:
# Common methods shared between scanners
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
"""Process a single model file and return its metadata"""
metadata = await load_metadata(file_path, self.model_class)
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
if metadata is None:
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
@@ -789,7 +788,7 @@ class ModelScanner:
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
await save_metadata(file_path, metadata)
await MetadataManager.save_metadata(file_path, metadata, True)
logger.debug(f"Created metadata from .civitai.info for {file_path}")
except Exception as e:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
@@ -816,13 +815,13 @@ class ModelScanner:
metadata.modelDescription = version_info['model']['description']
# Save the updated metadata
await save_metadata(file_path, metadata)
await MetadataManager.save_metadata(file_path, metadata, True)
logger.debug(f"Updated metadata with civitai info for {file_path}")
except Exception as e:
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
if metadata is None:
metadata = await self._get_file_info(file_path)
metadata = await self._create_default_metadata(file_path)
model_data = metadata.to_dict()
@@ -872,9 +871,7 @@ class ModelScanner:
logger.warning(f"Model {model_id} appears to be deleted from Civitai (404 response)")
model_data['civitai_deleted'] = True
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(model_data, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(file_path, model_data)
elif model_metadata:
logger.debug(f"Updating metadata for {file_path} with model ID {model_id}")
@@ -887,9 +884,7 @@ class ModelScanner:
model_data['civitai']['creator'] = model_metadata['creator']
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(model_data, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(file_path, model_data, True)
except Exception as e:
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
@@ -995,26 +990,6 @@ class ModelScanner:
real_source = os.path.realpath(source_path)
real_target = os.path.realpath(target_file)
file_size = os.path.getsize(real_source)
# Get the appropriate file monitor through ServiceRegistry
if self.model_type == "lora":
monitor = await ServiceRegistry.get_lora_monitor()
elif self.model_type == "checkpoint":
monitor = await ServiceRegistry.get_checkpoint_monitor()
else:
monitor = None
if monitor:
monitor.handler.add_ignore_path(
real_source,
file_size
)
monitor.handler.add_ignore_path(
real_target,
file_size
)
shutil.move(real_source, real_target)
# Move all associated files with the same base name
@@ -1068,15 +1043,14 @@ class ModelScanner:
metadata['file_path'] = model_path.replace(os.sep, '/')
if 'preview_url' in metadata:
if 'preview_url' in metadata and metadata['preview_url']:
preview_dir = os.path.dirname(model_path)
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
preview_ext = os.path.splitext(metadata['preview_url'])[1]
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(metadata_path, metadata)
return metadata
@@ -1210,12 +1184,13 @@ class ModelScanner:
"""Get list of excluded model file paths"""
return self._excluded_models.copy()
async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
async def update_preview_in_cache(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
"""Update preview URL in cache for a specific lora
Args:
file_path: The file path of the lora to update
preview_url: The new preview URL
preview_nsfw_level: The NSFW level of the preview
Returns:
bool: True if the update was successful, False if cache doesn't exist or lora wasn't found
@@ -1223,7 +1198,7 @@ class ModelScanner:
if self._cache is None:
return False
updated = await self._cache.update_preview_url(file_path, preview_url)
updated = await self._cache.update_preview_url(file_path, preview_url, preview_nsfw_level)
if updated:
# Save updated cache to disk
await self._save_cache_to_disk()
@@ -1246,9 +1221,6 @@ class ModelScanner:
'results': []
}
# Get the file monitor
file_monitor = getattr(self, 'file_monitor', None)
# Keep track of success and failures
results = []
total_deleted = 0
@@ -1269,8 +1241,7 @@ class ModelScanner:
from ..utils.routes_common import ModelRouteUtils
deleted_files = await ModelRouteUtils.delete_model_files(
target_dir,
file_name,
file_monitor
file_name
)
if deleted_files:
@@ -1352,7 +1323,7 @@ class ModelScanner:
hash_val = model.get('sha256', '').lower()
# Remove from hash index
self._hash_index.remove_by_path(file_path)
self._hash_index.remove_by_path(file_path, hash_val)
# Check and clean up duplicates
self._cleanup_duplicates_after_removal(hash_val, file_name)

View File

@@ -58,26 +58,6 @@ class ServiceRegistry:
scanner = await CheckpointScanner.get_instance()
await cls.register_service("checkpoint_scanner", scanner)
return scanner
@classmethod
async def get_lora_monitor(cls):
"""Get the LoraFileMonitor instance"""
from .file_monitor import LoraFileMonitor
monitor = await cls.get_service("lora_monitor")
if monitor is None:
monitor = await LoraFileMonitor.get_instance()
await cls.register_service("lora_monitor", monitor)
return monitor
@classmethod
async def get_checkpoint_monitor(cls):
"""Get the CheckpointFileMonitor instance"""
from .file_monitor import CheckpointFileMonitor
monitor = await cls.get_service("checkpoint_monitor")
if monitor is None:
monitor = await CheckpointFileMonitor.get_instance()
await cls.register_service("checkpoint_monitor", monitor)
return monitor
@classmethod
async def get_civitai_client(cls):
@@ -95,7 +75,6 @@ class ServiceRegistry:
from .download_manager import DownloadManager
manager = await cls.get_service("download_manager")
if manager is None:
# We'll let DownloadManager.get_instance handle file_monitor parameter
manager = await DownloadManager.get_instance()
await cls.register_service("download_manager", manager)
return manager

View File

@@ -7,6 +7,15 @@ NSFW_LEVELS = {
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
}
# Node type constants
NODE_TYPES = {
"Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2
}
# Default ComfyUI node color when bgcolor is null
DEFAULT_NODE_COLOR = "#353535"
# preview extensions
PREVIEW_EXTENSIONS = [
'.webp',
@@ -18,7 +27,9 @@ PREVIEW_EXTENSIONS = [
'.png',
'.jpeg',
'.jpg',
'.mp4'
'.mp4',
'.gif',
'.webm'
]
# Card preview image width
@@ -31,4 +42,7 @@ EXAMPLE_IMAGE_WIDTH = 832
SUPPORTED_MEDIA_EXTENSIONS = {
'images': ['.jpg', '.jpeg', '.png', '.webp', '.gif'],
'videos': ['.mp4', '.webm']
}
}
# Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon', 'dora']

View File

@@ -0,0 +1,404 @@
import logging
import os
import asyncio
import json
import time
import aiohttp
from aiohttp import web
from ..services.service_registry import ServiceRegistry
from .example_images_processor import ExampleImagesProcessor
from .example_images_metadata import MetadataUpdater
logger = logging.getLogger(__name__)
# Download status tracking
download_task = None
is_downloading = False
download_progress = {
'total': 0,
'completed': 0,
'current_model': '',
'status': 'idle', # idle, running, paused, completed, error
'errors': [],
'last_error': None,
'start_time': None,
'end_time': None,
'processed_models': set(), # Track models that have been processed
'refreshed_models': set() # Track models that had metadata refreshed
}
class DownloadManager:
"""Manages downloading example images for models"""
@staticmethod
async def start_download(request):
"""
Start downloading example images for models
Expects a JSON body with:
{
"output_dir": "path/to/output", # Base directory to save example images
"optimize": true, # Whether to optimize images (default: true)
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
"delay": 1.0 # Delay between downloads to avoid rate limiting (default: 1.0)
}
"""
global download_task, is_downloading, download_progress
if is_downloading:
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': False,
'error': 'Download already in progress',
'status': response_progress
}, status=400)
try:
# Parse the request body
data = await request.json()
output_dir = data.get('output_dir')
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
if not output_dir:
return web.json_response({
'success': False,
'error': 'Missing output_dir parameter'
}, status=400)
# Create the output directory
os.makedirs(output_dir, exist_ok=True)
# Initialize progress tracking
download_progress['total'] = 0
download_progress['completed'] = 0
download_progress['current_model'] = ''
download_progress['status'] = 'running'
download_progress['errors'] = []
download_progress['last_error'] = None
download_progress['start_time'] = time.time()
download_progress['end_time'] = None
# Get the processed models list from a file if it exists
progress_file = os.path.join(output_dir, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
saved_progress = json.load(f)
download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
logger.info(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed")
except Exception as e:
logger.error(f"Failed to load progress file: {e}")
download_progress['processed_models'] = set()
else:
download_progress['processed_models'] = set()
# Start the download task
is_downloading = True
download_task = asyncio.create_task(
DownloadManager._download_all_example_images(
output_dir,
optimize,
model_types,
delay
)
)
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': True,
'message': 'Download started',
'status': response_progress
})
except Exception as e:
logger.error(f"Failed to start example images download: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_status(request):
"""Get the current status of example images download"""
global download_progress
# Create a copy of the progress dict with the set converted to a list for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': True,
'is_downloading': is_downloading,
'status': response_progress
})
@staticmethod
async def pause_download(request):
"""Pause the example images download"""
global download_progress
if not is_downloading:
return web.json_response({
'success': False,
'error': 'No download in progress'
}, status=400)
download_progress['status'] = 'paused'
return web.json_response({
'success': True,
'message': 'Download paused'
})
@staticmethod
async def resume_download(request):
"""Resume the example images download"""
global download_progress
if not is_downloading:
return web.json_response({
'success': False,
'error': 'No download in progress'
}, status=400)
if download_progress['status'] == 'paused':
download_progress['status'] = 'running'
return web.json_response({
'success': True,
'message': 'Download resumed'
})
else:
return web.json_response({
'success': False,
'error': f"Download is in '{download_progress['status']}' state, cannot resume"
}, status=400)
@staticmethod
async def _download_all_example_images(output_dir, optimize, model_types, delay):
"""Download example images for all models"""
global is_downloading, download_progress
# Create independent download session
connector = aiohttp.TCPConnector(
ssl=True,
limit=3,
force_close=False,
enable_cleanup_closed=True
)
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=60)
independent_session = aiohttp.ClientSession(
connector=connector,
trust_env=True,
timeout=timeout
)
try:
# Get scanners
scanners = []
if 'lora' in model_types:
lora_scanner = await ServiceRegistry.get_lora_scanner()
scanners.append(('lora', lora_scanner))
if 'checkpoint' in model_types:
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
scanners.append(('checkpoint', checkpoint_scanner))
# Get all models
all_models = []
for scanner_type, scanner in scanners:
cache = await scanner.get_cached_data()
if cache and cache.raw_data:
for model in cache.raw_data:
if model.get('sha256'):
all_models.append((scanner_type, model, scanner))
# Update total count
download_progress['total'] = len(all_models)
logger.info(f"Found {download_progress['total']} models to process")
# Process each model
for i, (scanner_type, model, scanner) in enumerate(all_models):
# Main logic for processing model is here, but actual operations are delegated to other classes
was_remote_download = await DownloadManager._process_model(
scanner_type, model, scanner,
output_dir, optimize, independent_session
)
# Update progress
download_progress['completed'] += 1
# Only add delay after remote download of models, and not after processing the last model
if was_remote_download and i < len(all_models) - 1 and download_progress['status'] == 'running':
await asyncio.sleep(delay)
# Mark as completed
download_progress['status'] = 'completed'
download_progress['end_time'] = time.time()
logger.info(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
except Exception as e:
error_msg = f"Error during example images download: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
download_progress['status'] = 'error'
download_progress['end_time'] = time.time()
finally:
# Close the independent session
try:
await independent_session.close()
except Exception as e:
logger.error(f"Error closing download session: {e}")
# Save final progress to file
try:
DownloadManager._save_progress(output_dir)
except Exception as e:
logger.error(f"Failed to save progress file: {e}")
# Set download status to not downloading
is_downloading = False
@staticmethod
async def _process_model(scanner_type, model, scanner, output_dir, optimize, independent_session):
"""Process a single model download"""
global download_progress
# Check if download is paused
while download_progress['status'] == 'paused':
await asyncio.sleep(1)
# Check if download should continue
if download_progress['status'] != 'running':
logger.info(f"Download stopped: {download_progress['status']}")
return False # Return False to indicate no remote download happened
model_hash = model.get('sha256', '').lower()
model_name = model.get('model_name', 'Unknown')
model_file_path = model.get('file_path', '')
model_file_name = model.get('file_name', '')
try:
# Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already processed AND directory exists with files
if model_hash in download_progress['processed_models']:
model_dir = os.path.join(output_dir, model_hash)
has_files = os.path.exists(model_dir) and any(os.listdir(model_dir))
if has_files:
logger.debug(f"Skipping already processed model: {model_name}")
return False
else:
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
os.makedirs(model_dir, exist_ok=True)
# First check for local example images - local processing doesn't need delay
local_images_processed = await ExampleImagesProcessor.process_local_examples(
model_file_path, model_file_name, model_name, model_dir, optimize
)
# If we processed local images, update metadata
if local_images_processed:
await MetadataUpdater.update_metadata_from_local_examples(
model_hash, model, scanner_type, scanner, model_dir
)
download_progress['processed_models'].add(model_hash)
return False # Return False to indicate no remote download happened
# If no local images, try to download from remote
elif model.get('civitai') and model.get('civitai', {}).get('images'):
images = model.get('civitai', {}).get('images', [])
success, is_stale = await ExampleImagesProcessor.download_model_images(
model_hash, model_name, images, model_dir, optimize, independent_session
)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in download_progress['refreshed_models']:
await MetadataUpdater.refresh_model_metadata(
model_hash, model_name, scanner_type, scanner
)
# Get the updated model data
updated_model = await MetadataUpdater.get_updated_model(
model_hash, scanner
)
if updated_model and updated_model.get('civitai', {}).get('images'):
# Retry download with updated metadata
updated_images = updated_model.get('civitai', {}).get('images', [])
success, _ = await ExampleImagesProcessor.download_model_images(
model_hash, model_name, updated_images, model_dir, optimize, independent_session
)
# Only mark as processed if all images were downloaded successfully
if success:
download_progress['processed_models'].add(model_hash)
return True # Return True to indicate a remote download happened
# Save progress periodically
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
DownloadManager._save_progress(output_dir)
return False # Default return if no conditions met
except Exception as e:
error_msg = f"Error processing model {model.get('model_name')}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False # Return False on exception
@staticmethod
def _save_progress(output_dir):
"""Save download progress to file"""
global download_progress
try:
progress_file = os.path.join(output_dir, '.download_progress.json')
# Read existing progress file if it exists
existing_data = {}
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
except Exception as e:
logger.warning(f"Failed to read existing progress file: {e}")
# Create new progress data
progress_data = {
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time()
}
# Preserve existing fields (especially naming_version)
for key, value in existing_data.items():
if key not in progress_data:
progress_data[key] = value
# Write updated progress data
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump(progress_data, f, indent=2)
except Exception as e:
logger.error(f"Failed to save progress file: {e}")

View File

@@ -0,0 +1,201 @@
import logging
import os
import re
import sys
import subprocess
from aiohttp import web
from ..services.settings_manager import settings
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
class ExampleImagesFileManager:
"""Manages access and operations for example image files"""
@staticmethod
async def open_folder(request):
"""
Open the example images folder for a specific model
Expects a JSON request body with:
{
"model_hash": "sha256_hash" # SHA256 hash of the model
}
"""
try:
# Parse request body
data = await request.json()
model_hash = data.get('model_hash')
if not model_hash:
return web.json_response({
'success': False,
'error': 'Missing model_hash parameter'
}, status=400)
# Get example images path from settings
example_images_path = settings.get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
'error': 'No example images path configured. Please set it in the settings panel first.'
}, status=400)
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
# Check if folder exists
if not os.path.exists(model_folder):
return web.json_response({
'success': False,
'error': 'No example images found for this model. Download example images first.'
}, status=404)
# Open folder in file explorer
if os.name == 'nt': # Windows
os.startfile(model_folder)
elif os.name == 'posix': # macOS and Linux
if sys.platform == 'darwin': # macOS
subprocess.Popen(['open', model_folder])
else: # Linux
subprocess.Popen(['xdg-open', model_folder])
return web.json_response({
'success': True,
'message': f'Opened example images folder for model {model_hash}'
})
except Exception as e:
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_files(request):
"""
Get the list of example image files for a specific model
Expects:
- model_hash in query parameters
Returns:
- List of image files and their paths
"""
try:
# Get model_hash from query parameters
model_hash = request.query.get('model_hash')
if not model_hash:
return web.json_response({
'success': False,
'error': 'Missing model_hash parameter'
}, status=400)
# Get example images path from settings
example_images_path = settings.get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
'error': 'No example images path configured'
}, status=400)
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
# Check if folder exists
if not os.path.exists(model_folder):
return web.json_response({
'success': False,
'error': 'No example images found for this model',
'files': []
}, status=404)
# Get list of files in the folder
files = []
for file in os.listdir(model_folder):
file_path = os.path.join(model_folder, file)
if os.path.isfile(file_path):
# Check if file is a supported media file
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
files.append({
'name': file,
'path': f'/example_images_static/{model_hash}/{file}',
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
})
return web.json_response({
'success': True,
'files': files
})
except Exception as e:
logger.error(f"Failed to get example image files: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def has_images(request):
"""
Check if the example images folder for a model exists and is not empty
Expects:
- model_hash in query parameters
Returns:
- Boolean indicating whether the folder exists and contains images/videos
"""
try:
# Get model_hash from query parameters
model_hash = request.query.get('model_hash')
if not model_hash:
return web.json_response({
'success': False,
'error': 'Missing model_hash parameter'
}, status=400)
# Get example images path from settings
example_images_path = settings.get('example_images_path')
if not example_images_path:
return web.json_response({
'has_images': False
})
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
# Check if folder exists
if not os.path.exists(model_folder) or not os.path.isdir(model_folder):
return web.json_response({
'has_images': False
})
# Check if folder contains any supported media files
for file in os.listdir(model_folder):
file_path = os.path.join(model_folder, file)
if os.path.isfile(file_path):
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
return web.json_response({
'has_images': True
})
# If reached here, folder exists but has no supported media files
return web.json_response({
'has_images': False
})
except Exception as e:
logger.error(f"Failed to check example images folder: {e}", exc_info=True)
return web.json_response({
'has_images': False,
'error': str(e)
})

View File

@@ -0,0 +1,390 @@
import logging
import os
import re
from ..utils.metadata_manager import MetadataManager
from ..utils.routes_common import ModelRouteUtils
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..utils.exif_utils import ExifUtils
from ..recipes.constants import GEN_PARAM_KEYS
logger = logging.getLogger(__name__)
class MetadataUpdater:
"""Handles updating model metadata related to example images"""
@staticmethod
async def refresh_model_metadata(model_hash, model_name, scanner_type, scanner):
"""Refresh model metadata from CivitAI
Args:
model_hash: SHA256 hash of the model
model_name: Model name (for logging)
scanner_type: Scanner type ('lora' or 'checkpoint')
scanner: Scanner instance for this model type
Returns:
bool: True if metadata was successfully refreshed, False otherwise
"""
from ..utils.example_images_download_manager import download_progress
try:
# Find the model in the scanner cache
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
break
if not model_data:
logger.warning(f"Model {model_name} with hash {model_hash} not found in cache")
return False
file_path = model_data.get('file_path')
if not file_path:
logger.warning(f"Model {model_name} has no file path")
return False
# Track that we're refreshing this model
download_progress['refreshed_models'].add(model_hash)
# Use ModelRouteUtils to refresh metadata
async def update_cache_func(old_path, new_path, metadata):
return await scanner.update_single_model_cache(old_path, new_path, metadata)
success = await ModelRouteUtils.fetch_and_update_model(
model_hash,
file_path,
model_data,
update_cache_func
)
if success:
logger.info(f"Successfully refreshed metadata for {model_name}")
return True
else:
logger.warning(f"Failed to refresh metadata for {model_name}")
return False
except Exception as e:
error_msg = f"Error refreshing metadata for {model_name}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False
@staticmethod
async def get_updated_model(model_hash, scanner):
"""Get updated model data
Args:
model_hash: SHA256 hash of the model
scanner: Scanner instance
Returns:
dict: Updated model data or None if not found
"""
cache = await scanner.get_cached_data()
for item in cache.raw_data:
if item.get('sha256') == model_hash:
return item
return None
@staticmethod
async def update_metadata_from_local_examples(model_hash, model, scanner_type, scanner, model_dir):
"""Update model metadata with local example image information
Args:
model_hash: SHA256 hash of the model
model: Model data dictionary
scanner_type: Scanner type ('lora' or 'checkpoint')
scanner: Scanner instance for this model type
model_dir: Model images directory
Returns:
bool: True if metadata was successfully updated, False otherwise
"""
try:
# Collect local image paths
local_images_paths = []
if os.path.exists(model_dir):
for file in os.listdir(model_dir):
file_path = os.path.join(model_dir, file)
if os.path.isfile(file_path):
file_ext = os.path.splitext(file)[1].lower()
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
if is_supported:
local_images_paths.append(file_path)
# Check if metadata update is needed (no civitai field or empty images)
needs_update = not model.get('civitai') or not model.get('civitai', {}).get('images')
if needs_update and local_images_paths:
logger.debug(f"Found {len(local_images_paths)} local example images for {model.get('model_name')}, updating metadata")
# Create or get civitai field
if not model.get('civitai'):
model['civitai'] = {}
# Create images array
images = []
# Generate metadata for each local image/video
for path in local_images_paths:
# Determine if video or image
file_ext = os.path.splitext(path)[1].lower()
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
# Create image metadata entry
image_entry = {
"url": "", # Empty URL as required
"nsfwLevel": 0,
"width": 720, # Default dimensions
"height": 1280,
"type": "video" if is_video else "image",
"meta": None,
"hasMeta": False,
"hasPositivePrompt": False
}
# If it's an image, try to get actual dimensions (optional enhancement)
try:
from PIL import Image
if not is_video and os.path.exists(path):
with Image.open(path) as img:
image_entry["width"], image_entry["height"] = img.size
except:
# If PIL fails or is unavailable, use default dimensions
pass
images.append(image_entry)
# Update the model's civitai.images field
model['civitai']['images'] = images
# Save metadata to .metadata.json file
file_path = model.get('file_path')
try:
# Create a copy of model data without 'folder' field
model_copy = model.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.info(f"Saved metadata for {model.get('model_name')}")
except Exception as e:
logger.error(f"Failed to save metadata for {model.get('model_name')}: {str(e)}")
# Save updated metadata to scanner cache
success = await scanner.update_single_model_cache(file_path, file_path, model)
if success:
logger.info(f"Successfully updated metadata for {model.get('model_name')} with {len(images)} local examples")
return True
else:
logger.warning(f"Failed to update metadata for {model.get('model_name')}")
return False
except Exception as e:
logger.error(f"Error updating metadata from local examples: {str(e)}", exc_info=True)
return False
@staticmethod
async def update_metadata_after_import(model_hash, model_data, scanner, newly_imported_paths):
"""Update model metadata after importing example images
Args:
model_hash: SHA256 hash of the model
model_data: Model data dictionary
scanner: Scanner instance (lora or checkpoint)
newly_imported_paths: List of paths to newly imported files
Returns:
tuple: (regular_images, custom_images) - Both image arrays
"""
try:
# Ensure civitai field exists in model_data
if not model_data.get('civitai'):
model_data['civitai'] = {}
# Ensure customImages array exists
if not model_data['civitai'].get('customImages'):
model_data['civitai']['customImages'] = []
# Get current customImages array
custom_images = model_data['civitai']['customImages']
# Add new image entry for each imported file
for path_tuple in newly_imported_paths:
path, short_id = path_tuple
# Determine if video or image
file_ext = os.path.splitext(path)[1].lower()
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
# Create image metadata entry
image_entry = {
"url": "", # Empty URL as requested
"id": short_id,
"nsfwLevel": 0,
"width": 720, # Default dimensions
"height": 1280,
"type": "video" if is_video else "image",
"meta": None,
"hasMeta": False,
"hasPositivePrompt": False
}
# Extract and parse metadata if this is an image
if not is_video:
try:
# Extract metadata from image
extracted_metadata = ExifUtils.extract_image_metadata(path)
if extracted_metadata:
# Parse the extracted metadata to get generation parameters
parsed_meta = MetadataUpdater._parse_image_metadata(extracted_metadata)
if parsed_meta:
image_entry["meta"] = parsed_meta
image_entry["hasMeta"] = True
image_entry["hasPositivePrompt"] = bool(parsed_meta.get("prompt", ""))
logger.debug(f"Extracted metadata from {os.path.basename(path)}")
except Exception as e:
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
# If it's an image, try to get actual dimensions
try:
from PIL import Image
if not is_video and os.path.exists(path):
with Image.open(path) as img:
image_entry["width"], image_entry["height"] = img.size
except:
# If PIL fails or is unavailable, use default dimensions
pass
# Append to existing customImages array
custom_images.append(image_entry)
# Save metadata to .metadata.json file
file_path = model_data.get('file_path')
if file_path:
try:
# Create a copy of model data without 'folder' field
model_copy = model_data.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.info(f"Saved metadata for {model_data.get('model_name')}")
except Exception as e:
logger.error(f"Failed to save metadata: {str(e)}")
# Save updated metadata to scanner cache
if file_path:
await scanner.update_single_model_cache(file_path, file_path, model_data)
# Get regular images array (might be None)
regular_images = model_data['civitai'].get('images', [])
# Return both image arrays
return regular_images, custom_images
except Exception as e:
logger.error(f"Failed to update metadata after import: {e}", exc_info=True)
return [], []
@staticmethod
def _parse_image_metadata(user_comment):
"""Parse metadata from image to extract generation parameters
Args:
user_comment: Metadata string extracted from image
Returns:
dict: Parsed metadata with generation parameters
"""
if not user_comment:
return None
try:
# Initialize metadata dictionary
metadata = {}
# Split on Negative prompt if it exists
if "Negative prompt:" in user_comment:
parts = user_comment.split('Negative prompt:', 1)
prompt = parts[0].strip()
negative_and_params = parts[1] if len(parts) > 1 else ""
else:
# No negative prompt section
param_start = re.search(r'Steps: \d+', user_comment)
if param_start:
prompt = user_comment[:param_start.start()].strip()
negative_and_params = user_comment[param_start.start():]
else:
prompt = user_comment.strip()
negative_and_params = ""
# Add prompt if it's in GEN_PARAM_KEYS
if 'prompt' in GEN_PARAM_KEYS:
metadata['prompt'] = prompt
# Extract negative prompt and parameters
if negative_and_params:
# If we split on "Negative prompt:", check for params section
if "Negative prompt:" in user_comment:
param_start = re.search(r'Steps: ', negative_and_params)
if param_start:
neg_prompt = negative_and_params[:param_start.start()].strip()
if 'negative_prompt' in GEN_PARAM_KEYS:
metadata['negative_prompt'] = neg_prompt
params_section = negative_and_params[param_start.start():]
else:
if 'negative_prompt' in GEN_PARAM_KEYS:
metadata['negative_prompt'] = negative_and_params.strip()
params_section = ""
else:
# No negative prompt, entire section is params
params_section = negative_and_params
# Extract generation parameters
if params_section:
# Extract basic parameters
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
params = re.findall(param_pattern, params_section)
for key, value in params:
clean_key = key.strip().lower().replace(' ', '_')
# Skip if not in recognized gen param keys
if clean_key not in GEN_PARAM_KEYS:
continue
# Convert numeric values
if clean_key in ['steps', 'seed']:
try:
metadata[clean_key] = int(value.strip())
except ValueError:
metadata[clean_key] = value.strip()
elif clean_key in ['cfg_scale']:
try:
metadata[clean_key] = float(value.strip())
except ValueError:
metadata[clean_key] = value.strip()
else:
metadata[clean_key] = value.strip()
# Extract size if available and add if a recognized key
size_match = re.search(r'Size: (\d+)x(\d+)', params_section)
if size_match and 'size' in GEN_PARAM_KEYS:
width, height = size_match.groups()
metadata['size'] = f"{width}x{height}"
# Return metadata if we have any entries
return metadata if metadata else None
except Exception as e:
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
return None

View File

@@ -0,0 +1,318 @@
import asyncio
import logging
import os
import re
import json
from ..services.settings_manager import settings
from ..services.service_registry import ServiceRegistry
from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
class ExampleImagesMigration:
"""Handles migrations for example images naming conventions"""
@staticmethod
async def check_and_run_migrations():
"""Check if migrations are needed and run them in background"""
example_images_path = settings.get('example_images_path')
if not example_images_path or not os.path.exists(example_images_path):
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
return
# Check current version from progress file
current_version = 0
progress_file = os.path.join(example_images_path, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
current_version = progress_data.get('naming_version', 0)
except Exception as e:
logger.error(f"Failed to load progress file for migration check: {e}")
# If current version is less than target version, start migration
if current_version < CURRENT_NAMING_VERSION:
logger.info(f"Starting example images naming migration from v{current_version} to v{CURRENT_NAMING_VERSION}")
# Start migration in background task
asyncio.create_task(
ExampleImagesMigration.run_migrations(example_images_path, current_version, CURRENT_NAMING_VERSION)
)
@staticmethod
async def run_migrations(example_images_path, from_version, to_version):
"""Run necessary migrations based on version difference"""
try:
# Get all model folders
model_folders = []
for item in os.listdir(example_images_path):
item_path = os.path.join(example_images_path, item)
if os.path.isdir(item_path) and len(item) == 64: # SHA256 hash is 64 chars
model_folders.append(item_path)
logger.info(f"Found {len(model_folders)} model folders to check for migration")
# Apply migrations sequentially
if from_version < 1 and to_version >= 1:
await ExampleImagesMigration._migrate_to_v1(model_folders)
if from_version < 2 and to_version >= 2:
await ExampleImagesMigration._migrate_to_v2(model_folders)
# Update version in progress file
progress_file = os.path.join(example_images_path, '.download_progress.json')
try:
progress_data = {}
if os.path.exists(progress_file):
with open(progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
progress_data['naming_version'] = to_version
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump(progress_data, f, indent=2)
logger.info(f"Example images naming migration to v{to_version} completed")
except Exception as e:
logger.error(f"Failed to update version in progress file: {e}")
except Exception as e:
logger.error(f"Error during migration: {e}", exc_info=True)
@staticmethod
async def _migrate_to_v1(model_folders):
"""Migrate from 1-based to 0-based indexing"""
count = 0
for folder in model_folders:
has_one_based = False
has_zero_based = False
files_to_rename = []
# Check naming pattern in this folder
for file in os.listdir(folder):
if re.match(r'image_1\.\w+$', file):
has_one_based = True
if re.match(r'image_0\.\w+$', file):
has_zero_based = True
# Only migrate folders with 1-based indexing and no 0-based
if has_one_based and not has_zero_based:
# Create rename mapping
for file in os.listdir(folder):
match = re.match(r'image_(\d+)\.(\w+)$', file)
if match:
index = int(match.group(1))
ext = match.group(2)
if index > 0: # Only rename if index is positive
files_to_rename.append((
file,
f"image_{index-1}.{ext}"
))
# Use temporary names to avoid conflicts
for old_name, new_name in files_to_rename:
old_path = os.path.join(folder, old_name)
temp_path = os.path.join(folder, f"temp_{old_name}")
try:
os.rename(old_path, temp_path)
except Exception as e:
logger.error(f"Failed to rename {old_path} to {temp_path}: {e}")
# Rename from temporary names to final names
for old_name, new_name in files_to_rename:
temp_path = os.path.join(folder, f"temp_{old_name}")
new_path = os.path.join(folder, new_name)
try:
os.rename(temp_path, new_path)
logger.debug(f"Renamed {old_name} to {new_name} in {folder}")
except Exception as e:
logger.error(f"Failed to rename {temp_path} to {new_path}: {e}")
count += 1
# Give other tasks a chance to run
if count % 10 == 0:
await asyncio.sleep(0)
logger.info(f"Migrated {count} folders from 1-based to 0-based indexing")
@staticmethod
async def _migrate_to_v2(model_folders):
"""
Migrate to v2 naming scheme:
- Move custom examples from images array to customImages array
- Rename files from image_<index>.<ext> to custom_<short_id>.<ext>
- Add id field to each custom image entry
"""
count = 0
updated_models = 0
migration_errors = 0
# Get scanner instances
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
# Wait until scanners are initialized
scanners = [lora_scanner, checkpoint_scanner]
for scanner in scanners:
if scanner.is_initializing():
logger.info("Waiting for scanners to complete initialization before starting migration...")
initialized = False
retry_count = 0
while not initialized and retry_count < 120: # Wait up to 120 seconds
await asyncio.sleep(1)
initialized = not scanner.is_initializing()
retry_count += 1
if not initialized:
logger.warning("Scanner initialization timeout - proceeding with migration anyway")
logger.info(f"Starting migration to v2 naming scheme for {len(model_folders)} model folders")
for folder in model_folders:
try:
# Extract model hash from folder name
model_hash = os.path.basename(folder)
if not model_hash or len(model_hash) != 64:
continue
# Find the model in scanner cache
model_data = None
scanner = None
for scan_obj in scanners:
if scan_obj.has_hash(model_hash):
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
scanner = scan_obj
break
if model_data:
break
if not model_data or not scanner:
logger.debug(f"Model with hash {model_hash} not found in cache, skipping migration")
continue
# Clone model data to avoid modifying the cache directly
model_metadata = model_data.copy()
# Check if model has civitai metadata
if not model_metadata.get('civitai'):
continue
# Get images array
images = model_metadata.get('civitai', {}).get('images', [])
if not images:
continue
# Initialize customImages array if it doesn't exist
if not model_metadata['civitai'].get('customImages'):
model_metadata['civitai']['customImages'] = []
# Find custom examples (entries with empty url)
custom_indices = []
for i, image in enumerate(images):
if image.get('url') == "":
custom_indices.append(i)
if not custom_indices:
continue
logger.debug(f"Found {len(custom_indices)} custom examples in {model_hash}")
# Process each custom example
for index in custom_indices:
try:
image_entry = images[index]
# Determine media type based on the entry type
media_type = 'videos' if image_entry.get('type') == 'video' else 'images'
extensions_to_try = SUPPORTED_MEDIA_EXTENSIONS[media_type]
# Find the image file by trying possible extensions
old_path = None
old_filename = None
found = False
for ext in extensions_to_try:
test_path = os.path.join(folder, f"image_{index}{ext}")
if os.path.exists(test_path):
old_path = test_path
old_filename = f"image_{index}{ext}"
found = True
break
if not found:
logger.warning(f"Could not find file for index {index} in {model_hash}, skipping")
continue
# Generate short ID for the custom example
short_id = ExampleImagesProcessor.generate_short_id()
# Get file extension
file_ext = os.path.splitext(old_path)[1]
# Create new filename
new_filename = f"custom_{short_id}{file_ext}"
new_path = os.path.join(folder, new_filename)
# Rename the file
try:
os.rename(old_path, new_path)
logger.debug(f"Renamed {old_filename} to {new_filename} in {folder}")
except Exception as e:
logger.error(f"Failed to rename {old_path} to {new_path}: {e}")
continue
# Create a copy of the image entry with the id field
custom_entry = image_entry.copy()
custom_entry['id'] = short_id
# Add to customImages array
model_metadata['civitai']['customImages'].append(custom_entry)
count += 1
except Exception as e:
logger.error(f"Error migrating custom example at index {index} for {model_hash}: {e}")
# Remove custom examples from the original images array
model_metadata['civitai']['images'] = [
img for i, img in enumerate(images) if i not in custom_indices
]
# Save the updated metadata
file_path = model_data.get('file_path')
if file_path:
try:
# Create a copy of model data without 'folder' field
model_copy = model_metadata.copy()
model_copy.pop('folder', None)
# Save metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
# Update scanner cache
await scanner.update_single_model_cache(file_path, file_path, model_metadata)
updated_models += 1
except Exception as e:
logger.error(f"Failed to save metadata for {model_hash}: {e}")
migration_errors += 1
# Give other tasks a chance to run
if count % 10 == 0:
await asyncio.sleep(0)
except Exception as e:
logger.error(f"Error migrating folder {folder}: {e}")
migration_errors += 1
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")

View File

@@ -0,0 +1,494 @@
import logging
import os
import re
import tempfile
import random
import string
from aiohttp import web
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings
from .example_images_metadata import MetadataUpdater
from ..utils.metadata_manager import MetadataManager
logger = logging.getLogger(__name__)
class ExampleImagesProcessor:
"""Processes and manipulates example images"""
@staticmethod
def generate_short_id(length=8):
"""Generate a short random alphanumeric identifier"""
chars = string.ascii_lowercase + string.digits
return ''.join(random.choice(chars) for _ in range(length))
@staticmethod
def get_civitai_optimized_url(image_url):
"""Convert Civitai image URL to its optimized WebP version"""
base_pattern = r'(https://image\.civitai\.com/[^/]+/[^/]+)'
match = re.match(base_pattern, image_url)
if match:
base_url = match.group(1)
return f"{base_url}/optimized=true/image.webp"
return image_url
@staticmethod
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, independent_session):
"""Download images for a single model
Returns:
tuple: (success, is_stale_metadata) - whether download was successful, whether metadata is stale
"""
model_success = True
for i, image in enumerate(model_images):
image_url = image.get('url')
if not image_url:
continue
# Get image filename from URL
image_filename = os.path.basename(image_url.split('?')[0])
image_ext = os.path.splitext(image_filename)[1].lower()
# Handle images and videos
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {image_filename}")
continue
# Use 0-based indexing instead of 1-based indexing
save_filename = f"image_{i}{image_ext}"
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
if is_image and optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
save_filename = f"image_{i}.webp"
# Check if already downloaded
save_path = os.path.join(model_dir, save_filename)
if os.path.exists(save_path):
logger.debug(f"File already exists: {save_path}")
continue
# Download the file
try:
logger.debug(f"Downloading {save_filename} for {model_name}")
# Download directly using the independent session
async with independent_session.get(image_url, timeout=60) as response:
if response.status == 200:
with open(save_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
if chunk:
f.write(chunk)
elif response.status == 404:
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
model_success = False # Mark the model as failed due to 404 error
# Return early to trigger metadata refresh attempt
return False, True # (success, is_metadata_stale)
else:
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
logger.warning(error_msg)
model_success = False # Mark the model as failed
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
model_success = False # Mark the model as failed
return model_success, False # (success, is_metadata_stale)
@staticmethod
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
"""Process local example images
Returns:
bool: True if local images were processed successfully, False otherwise
"""
try:
if not model_file_path or not os.path.exists(os.path.dirname(model_file_path)):
return False
model_dir_path = os.path.dirname(model_file_path)
local_images = []
# Look for files with pattern: filename.example.*.ext
if model_file_name:
example_prefix = f"{model_file_name}.example."
if os.path.exists(model_dir_path):
for file in os.listdir(model_dir_path):
file_lower = file.lower()
if file_lower.startswith(example_prefix.lower()):
file_ext = os.path.splitext(file_lower)[1]
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
if is_supported:
local_images.append(os.path.join(model_dir_path, file))
# Process local images if found
if local_images:
logger.info(f"Found {len(local_images)} local example images for {model_name}")
for local_image_path in local_images:
# Extract index from filename
file_name = os.path.basename(local_image_path)
example_prefix = f"{model_file_name}.example."
try:
# Extract the part between '.example.' and the file extension
index_part = file_name[len(example_prefix):].split('.')[0]
# Try to parse it as an integer
index = int(index_part)
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{index}{local_ext}"
except (ValueError, IndexError):
# If we can't parse the index, fall back to sequential numbering
logger.warning(f"Could not extract index from {file_name}, using sequential numbering")
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{len(local_images)}{local_ext}"
save_path = os.path.join(model_dir, save_filename)
# Skip if already exists in output directory
if os.path.exists(save_path):
logger.debug(f"File already exists in output: {save_path}")
continue
# Copy the file
with open(local_image_path, 'rb') as src_file:
with open(save_path, 'wb') as dst_file:
dst_file.write(src_file.read())
return True
return False
except Exception as e:
logger.error(f"Error processing local examples for {model_name}: {str(e)}")
return False
@staticmethod
async def import_images(request):
"""
Import local example images
Accepts:
- multipart/form-data form with model_hash and files fields
or
- JSON request with model_hash and file_paths
Returns:
- Success status and list of imported files
"""
try:
model_hash = None
files_to_import = []
temp_files_to_cleanup = []
# Check if it's a multipart form-data request (direct file upload)
if request.content_type and 'multipart/form-data' in request.content_type:
reader = await request.multipart()
# First get model_hash
field = await reader.next()
if field.name == 'model_hash':
model_hash = await field.text()
# Then process all files
while True:
field = await reader.next()
if field is None:
break
if field.name == 'files':
# Create a temporary file with appropriate suffix for type detection
file_name = field.filename
file_ext = os.path.splitext(file_name)[1].lower()
with tempfile.NamedTemporaryFile(suffix=file_ext, delete=False) as tmp_file:
temp_path = tmp_file.name
temp_files_to_cleanup.append(temp_path) # Track for cleanup
# Write chunks to the temporary file
while True:
chunk = await field.read_chunk()
if not chunk:
break
tmp_file.write(chunk)
# Add to the list of files to process
files_to_import.append(temp_path)
else:
# Parse JSON request (legacy method using file paths)
data = await request.json()
model_hash = data.get('model_hash')
files_to_import = data.get('file_paths', [])
if not model_hash:
return web.json_response({
'success': False,
'error': 'Missing model_hash parameter'
}, status=400)
if not files_to_import:
return web.json_response({
'success': False,
'error': 'No files provided to import'
}, status=400)
# Get example images path
example_images_path = settings.get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
'error': 'No example images path configured'
}, status=400)
# Find the model and get current metadata
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
model_data = None
scanner = None
# Check both scanners to find the model
for scan_obj in [lora_scanner, checkpoint_scanner]:
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
scanner = scan_obj
break
if model_data:
break
if not model_data:
return web.json_response({
'success': False,
'error': f"Model with hash {model_hash} not found in cache"
}, status=404)
# Create model folder
model_folder = os.path.join(example_images_path, model_hash)
os.makedirs(model_folder, exist_ok=True)
imported_files = []
errors = []
newly_imported_paths = []
# Process each file path
for file_path in files_to_import:
try:
# Ensure the file exists
if not os.path.isfile(file_path):
errors.append(f"File not found: {file_path}")
continue
# Check if file type is supported
file_ext = os.path.splitext(file_path)[1].lower()
if not (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
errors.append(f"Unsupported file type: {file_path}")
continue
# Generate new filename using short ID instead of UUID
short_id = ExampleImagesProcessor.generate_short_id()
new_filename = f"custom_{short_id}{file_ext}"
dest_path = os.path.join(model_folder, new_filename)
# Copy the file
import shutil
shutil.copy2(file_path, dest_path)
# Store both the dest_path and the short_id
newly_imported_paths.append((dest_path, short_id))
# Add to imported files list
imported_files.append({
'name': new_filename,
'path': f'/example_images_static/{model_hash}/{new_filename}',
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
})
except Exception as e:
errors.append(f"Error importing {file_path}: {str(e)}")
# Update metadata with new example images
regular_images, custom_images = await MetadataUpdater.update_metadata_after_import(
model_hash,
model_data,
scanner,
newly_imported_paths
)
return web.json_response({
'success': len(imported_files) > 0,
'message': f'Successfully imported {len(imported_files)} files' +
(f' with {len(errors)} errors' if errors else ''),
'files': imported_files,
'errors': errors,
'regular_images': regular_images,
'custom_images': custom_images,
"model_file_path": model_data.get('file_path', ''),
})
except Exception as e:
logger.error(f"Failed to import example images: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
finally:
# Clean up temporary files
for temp_file in temp_files_to_cleanup:
try:
os.remove(temp_file)
except Exception as e:
logger.error(f"Failed to remove temporary file {temp_file}: {e}")
@staticmethod
async def delete_custom_image(request):
"""
Delete a custom example image for a model
Accepts:
- JSON request with model_hash and short_id
Returns:
- Success status and updated image lists
"""
try:
# Parse request data
data = await request.json()
model_hash = data.get('model_hash')
short_id = data.get('short_id')
if not model_hash or not short_id:
return web.json_response({
'success': False,
'error': 'Missing required parameters: model_hash and short_id'
}, status=400)
# Get example images path
example_images_path = settings.get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
'error': 'No example images path configured'
}, status=400)
# Find the model and get current metadata
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
model_data = None
scanner = None
# Check both scanners to find the model
for scan_obj in [lora_scanner, checkpoint_scanner]:
if scan_obj.has_hash(model_hash):
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
scanner = scan_obj
break
if model_data:
break
if not model_data:
return web.json_response({
'success': False,
'error': f"Model with hash {model_hash} not found in cache"
}, status=404)
# Check if model has custom images
if not model_data.get('civitai', {}).get('customImages'):
return web.json_response({
'success': False,
'error': f"Model has no custom images"
}, status=404)
# Find the custom image with matching short_id
custom_images = model_data['civitai']['customImages']
matching_image = None
new_custom_images = []
for image in custom_images:
if image.get('id') == short_id:
matching_image = image
else:
new_custom_images.append(image)
if not matching_image:
return web.json_response({
'success': False,
'error': f"Custom image with id {short_id} not found"
}, status=404)
# Find and delete the actual file
model_folder = os.path.join(example_images_path, model_hash)
file_deleted = False
if os.path.exists(model_folder):
for filename in os.listdir(model_folder):
if f"custom_{short_id}" in filename:
file_path = os.path.join(model_folder, filename)
try:
os.remove(file_path)
file_deleted = True
logger.info(f"Deleted custom example file: {file_path}")
break
except Exception as e:
return web.json_response({
'success': False,
'error': f"Failed to delete file: {str(e)}"
}, status=500)
if not file_deleted:
logger.warning(f"File for custom example with id {short_id} not found, but metadata will still be updated")
# Update metadata
model_data['civitai']['customImages'] = new_custom_images
# Save updated metadata to file
file_path = model_data.get('file_path')
if file_path:
try:
# Create a copy of model data without 'folder' field
model_copy = model_data.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.debug(f"Saved updated metadata for {model_data.get('model_name')}")
except Exception as e:
logger.error(f"Failed to save metadata: {str(e)}")
return web.json_response({
'success': False,
'error': f"Failed to save metadata: {str(e)}"
}, status=500)
# Update cache
await scanner.update_single_model_cache(file_path, file_path, model_data)
# Get regular images array (might be None)
regular_images = model_data['civitai'].get('images', [])
return web.json_response({
'success': True,
'regular_images': regular_images,
'custom_images': new_custom_images,
'model_file_path': model_data.get('file_path', '')
})
except Exception as e:
logger.error(f"Failed to delete custom example image: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -31,7 +31,7 @@ class ExifUtils:
# Method 2: Check EXIF UserComment field
if img.format not in ['JPEG', 'TIFF', 'WEBP']:
# For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
exif = img._getexif()
exif = img.getexif()
if exif and piexif.ExifIFD.UserComment in exif:
user_comment = exif[piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
@@ -147,7 +147,7 @@ class ExifUtils:
"file_name": lora.get("file_name", ""),
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
"strength": float(lora.get("strength", 1.0)),
"modelVersionId": lora.get("modelVersionId", ""),
"modelVersionId": lora.get("modelVersionId", 0),
"modelName": lora.get("modelName", ""),
"modelVersionName": lora.get("modelVersionName", ""),
}

View File

@@ -1,13 +1,7 @@
import logging
import os
import hashlib
import json
import time
from typing import Dict, Optional, Type
from .model_utils import determine_base_model
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
from .models import BaseModelMetadata, LoraMetadata, CheckpointMetadata
from .constants import PREVIEW_EXTENSIONS, CARD_PREVIEW_WIDTH
from .exif_utils import ExifUtils
@@ -24,7 +18,12 @@ async def calculate_sha256(file_path: str) -> str:
def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory"""
for ext in PREVIEW_EXTENSIONS:
temp_extensions = PREVIEW_EXTENSIONS.copy()
# Add example extension for compatibility
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
# The preview image will be optimized to lora-name.webp, so it won't affect other logic
temp_extensions.append(".example.0.jpeg")
for ext in temp_extensions:
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
if os.path.exists(full_pattern):
# Check if this is an image and not already webp
@@ -42,7 +41,7 @@ def find_preview_file(base_name: str, dir_path: str) -> str:
target_width=CARD_PREVIEW_WIDTH,
format='webp',
quality=85,
preserve_metadata=False # Changed from True to False
preserve_metadata=False
)
# Save the optimized webp file
@@ -63,199 +62,4 @@ def find_preview_file(base_name: str, dir_path: str) -> str:
def normalize_path(path: str) -> str:
"""Normalize file path to use forward slashes"""
return path.replace(os.sep, "/") if path else path
async def get_file_info(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
"""Get basic file information as a model metadata object"""
# First check if file actually exists and resolve symlinks
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
return None
except Exception as e:
logger.error(f"Error checking file existence for {file_path}: {e}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
preview_url = find_preview_file(base_name, dir_path)
# Check if a .json file exists with SHA256 hash to avoid recalculation
json_path = f"{os.path.splitext(file_path)[0]}.json"
sha256 = None
if os.path.exists(json_path):
try:
with open(json_path, 'r', encoding='utf-8') as f:
json_data = json.load(f)
if 'sha256' in json_data:
sha256 = json_data['sha256'].lower()
logger.debug(f"Using SHA256 from .json file for {file_path}")
except Exception as e:
logger.error(f"Error reading .json file for {file_path}: {e}")
# If SHA256 is still not found, check for a .sha256 file
if sha256 is None:
sha256_file = f"{os.path.splitext(file_path)[0]}.sha256"
if os.path.exists(sha256_file):
try:
with open(sha256_file, 'r', encoding='utf-8') as f:
sha256 = f.read().strip().lower()
logger.debug(f"Using SHA256 from .sha256 file for {file_path}")
except Exception as e:
logger.error(f"Error reading .sha256 file for {file_path}: {e}")
try:
# If we didn't get SHA256 from the .json file, calculate it
if not sha256:
start_time = time.time()
sha256 = await calculate_sha256(real_path)
logger.debug(f"Calculated SHA256 for {file_path} in {time.time() - start_time:.2f} seconds")
# Create default metadata based on model class
if model_class == CheckpointMetadata:
metadata = CheckpointMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=os.path.getmtime(real_path),
sha256=sha256,
base_model="Unknown", # Will be updated later
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
model_type="checkpoint"
)
# Extract checkpoint-specific metadata
# model_info = await extract_checkpoint_metadata(real_path)
# metadata.base_model = model_info['base_model']
# if 'model_type' in model_info:
# metadata.model_type = model_info['model_type']
else: # Default to LoraMetadata
metadata = LoraMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=os.path.getmtime(real_path),
sha256=sha256,
base_model="Unknown", # Will be updated later
usage_tips="{}",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription=""
)
# Extract lora-specific metadata
model_info = await extract_lora_metadata(real_path)
metadata.base_model = model_info['base_model']
# Save metadata to file
await save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(f"Error getting file info for {file_path}: {e}")
return None
async def save_metadata(file_path: str, metadata: BaseModelMetadata) -> None:
"""Save metadata to .metadata.json file"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
try:
metadata_dict = metadata.to_dict()
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Error saving metadata to {metadata_path}: {str(e)}")
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
"""Load metadata from .metadata.json file"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
try:
if os.path.exists(metadata_path):
with open(metadata_path, 'r', encoding='utf-8') as f:
data = json.load(f)
needs_update = False
# Check and normalize base model name
normalized_base_model = determine_base_model(data['base_model'])
if data['base_model'] != normalized_base_model:
data['base_model'] = normalized_base_model
needs_update = True
# Compare paths without extensions
stored_path_base = os.path.splitext(data['file_path'])[0]
current_path_base = os.path.splitext(normalize_path(file_path))[0]
if stored_path_base != current_path_base:
data['file_path'] = normalize_path(file_path)
needs_update = True
# TODO: optimize preview image to webp format if not already done
preview_url = data.get('preview_url', '')
if not preview_url or not os.path.exists(preview_url):
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = normalize_path(find_preview_file(base_name, dir_path))
if new_preview_url != preview_url:
data['preview_url'] = new_preview_url
needs_update = True
else:
# Compare preview paths without extensions
stored_preview_base = os.path.splitext(preview_url)[0]
current_preview_base = os.path.splitext(normalize_path(preview_url))[0]
if stored_preview_base != current_preview_base:
data['preview_url'] = normalize_path(preview_url)
needs_update = True
# Ensure all fields are present
if 'tags' not in data:
data['tags'] = []
needs_update = True
if 'modelDescription' not in data:
data['modelDescription'] = ""
needs_update = True
# For checkpoint metadata
if model_class == CheckpointMetadata and 'model_type' not in data:
data['model_type'] = "checkpoint"
needs_update = True
# For lora metadata
if model_class == LoraMetadata and 'usage_tips' not in data:
data['usage_tips'] = "{}"
needs_update = True
# Update preview_nsfw_level if needed
civitai_data = data.get('civitai', {})
civitai_images = civitai_data.get('images', []) if civitai_data else []
if (data.get('preview_url') and
data.get('preview_nsfw_level', 0) == 0 and
civitai_images and
civitai_images[0].get('nsfwLevel', 0) != 0):
data['preview_nsfw_level'] = civitai_images[0]['nsfwLevel']
# TODO: write to metadata file
# needs_update = True
if needs_update:
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return model_class.from_dict(data)
except Exception as e:
print(f"Error loading metadata from {metadata_path}: {str(e)}")
return None
async def update_civitai_metadata(file_path: str, civitai_data: Dict) -> None:
"""Update metadata file with Civitai data"""
metadata = await load_metadata(file_path)
metadata['civitai'] = civitai_data
await save_metadata(file_path, metadata)
return path.replace(os.sep, "/") if path else path

View File

@@ -0,0 +1,292 @@
import os
import json
import shutil
import logging
from typing import Dict, Optional, Type, Union
from .models import BaseModelMetadata, LoraMetadata
from .file_utils import normalize_path, find_preview_file, calculate_sha256
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
logger = logging.getLogger(__name__)
class MetadataManager:
"""
Centralized manager for all metadata operations.
This class is responsible for:
1. Loading metadata safely with fallback mechanisms
2. Saving metadata with atomic operations and backups
3. Creating default metadata for models
4. Handling unknown fields gracefully
"""
@staticmethod
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
"""
Load metadata with robust error handling and data preservation.
Args:
file_path: Path to the model file
model_class: Class to instantiate (LoraMetadata, CheckpointMetadata, etc.)
Returns:
BaseModelMetadata instance or None if file doesn't exist
"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
backup_path = f"{metadata_path}.bak"
# Try loading the main metadata file
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Create model instance
metadata = model_class.from_dict(data)
# Normalize paths
await MetadataManager._normalize_metadata_paths(metadata, file_path)
return metadata
except json.JSONDecodeError:
# JSON parsing error - try to restore from backup
logger.warning(f"Invalid JSON in metadata file: {metadata_path}")
return await MetadataManager._restore_from_backup(backup_path, file_path, model_class)
except Exception as e:
# Other errors might be due to unknown fields or schema changes
logger.error(f"Error loading metadata from {metadata_path}: {str(e)}")
return await MetadataManager._restore_from_backup(backup_path, file_path, model_class)
return None
@staticmethod
async def _restore_from_backup(backup_path: str, file_path: str, model_class: Type[BaseModelMetadata]) -> Optional[BaseModelMetadata]:
"""
Try to restore metadata from backup file
Args:
backup_path: Path to backup file
file_path: Path to the original model file
model_class: Class to instantiate
Returns:
BaseModelMetadata instance or None if restoration fails
"""
if os.path.exists(backup_path):
try:
logger.info(f"Attempting to restore metadata from backup: {backup_path}")
with open(backup_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Process data similarly to normal loading
metadata = model_class.from_dict(data)
await MetadataManager._normalize_metadata_paths(metadata, file_path)
return metadata
except Exception as e:
logger.error(f"Failed to restore from backup: {str(e)}")
return None
@staticmethod
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict], create_backup: bool = False) -> bool:
"""
Save metadata with atomic write operations and backup creation.
Args:
path: Path to the model file or directly to the metadata file
metadata: Metadata to save (either BaseModelMetadata object or dict)
create_backup: Whether to create a new backup of existing file if a backup doesn't already exist
Returns:
bool: Success or failure
"""
# Determine if the input is a metadata path or a model file path
if path.endswith('.metadata.json'):
metadata_path = path
else:
# Use existing logic for model file paths
file_path = path
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
temp_path = f"{metadata_path}.tmp"
backup_path = f"{metadata_path}.bak"
try:
# Create backup if file exists and either:
# 1. create_backup is True, OR
# 2. backup file doesn't already exist
if os.path.exists(metadata_path) and (create_backup or not os.path.exists(backup_path)):
try:
shutil.copy2(metadata_path, backup_path)
logger.debug(f"Created metadata backup at: {backup_path}")
except Exception as e:
logger.warning(f"Failed to create metadata backup: {str(e)}")
# Convert to dict if needed
if isinstance(metadata, BaseModelMetadata):
metadata_dict = metadata.to_dict()
# Preserve unknown fields if present
if hasattr(metadata, '_unknown_fields'):
metadata_dict.update(metadata._unknown_fields)
else:
metadata_dict = metadata.copy()
# Normalize paths
if 'file_path' in metadata_dict:
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
if 'preview_url' in metadata_dict:
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
# Write to temporary file first
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)
# Atomic rename operation
os.replace(temp_path, metadata_path)
return True
except Exception as e:
logger.error(f"Error saving metadata to {metadata_path}: {str(e)}")
# Clean up temporary file if it exists
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
return False
@staticmethod
async def create_default_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
"""
Create basic metadata structure for a model file.
This replaces the old get_file_info function with a more appropriately named method.
Args:
file_path: Path to the model file
model_class: Class to instantiate
Returns:
BaseModelMetadata instance or None if file doesn't exist
"""
# First check if file actually exists and resolve symlinks
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
return None
except Exception as e:
logger.error(f"Error checking file existence for {file_path}: {e}")
return None
try:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
# Calculate file hash
sha256 = await calculate_sha256(real_path)
# Create instance based on model type
if model_class.__name__ == "CheckpointMetadata":
metadata = model_class(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=os.path.getmtime(real_path),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
model_type="checkpoint",
from_civitai=True
)
else: # Default to LoraMetadata
metadata = model_class(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=os.path.getmtime(real_path),
sha256=sha256,
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
from_civitai=True,
usage_tips="{}"
)
# Try to extract model-specific metadata
await MetadataManager._enrich_metadata(metadata, real_path)
# Save the created metadata
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
return metadata
except Exception as e:
logger.error(f"Error creating default metadata for {file_path}: {e}")
return None
@staticmethod
async def _enrich_metadata(metadata: BaseModelMetadata, file_path: str) -> None:
"""
Enrich metadata with model-specific information
Args:
metadata: Metadata to enrich
file_path: Path to the model file
"""
try:
if metadata.__class__.__name__ == "LoraMetadata":
model_info = await extract_lora_metadata(file_path)
metadata.base_model = model_info['base_model']
# elif metadata.__class__.__name__ == "CheckpointMetadata":
# model_info = await extract_checkpoint_metadata(file_path)
# metadata.base_model = model_info['base_model']
# if 'model_type' in model_info:
# metadata.model_type = model_info['model_type']
except Exception as e:
logger.error(f"Error enriching metadata: {str(e)}")
@staticmethod
async def _normalize_metadata_paths(metadata: BaseModelMetadata, file_path: str) -> None:
"""
Normalize paths in metadata object
Args:
metadata: Metadata object to update
file_path: Current file path for the model
"""
need_update = False
# Check if file path is different from what's in metadata
if normalize_path(file_path) != metadata.file_path:
metadata.file_path = normalize_path(file_path)
need_update = True
# Check if preview exists at the current location
preview_url = metadata.preview_url
if preview_url:
# Get directory parts of both paths
file_dir = os.path.dirname(file_path)
preview_dir = os.path.dirname(preview_url)
# Update preview if it doesn't exist OR if model and preview are in different directories
if not os.path.exists(preview_url) or file_dir != preview_dir:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = find_preview_file(base_name, dir_path)
if new_preview_url:
metadata.preview_url = normalize_path(new_preview_url)
need_update = True
# If path attributes were changed, save the metadata back to disk
if need_update:
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass, asdict
from typing import Dict, Optional, List
from dataclasses import dataclass, asdict, field
from typing import Dict, Optional, List, Any
from datetime import datetime
import os
from .model_utils import determine_base_model
@@ -24,6 +24,7 @@ class BaseModelMetadata:
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue
@@ -34,11 +35,43 @@ class BaseModelMetadata:
def from_dict(cls, data: Dict) -> 'BaseModelMetadata':
"""Create instance from dictionary"""
data_copy = data.copy()
return cls(**data_copy)
# Use cached fields if available, otherwise compute them
if not hasattr(cls, '_known_fields_cache'):
known_fields = set()
for c in cls.mro():
if hasattr(c, '__annotations__'):
known_fields.update(c.__annotations__.keys())
cls._known_fields_cache = known_fields
known_fields = cls._known_fields_cache
# Extract fields that match our class attributes
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
# Store unknown fields separately
unknown_fields = {k: v for k, v in data_copy.items() if k not in known_fields and not k.startswith('_')}
# Create instance with known fields
instance = cls(**fields_to_use)
# Add unknown fields as a separate attribute
instance._unknown_fields = unknown_fields
return instance
def to_dict(self) -> Dict:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
result = asdict(self)
# Remove private fields
result = {k: v for k, v in result.items() if not k.startswith('_')}
# Add back unknown fields if they exist
if hasattr(self, '_unknown_fields'):
result.update(self._unknown_fields)
return result
@property
def modified_datetime(self) -> datetime:

View File

@@ -9,6 +9,7 @@ from .constants import PREVIEW_EXTENSIONS, CARD_PREVIEW_WIDTH
from ..config import config
from ..services.civitai_client import CivitaiClient
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from ..services.download_manager import DownloadManager
logger = logging.getLogger(__name__)
@@ -32,14 +33,29 @@ class ModelRouteUtils:
async def handle_not_found_on_civitai(metadata_path: str, local_metadata: Dict) -> None:
"""Handle case when model is not found on CivitAI"""
local_metadata['from_civitai'] = False
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(local_metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(metadata_path, local_metadata)
@staticmethod
async def update_model_metadata(metadata_path: str, local_metadata: Dict,
civitai_metadata: Dict, client: CivitaiClient) -> None:
"""Update local metadata with CivitAI data"""
local_metadata['civitai'] = civitai_metadata
# Save existing trainedWords and customImages if they exist
existing_civitai = local_metadata.get('civitai') or {} # Use empty dict if None
# Create a new civitai metadata by updating existing with new
merged_civitai = existing_civitai.copy()
merged_civitai.update(civitai_metadata)
# Special handling for trainedWords - ensure we don't lose any existing trained words
if 'trainedWords' in existing_civitai:
existing_trained_words = existing_civitai.get('trainedWords', [])
new_trained_words = civitai_metadata.get('trainedWords', [])
# Use a set to combine words without duplicates, then convert back to list
merged_trained_words = list(set(existing_trained_words + new_trained_words))
merged_civitai['trainedWords'] = merged_trained_words
# Update local metadata with merged civitai data
local_metadata['civitai'] = merged_civitai
local_metadata['from_civitai'] = True
# Update model name if available
@@ -47,13 +63,30 @@ class ModelRouteUtils:
if civitai_metadata.get('model', {}).get('name'):
local_metadata['model_name'] = civitai_metadata['model']['name']
# Fetch additional model metadata (description and tags) if we have model ID
model_id = civitai_metadata['modelId']
if model_id:
model_metadata, _ = await client.get_model_metadata(str(model_id))
if (model_metadata):
local_metadata['modelDescription'] = model_metadata.get('description', '')
local_metadata['tags'] = model_metadata.get('tags', [])
# Extract model metadata directly from civitai_metadata if available
model_metadata = None
if 'model' in civitai_metadata and civitai_metadata.get('model'):
# Data is already available in the response from get_model_version
model_metadata = {
'description': civitai_metadata.get('model', {}).get('description', ''),
'tags': civitai_metadata.get('model', {}).get('tags', []),
'creator': civitai_metadata.get('creator', {})
}
# If we have modelId and don't have enough metadata, fetch additional data
if not model_metadata or not model_metadata.get('description'):
model_id = civitai_metadata.get('modelId')
if model_id:
fetched_metadata, _ = await client.get_model_metadata(str(model_id))
if fetched_metadata:
model_metadata = fetched_metadata
# Update local metadata with the model information
if model_metadata:
local_metadata['modelDescription'] = model_metadata.get('description', '')
local_metadata['tags'] = model_metadata.get('tags', [])
if 'creator' in model_metadata and model_metadata['creator']:
local_metadata['civitai']['creator'] = model_metadata['creator']
# Update base model
@@ -121,8 +154,7 @@ class ModelRouteUtils:
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
# Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(local_metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(metadata_path, local_metadata, True)
@staticmethod
async def fetch_and_update_model(
@@ -160,8 +192,7 @@ class ModelRouteUtils:
# Mark as not from CivitAI if not found
local_metadata['from_civitai'] = False
model_data['from_civitai'] = False
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(local_metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(file_path, local_metadata)
return False
# Update metadata
@@ -204,18 +235,17 @@ class ModelRouteUtils:
fields = [
"id", "modelId", "name", "createdAt", "updatedAt",
"publishedAt", "trainedWords", "baseModel", "description",
"model", "images", "creator"
"model", "images", "customImages", "creator"
]
return {k: data[k] for k in fields if k in data}
@staticmethod
async def delete_model_files(target_dir: str, file_name: str, file_monitor=None) -> List[str]:
async def delete_model_files(target_dir: str, file_name: str) -> List[str]:
"""Delete model and associated files
Args:
target_dir: Directory containing the model files
file_name: Base name of the model file without extension
file_monitor: Optional file monitor to ignore delete events
Returns:
List of deleted file paths
@@ -233,11 +263,7 @@ class ModelRouteUtils:
main_file = patterns[0]
main_path = os.path.join(target_dir, main_file).replace(os.sep, '/')
if os.path.exists(main_path):
# Notify file monitor to ignore delete event if available
if file_monitor:
file_monitor.handler.add_ignore_path(main_path, 0)
if os.path.exists(main_path):
# Delete file
os.remove(main_path)
deleted.append(main_path)
@@ -258,10 +284,12 @@ class ModelRouteUtils:
@staticmethod
def get_multipart_ext(filename):
"""Get extension that may have multiple parts like .metadata.json"""
"""Get extension that may have multiple parts like .metadata.json or .metadata.json.bak"""
parts = filename.split(".")
if len(parts) > 2: # If contains multi-part extension
if len(parts) == 3: # If contains 2-part extension
return "." + ".".join(parts[-2:]) # Take the last two parts, like ".metadata.json"
elif len(parts) >= 4: # If contains 3-part or more extensions
return "." + ".".join(parts[-3:]) # Take the last three parts, like ".metadata.json.bak"
return os.path.splitext(filename)[1] # Otherwise take the regular extension, like ".safetensors"
# New common endpoint handlers
@@ -286,13 +314,9 @@ class ModelRouteUtils:
target_dir = os.path.dirname(file_path)
file_name = os.path.splitext(os.path.basename(file_path))[0]
# Get the file monitor from the scanner if available
file_monitor = getattr(scanner, 'file_monitor', None)
deleted_files = await ModelRouteUtils.delete_model_files(
target_dir,
file_name,
file_monitor
file_name
)
# Remove from cache
@@ -324,7 +348,7 @@ class ModelRouteUtils:
scanner: The model scanner instance with cache management methods
Returns:
web.Response: The HTTP response
web.Response: The HTTP response with metadata on success
"""
try:
data = await request.json()
@@ -349,7 +373,8 @@ class ModelRouteUtils:
# Update the cache
await scanner.update_single_model_cache(data['file_path'], data['file_path'], local_metadata)
return web.json_response({"success": True})
# Return the updated metadata along with success status
return web.json_response({"success": True, "metadata": local_metadata})
finally:
await client.close()
@@ -359,15 +384,7 @@ class ModelRouteUtils:
@staticmethod
async def handle_replace_preview(request: web.Request, scanner) -> web.Response:
"""Handle preview image replacement request
Args:
request: The aiohttp request
scanner: The model scanner instance with methods to update cache
Returns:
web.Response: The HTTP response
"""
"""Handle preview image replacement request"""
try:
reader = await request.multipart()
@@ -376,6 +393,15 @@ class ModelRouteUtils:
if field.name != 'preview_file':
raise ValueError("Expected 'preview_file' field")
content_type = field.headers.get('Content-Type', 'image/png')
# Try to get original filename if available
content_disposition = field.headers.get('Content-Disposition', '')
original_filename = None
import re
filename_match = re.search(r'filename="(.*?)"', content_disposition)
if filename_match:
original_filename = filename_match.group(1)
preview_data = await field.read()
# Read model path
@@ -384,17 +410,47 @@ class ModelRouteUtils:
raise ValueError("Expected 'model_path' field")
model_path = (await field.read()).decode()
# Read NSFW level
nsfw_level = 0 # Default to 0 (unknown)
field = await reader.next()
if field and field.name == 'nsfw_level':
try:
nsfw_level = int((await field.read()).decode())
except (ValueError, TypeError):
logger.warning("Invalid NSFW level format, using default 0")
# Save preview file
base_name = os.path.splitext(os.path.basename(model_path))[0]
folder = os.path.dirname(model_path)
# Determine if content is video or image
# Determine format based on content type and original filename
is_gif = False
if original_filename and original_filename.lower().endswith('.gif'):
is_gif = True
elif content_type.lower() == 'image/gif':
is_gif = True
# Determine if content is video or image and handle specific formats
if content_type.startswith('video/'):
# For videos, keep original format and use .mp4 extension
extension = '.mp4'
# For videos, preserve original format if possible
if original_filename:
extension = os.path.splitext(original_filename)[1].lower()
# Default to .mp4 if no extension or unrecognized
if not extension or extension not in ['.mp4', '.webm', '.mov', '.avi']:
extension = '.mp4'
else:
# Try to determine extension from content type
if 'webm' in content_type:
extension = '.webm'
else:
extension = '.mp4' # Default
optimized_data = preview_data # No optimization for videos
elif is_gif:
# Preserve GIF format without optimization
extension = '.gif'
optimized_data = preview_data
else:
# For images, optimize and convert to WebP
# For other images, optimize and convert to WebP
optimized_data, _ = ExifUtils.optimize_image(
image_data=preview_data,
target_width=CARD_PREVIEW_WIDTH,
@@ -402,35 +458,45 @@ class ModelRouteUtils:
quality=85,
preserve_metadata=False
)
extension = '.webp' # Use .webp without .preview part
extension = '.webp'
# Delete any existing preview files for this model
for ext in PREVIEW_EXTENSIONS:
existing_preview = os.path.join(folder, base_name + ext)
if os.path.exists(existing_preview):
try:
os.remove(existing_preview)
logger.debug(f"Deleted existing preview: {existing_preview}")
except Exception as e:
logger.warning(f"Failed to delete existing preview {existing_preview}: {e}")
preview_path = os.path.join(folder, base_name + extension).replace(os.sep, '/')
with open(preview_path, 'wb') as f:
f.write(optimized_data)
# Update preview path in metadata
# Update preview path and NSFW level in metadata
metadata_path = os.path.splitext(model_path)[0] + '.metadata.json'
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
# Update preview_url directly in the metadata dict
# Update preview_url and preview_nsfw_level in the metadata dict
metadata['preview_url'] = preview_path
metadata['preview_nsfw_level'] = nsfw_level
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(model_path, metadata)
except Exception as e:
logger.error(f"Error updating metadata: {e}")
# Update preview URL in scanner cache
if hasattr(scanner, 'update_preview_in_cache'):
await scanner.update_preview_in_cache(model_path, preview_path)
await scanner.update_preview_in_cache(model_path, preview_path, nsfw_level)
return web.json_response({
"success": True,
"preview_url": config.get_preview_static_url(preview_path)
"preview_url": config.get_preview_static_url(preview_path),
"preview_nsfw_level": nsfw_level
})
except Exception as e:
@@ -460,8 +526,7 @@ class ModelRouteUtils:
metadata['exclude'] = True
# Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
await MetadataManager.save_metadata(file_path, metadata)
# Update cache
cache = await scanner.get_cached_data()
@@ -616,7 +681,7 @@ class ModelRouteUtils:
@staticmethod
async def handle_relink_civitai(request: web.Request, scanner) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID
"""Handle CivitAI metadata re-linking request by model ID and/or version ID
Args:
request: The aiohttp request
@@ -628,10 +693,11 @@ class ModelRouteUtils:
try:
data = await request.json()
file_path = data.get('file_path')
model_id = data.get('model_id')
model_version_id = data.get('model_version_id')
if not file_path or not model_version_id:
return web.json_response({"success": False, "error": "Both file_path and model_version_id are required"}, status=400)
if not file_path or not model_id:
return web.json_response({"success": False, "error": "Both file_path and model_id are required"}, status=400)
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
@@ -641,24 +707,24 @@ class ModelRouteUtils:
# Create a client for fetching from Civitai
client = await CivitaiClient.get_instance()
try:
# Fetch metadata by model version ID
civitai_metadata, error = await client.get_model_version_info(model_version_id)
# Fetch metadata using get_model_version which includes more comprehensive data
civitai_metadata = await client.get_model_version(model_id, model_version_id)
if not civitai_metadata:
error_msg = error or "Model version not found on CivitAI"
error_msg = f"Model version not found on CivitAI for ID: {model_id}"
if model_version_id:
error_msg += f" with version: {model_version_id}"
return web.json_response({"success": False, "error": error_msg}, status=404)
# Find the primary model file to get the correct SHA256 hash
# Try to find the primary model file to get the SHA256 hash
primary_model_file = None
for file in civitai_metadata.get('files', []):
if file.get('primary', False) and file.get('type') == 'Model':
primary_model_file = file
break
if not primary_model_file or not primary_model_file.get('hashes', {}).get('SHA256'):
return web.json_response({"success": False, "error": "No SHA256 hash found in model metadata"}, status=404)
# Update the SHA256 hash in local metadata (convert to lowercase)
local_metadata['sha256'] = primary_model_file['hashes']['SHA256'].lower()
# Update the SHA256 hash in local metadata if available
if primary_model_file and primary_model_file.get('hashes', {}).get('SHA256'):
local_metadata['sha256'] = primary_model_file['hashes']['SHA256'].lower()
# Update metadata with CivitAI information
await ModelRouteUtils.update_model_metadata(metadata_path, local_metadata, civitai_metadata, client)
@@ -668,8 +734,9 @@ class ModelRouteUtils:
return web.json_response({
"success": True,
"message": f"Model successfully re-linked to Civitai version {model_version_id}",
"hash": local_metadata['sha256']
"message": f"Model successfully re-linked to Civitai model {model_id}" +
(f" version {model_version_id}" if model_version_id else ""),
"hash": local_metadata.get('sha256', '')
})
finally:
@@ -678,3 +745,223 @@ class ModelRouteUtils:
except Exception as e:
logger.error(f"Error re-linking to CivitAI: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
@staticmethod
async def handle_verify_duplicates(request: web.Request, scanner) -> web.Response:
"""Handle verification of duplicate model hashes
Args:
request: The aiohttp request
scanner: The model scanner instance with cache management methods
Returns:
web.Response: The HTTP response with verification results
"""
try:
data = await request.json()
file_paths = data.get('file_paths', [])
if not file_paths:
return web.json_response({
'success': False,
'error': 'No file paths provided for verification'
}, status=400)
# Results tracking
results = {
'verified_as_duplicates': True, # Start true, set to false if any mismatch
'mismatched_files': [],
'new_hash_map': {}
}
# Get expected hash from the first file's metadata
expected_hash = None
first_metadata_path = os.path.splitext(file_paths[0])[0] + '.metadata.json'
first_metadata = await ModelRouteUtils.load_local_metadata(first_metadata_path)
if first_metadata and 'sha256' in first_metadata:
expected_hash = first_metadata['sha256'].lower()
# Process each file
for file_path in file_paths:
# Skip files that don't exist
if not os.path.exists(file_path):
continue
# Calculate actual hash
try:
from .file_utils import calculate_sha256
actual_hash = await calculate_sha256(file_path)
# Get metadata
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Compare hashes
stored_hash = metadata.get('sha256', '').lower()
# Set expected hash from first file if not yet set
if not expected_hash:
expected_hash = stored_hash
# Check if hash matches expected hash
if actual_hash != expected_hash:
results['verified_as_duplicates'] = False
results['mismatched_files'].append(file_path)
results['new_hash_map'][file_path] = actual_hash
# Check if stored hash needs updating
if actual_hash != stored_hash:
# Update metadata with actual hash
metadata['sha256'] = actual_hash
# Save updated metadata
await MetadataManager.save_metadata(file_path, metadata)
# Update cache
await scanner.update_single_model_cache(file_path, file_path, metadata)
except Exception as e:
logger.error(f"Error verifying hash for {file_path}: {e}")
results['mismatched_files'].append(file_path)
results['new_hash_map'][file_path] = "error_calculating_hash"
results['verified_as_duplicates'] = False
return web.json_response({
'success': True,
**results
})
except Exception as e:
logger.error(f"Error verifying duplicate models: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def handle_rename_model(request: web.Request, scanner) -> web.Response:
"""Handle renaming a model file and its associated files
Args:
request: The aiohttp request
scanner: The model scanner instance
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_path = data.get('file_path')
new_file_name = data.get('new_file_name')
if not file_path or not new_file_name:
return web.json_response({
'success': False,
'error': 'File path and new file name are required'
}, status=400)
# Validate the new file name (no path separators or invalid characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
if any(char in new_file_name for char in invalid_chars):
return web.json_response({
'success': False,
'error': 'Invalid characters in file name'
}, status=400)
# Get the directory and current file name
target_dir = os.path.dirname(file_path)
old_file_name = os.path.splitext(os.path.basename(file_path))[0]
# Check if the target file already exists
new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/')
if os.path.exists(new_file_path):
return web.json_response({
'success': False,
'error': 'A file with this name already exists'
}, status=400)
# Define the patterns for associated files
patterns = [
f"{old_file_name}.safetensors", # Required
f"{old_file_name}.metadata.json",
f"{old_file_name}.metadata.json.bak",
]
# Add all preview file extensions
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}")
# Find all matching files
existing_files = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
# Get the hash from the main file to update hash index
hash_value = None
metadata = None
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
if os.path.exists(metadata_path):
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
hash_value = metadata.get('sha256')
# Rename all files
renamed_files = []
new_metadata_path = None
for old_path, pattern in existing_files:
# Get the file extension like .safetensors or .metadata.json
ext = ModelRouteUtils.get_multipart_ext(pattern)
# Create the new path
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
# Rename the file
os.rename(old_path, new_path)
renamed_files.append(new_path)
# Keep track of metadata path for later update
if ext == '.metadata.json':
new_metadata_path = new_path
# Update the metadata file with new file name and paths
if new_metadata_path and metadata:
# Update file_name, file_path and preview_url in metadata
metadata['file_name'] = new_file_name
metadata['file_path'] = new_file_path
# Update preview_url if it exists
if 'preview_url' in metadata and metadata['preview_url']:
old_preview = metadata['preview_url']
ext = ModelRouteUtils.get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
metadata['preview_url'] = new_preview
# Save updated metadata
await MetadataManager.save_metadata(new_file_path, metadata)
# Update the scanner cache
if metadata:
await scanner.update_single_model_cache(file_path, new_file_path, metadata)
# Update recipe files and cache if hash is available and recipe_scanner exists
if hash_value and hasattr(scanner, 'update_lora_filename_by_hash'):
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
if recipe_scanner:
recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name)
logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed model")
return web.json_response({
'success': True,
'new_file_path': new_file_path,
'renamed_files': renamed_files,
'reload_required': False
})
except Exception as e:
logger.error(f"Error renaming model: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -142,7 +142,7 @@ def calculate_recipe_fingerprint(loras):
# Get the hash - use modelVersionId as fallback if hash is empty
hash_value = lora.get("hash", "").lower()
if not hash_value and lora.get("isDeleted", False) and lora.get("modelVersionId"):
hash_value = lora.get("modelVersionId")
hash_value = str(lora.get("modelVersionId"))
# Skip entries without a valid hash
if not hash_value:

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.8.17"
version = "0.8.19"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -1,11 +1,258 @@
{
"loras": "<lora:ck-neon-retrowave-IL-000012:0.8> <lora:aorunIllstrious:1> <lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"steps": "20",
"sampler": "euler_ancestral",
"cfg_scale": "8",
"seed": "241",
"size": "832x1216",
"clip_skip": "2"
"id": 649516,
"name": "Cynthia -シロナ - Pokemon Diamond and Pearl - PDXL LORA",
"description": "<p><strong>Warning: Without Adetailer eyes are fucked (rainbow color and artefact)</strong></p><p><span style=\"color:rgb(193, 194, 197)\">Trained on </span><a target=\"_blank\" rel=\"ugc\" href=\"https://civitai.com/models/257749/horsefucker-diffusion-v6-xl\"><strong>Pony Diffusion V6 XL</strong></a> with 63 pictures.<br />Best result with weight between : 0.8-1.</p><p><span style=\"color:rgb(193, 194, 197)\">Basic prompts : </span><code>1girl, cynthia \\(pokemon\\), blonde hair, hair over one eye, very long hair, grey eyes, eyelashes, hair ornament</code> <br /><span style=\"color:rgb(193, 194, 197)\">Outfit prompts : </span><code>fur collar, black coat, fur-trimmed coat, long sleeves, black pants, black shirt, high heels</code></p><p>Reviews are really appreciated, i love to see the community use my work, that's why I share it.<br />If you like my work, you can tip me <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/konan49773\"><strong>here.</strong></a></p><p>Got a specific request ? I'm open for commission on my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/konan49773/commissions\"><strong>kofi</strong></a> or<strong> </strong><a target=\"_blank\" rel=\"ugc\" href=\"https://www.fiverr.com/konanai/create-lora-model-for-you\"><strong>fiverr gig</strong></a> *! If you provide enough data, OCs are accepted</p>",
"allowNoCredit": true,
"allowCommercialUse": [
"Image",
"RentCivit"
],
"allowDerivatives": true,
"allowDifferentLicense": true,
"type": "LORA",
"minor": false,
"sfwOnly": false,
"poi": false,
"nsfw": false,
"nsfwLevel": 29,
"availability": "Public",
"cosmetic": null,
"supportsGeneration": true,
"stats": {
"downloadCount": 811,
"favoriteCount": 0,
"thumbsUpCount": 175,
"thumbsDownCount": 0,
"commentCount": 4,
"ratingCount": 0,
"rating": 0,
"tippedAmountCount": 10
},
"creator": {
"username": "Konan",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/7cd552a1-60fe-4baf-a0e4-f7d5d5381711/width=96/Konan.jpeg"
},
"tags": [
"anime",
"character",
"cynthia",
"woman",
"pokemon",
"pokegirl"
],
"modelVersions": [
{
"id": 726676,
"index": 0,
"name": "v1.0",
"baseModel": "Pony",
"createdAt": "2024-08-16T01:13:16.099Z",
"publishedAt": "2024-08-16T01:14:44.984Z",
"status": "Published",
"availability": "Public",
"nsfwLevel": 29,
"trainedWords": [
"1girl, cynthia \\(pokemon\\), blonde hair, hair over one eye, very long hair, grey eyes, eyelashes, hair ornament",
"fur collar, black coat, fur-trimmed coat, long sleeves, black pants, black shirt, high heels"
],
"covered": true,
"stats": {
"downloadCount": 811,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 175,
"thumbsDownCount": 0
},
"files": [
{
"id": 641092,
"sizeKB": 56079.65234375,
"name": "CynthiaXL.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2024-08-16T01:17:19.087Z",
"metadata": {
"format": "SafeTensor"
},
"hashes": {},
"downloadUrl": "https://civitai.com/api/download/models/726676",
"primary": true
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/b346d757-2b59-4aeb-9f09-3bee2724519d/width=1248/24511993.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UqNc==RP.9s+~pxvIst7kWWBWBjY%MWBt7WB",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/fc132ac0-cc1c-4b68-a1d7-5b97b0996ac2/width=1248/24511997.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UMGSS+?tTw.60MIX9cbb~WxHRRR-NEtLRiR%",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/7b3237d1-e672-466a-85d0-cc5dd42ab130/width=1160/24512001.jpeg",
"nsfwLevel": 4,
"width": 1160,
"height": 1696,
"hash": "U9NA6f~o00%h00wvIYt74:ER-=D%5600DiE1",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/ccd7d11d-4fa9-4434-85a1-fb999312e60d/width=1248/24511991.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UyNTg.j?~qxu?aoLRkj]%MfkM{jZaya}a#ax",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/1743be6d-7fe5-4b55-9f19-c931618fa259/width=1248/24511996.jpeg",
"nsfwLevel": 4,
"width": 1248,
"height": 1824,
"hash": "UGOC~n^+?w~6Tx_4oM^$yYEkMds74:9F#*xY",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/91693c98-d037-4489-882c-100eb26019a0/width=1160/24512010.jpeg",
"nsfwLevel": 4,
"width": 1160,
"height": 1696,
"hash": "UJI}kp^-Kl%hXAIX4;Nf^+M|9GRP0Mt8%L%2",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/49c7a294-ac5b-4832-98e5-2acd0f1a8782/width=1248/24512017.jpeg",
"nsfwLevel": 4,
"width": 1248,
"height": 1824,
"hash": "UML;8Qn|9G%3mnWA4nWFMf%N?Hae~qog-oNF",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/d7b442f2-6ead-4a7a-9578-54d9ec2ff148/width=1248/24512015.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UPGR#kt8xw%M0LWC9bWC?wxtR*NLM^jrxWM|",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/d840f1e9-3dd3-4531-b83a-1ba2c6b7feaa/width=1160/24512004.jpeg",
"nsfwLevel": 8,
"width": 1160,
"height": 1696,
"hash": "ULNm1i_39wi^*I%hDiM_tlo#xuV?^kNIxCs,",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/520387ae-c176-43e3-92bd-5cd2a672475e/width=1248/24512012.jpeg",
"nsfwLevel": 4,
"width": 1248,
"height": 1824,
"hash": "URM%l.%M.9Ip~poIkExu_3V@M|xuD%oJM{D*",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/9ea28b94-f326-4776-83ff-851cc203c627/width=1248/24511988.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "U-PZloog_Nxut6j]WXWB-;j?IVa#ofaxj]j]",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/2e749dbb-7d5a-48f1-8e29-fea5022a5fe9/width=1248/24522268.jpeg",
"nsfwLevel": 16,
"width": 1248,
"height": 1824,
"hash": "UPLgtm9Z0z=|0yRRE2-A9rWAoNE1~DwOr=t7",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/726676"
}
]
}

View File

@@ -1,3 +1,4 @@
from pathlib import Path
import os
import sys
import json
@@ -280,10 +281,14 @@ class StandaloneLoraManager(LoraManager):
# Display path with forward slashes for consistency
display_target = target_path.replace('\\', '/')
app.router.add_static(route_path, target_path)
logger.info(f"Added static route for link target {route_path} -> {display_target}")
config.add_route_mapping(target_path, route_path)
added_targets.add(norm_target)
try:
app.router.add_static(route_path, Path(target_path).resolve(strict=False))
logger.info(f"Added static route for link target {route_path} -> {display_target}")
config.add_route_mapping(target_path, route_path)
added_targets.add(norm_target)
except Exception as e:
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
continue
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)
@@ -296,13 +301,16 @@ class StandaloneLoraManager(LoraManager):
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
from py.routes.stats_routes import StatsRoutes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
stats_routes = StatsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
stats_routes.setup_routes(app)
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)

View File

@@ -29,6 +29,7 @@ html, body {
:root {
--bg-color: #ffffff;
--text-color: #333333;
--text-muted: #6c757d;
--card-bg: #ffffff;
--border-color: #e0e0e0;
@@ -39,6 +40,9 @@ html, body {
--lora-warning-l: 75%;
--lora-warning-c: 0.25;
--lora-warning-h: 80;
--lora-success-l: 70%;
--lora-success-c: 0.2;
--lora-success-h: 140;
/* Composed Colors */
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
@@ -47,6 +51,7 @@ html, body {
--lora-text: oklch(95% 0.02 256);
--lora-error: oklch(75% 0.32 29);
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Modified to be used with oklch() */
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h)); /* New green success color */
/* Spacing Scale */
--space-1: calc(8px * 1);
@@ -80,6 +85,7 @@ html[data-theme="light"] {
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--text-muted: #a0a0a0;
--card-bg: #2d2d2d;
--border-color: #404040;

View File

@@ -89,7 +89,7 @@
/* Smaller text for medium density */
.medium-density .model-name {
font-size: 0.95em;
max-height: 2.6em;
max-height: 3em; /* Increased from 2.6em */
}
.medium-density .base-model-label {
@@ -105,7 +105,7 @@
/* Smaller text for compact mode */
.compact-density .model-name {
font-size: 0.9em;
max-height: 2.4em;
max-height: 2.8em; /* Increased from 2.4em */
}
.compact-density .base-model-label {
@@ -167,6 +167,38 @@
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
/* NSFW warning adjustments for medium density */
.medium-density .nsfw-warning {
padding: calc(var(--space-2) * 0.85);
max-width: 70%;
}
.medium-density .nsfw-warning p {
font-size: 0.95em;
margin-bottom: calc(var(--space-1) * 0.85);
}
.medium-density .show-content-btn {
font-size: 0.85em;
padding: 3px calc(var(--space-1) * 0.85);
}
/* NSFW warning adjustments for compact density */
.compact-density .nsfw-warning {
padding: calc(var(--space-2) * 0.7);
max-width: 60%;
}
.compact-density .nsfw-warning p {
font-size: 0.85em;
margin-bottom: calc(var(--space-1) * 0.7);
}
.compact-density .show-content-btn {
font-size: 0.8em;
padding: 2px var(--space-1);
}
.toggle-blur-btn {
position: absolute;
left: var(--space-1);
@@ -220,6 +252,18 @@
z-index: 3;
}
/* New styles for hover reveal mode */
.hover-reveal .card-header,
.hover-reveal .card-footer {
opacity: 0;
transition: opacity 0.2s ease;
}
.hover-reveal .lora-card:hover .card-header,
.hover-reveal .lora-card:hover .card-footer {
opacity: 1;
}
.card-footer {
position: absolute;
bottom: 0;
@@ -331,21 +375,24 @@
text-decoration: none;
}
/* Updated model name to fix text cutoff issues */
.model-name {
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
font-size: 0.95em;
word-break: break-word;
display: block;
max-height: 2.8em;
max-height: 3em; /* Increased to ensure two full lines */
overflow: hidden;
/* Add line height for consistency */
line-height: 1.4;
}
.model-info {
flex: 1;
min-width: 0;
overflow: hidden;
padding-bottom: 4px;
padding-bottom: 6px; /* Increased from 4px to give more room for text */
}
.base-model {
@@ -396,30 +443,6 @@
user-select: none;
}
/* Recipe specific elements - migrated from recipe-card.css */
.recipe-indicator {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
background: var(--lora-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
z-index: 2;
}
.base-model-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-left: 32px; /* For accommodating the recipe indicator */
}
.lora-count {
display: flex;
align-items: center;
@@ -485,4 +508,44 @@
.card-grid.virtual-scroll {
max-width: 2400px;
}
}
}
/* Add after the existing .lora-card:hover styles */
@keyframes update-pulse {
0% { box-shadow: 0 0 0 0 var(--lora-accent-transparent); }
50% { box-shadow: 0 0 0 4px var(--lora-accent-transparent); }
100% { box-shadow: 0 0 0 0 var(--lora-accent-transparent); }
}
/* Add semi-transparent version of accent color for animation */
:root {
--lora-accent-transparent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.6);
}
.lora-card.updated {
animation: update-pulse 1.2s ease-out;
}
/* Add a subtle updated tag that fades in and out */
.update-indicator {
position: absolute;
top: 8px;
right: 8px;
background: var(--lora-accent);
color: white;
border-radius: var(--border-radius-xs);
padding: 3px 6px;
font-size: 0.75em;
opacity: 0;
transform: translateY(-5px);
z-index: 4;
animation: update-tag 1.8s ease-out forwards;
}
@keyframes update-tag {
0% { opacity: 0; transform: translateY(-5px); }
15% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(0); }
}

View File

@@ -95,7 +95,7 @@
flex: 1;
}
.version-info {
.version-content .version-info {
display: flex;
flex-wrap: wrap;
flex-direction: row !important;
@@ -104,7 +104,7 @@
font-size: 0.9em;
}
.version-info .base-model {
.version-content .version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;

View File

@@ -315,6 +315,7 @@
margin-bottom: 4px;
display: flex;
flex-wrap: wrap;
word-break: break-all; /* Ensure long hashes wrap properly */
}
.model-tooltip .tooltip-info div strong {
@@ -322,6 +323,128 @@
min-width: 70px;
}
/* Latest indicator */
.hash-mismatch-info {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
font-weight: bold;
word-break: break-all; /* Ensure long hashes wrap properly */
}
/* Verification Badge Styles */
.verification-badge {
display: inline-flex;
align-items: center;
margin-left: 8px;
padding: 2px 6px;
font-size: 0.8em;
border-radius: var(--border-radius-xs);
font-weight: normal;
}
.verification-badge.metadata {
background-color: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
}
.verification-badge.verified {
background-color: oklch(70% 0.2 140); /* Green for verified */
color: white;
}
.verification-badge.mismatch {
background-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
color: white;
}
.verification-badge i {
margin-right: 4px;
}
/* Hash Mismatch Styling */
.lora-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 {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05),
oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h) / 0.05) 10px,
transparent 10px,
transparent 20px
);
z-index: 1;
pointer-events: none;
}
.lora-card.duplicate.hash-mismatch .card-preview {
filter: grayscale(20%);
}
/* Mismatch Badge */
.mismatch-badge {
position: absolute;
top: 10px;
left: 10px; /* Changed from right:10px to left:10px */
background: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
color: white;
font-size: 12px;
padding: 3px 8px;
border-radius: var(--border-radius-xs);
z-index: 5;
}
/* Disabled checkbox style */
.lora-card.duplicate.hash-mismatch .selector-checkbox {
opacity: 0.5;
cursor: not-allowed;
}
/* Hash mismatch info in tooltip */
.hash-mismatch-info {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
font-weight: bold;
}
/* Verify hash button styling */
.btn-verify-hashes {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-verify-hashes:hover {
background: var(--bg-color);
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
transform: translateY(-1px);
}
.btn-verify-hashes i {
font-size: 0.9em;
}
/* Badge Styles */
.badge {
display: inline-flex;

View File

@@ -79,6 +79,50 @@
flex: 1;
max-width: 400px;
margin: 0 1rem;
transition: opacity 0.2s ease;
}
/* Disabled state for header search */
.header-search.disabled {
opacity: 0.5;
pointer-events: none;
}
.header-search.disabled input {
background-color: var(--input-disabled-bg, #f5f5f5);
color: var(--text-muted);
cursor: not-allowed;
}
.header-search.disabled button {
background-color: var(--button-disabled-bg, #e0e0e0);
color: var(--text-muted);
cursor: not-allowed;
}
.header-search.disabled .search-icon {
color: var(--text-muted);
}
/* Dark theme specific styles for disabled header search */
[data-theme="dark"] .header-search.disabled input {
background-color: #3a3a3a;
color: #888888;
border-color: #555555;
}
[data-theme="dark"] .header-search.disabled button {
background-color: #3a3a3a;
color: #888888;
border-color: #555555;
}
[data-theme="dark"] .header-search.disabled .search-icon {
color: #888888;
}
[data-theme="dark"] .header-search.disabled .fas {
color: #888888;
}
/* Header controls (formerly corner controls) */
@@ -115,7 +159,8 @@
}
.theme-toggle .light-icon,
.theme-toggle .dark-icon {
.theme-toggle .dark-icon,
.theme-toggle .auto-icon {
position: absolute;
top: 50%;
left: 50%;
@@ -124,15 +169,38 @@
transition: opacity 0.3s ease;
}
/* Default state shows dark icon */
.theme-toggle .dark-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .light-icon {
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .dark-icon {
.theme-toggle.theme-light .dark-icon,
.theme-toggle.theme-light .auto-icon {
opacity: 0;
}
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon {
opacity: 1;
}
.theme-toggle.theme-dark .light-icon,
.theme-toggle.theme-dark .auto-icon {
opacity: 0;
}
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon {
opacity: 1;
}
.theme-toggle.theme-auto .light-icon,
.theme-toggle.theme-auto .dark-icon {
opacity: 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
/* Model Description Styling */
.model-description-container {
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
min-height: 200px;
position: relative;
/* Remove the max-height and overflow-y to allow content to expand naturally */
}
.model-description-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
font-size: 0.9em;
}
.model-description-loading .fa-spinner {
margin-right: var(--space-1);
}
.model-description-content {
padding: var(--space-2);
line-height: 1.5;
overflow-wrap: break-word;
font-size: 0.95em;
}
.model-description-content h1,
.model-description-content h2,
.model-description-content h3,
.model-description-content h4,
.model-description-content h5,
.model-description-content h6 {
margin-top: 1em;
margin-bottom: 0.5em;
font-weight: 600;
}
.model-description-content p {
margin-bottom: 1em;
}
.model-description-content img {
max-width: 100%;
height: auto;
border-radius: var(--border-radius-xs);
display: block;
margin: 1em 0;
}
.model-description-content pre {
background: rgba(0, 0, 0, 0.05);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
white-space: pre-wrap;
margin: 1em 0;
overflow-x: auto;
}
.model-description-content code {
font-family: monospace;
font-size: 0.9em;
background: rgba(0, 0, 0, 0.05);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.model-description-content pre code {
background: transparent;
padding: 0;
}
.model-description-content ul,
.model-description-content ol {
margin-left: 1.5em;
margin-bottom: 1em;
}
.model-description-content li {
margin-bottom: 0.5em;
}
.model-description-content blockquote {
border-left: 3px solid var (--lora-accent);
padding-left: 1em;
margin-left: 0;
margin-right: 0;
font-style: italic;
opacity: 0.8;
}
/* Adjust dark mode for model description */
[data-theme="dark"] .model-description-content pre,
[data-theme="dark"] .model-description-content code {
background: rgba(255, 255, 255, 0.05);
}

View File

@@ -0,0 +1,489 @@
/* Lora Modal Header */
.modal-header {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.info-item {
padding: var(--space-2);
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
}
/* 调整深色主题下的样式 */
[data-theme="dark"] .info-item {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
display: block;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 4px;
}
.info-item span {
color: var(--text-color);
word-break: break-word;
}
.info-item.usage-tips,
.info-item.notes {
grid-column: 1 / -1 !important; /* Make notes section full width */
}
/* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] {
min-height: 60px; /* Increase height for multiple lines */
max-height: 150px; /* Limit maximum height */
overflow-y: auto; /* Add scrolling for long content */
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */
padding: 8px 12px; /* Slightly increase padding */
}
.file-path {
font-family: monospace;
font-size: 0.9em;
}
.description-text {
line-height: 1.5;
max-height: 100px;
overflow-y: auto;
}
/* Editable Fields */
.editable-field {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
}
.editable-field [contenteditable] {
flex: 1;
min-height: 24px;
padding: 4px 8px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
line-height: 1.4;
color: var(--text-color);
transition: border-color 0.2s;
word-break: break-word;
}
.editable-field [contenteditable]:focus {
outline: none;
border-color: var(--lora-accent);
background: var(--bg-color);
}
.editable-field [contenteditable]:empty::before {
content: attr(data-placeholder);
color: var(--text-color);
opacity: 0.5;
}
.notes-hint {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-left: 5px;
cursor: help;
position: relative; /* Add positioning context */
}
@media (max-width: 640px) {
.info-item.usage-tips,
.info-item.notes {
grid-column: 1 / -1;
}
}
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
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;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.modal-content .back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.modal-content .back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* File name copy styles */
.file-name-wrapper {
display: flex;
align-items: center;
gap: 8px;
padding: 4px;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
position: relative;
}
.file-name-content {
padding: 2px 4px;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
flex: 1;
}
.file-name-wrapper.editing .file-name-content {
border: 1px solid var(--lora-accent);
background: var(--bg-color);
outline: none;
}
.edit-file-name-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-file-name-btn.visible,
.file-name-wrapper:hover .edit-file-name-btn {
opacity: 0.5;
}
.edit-file-name-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-file-name-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Base Model and Size combined styles */
.info-item.base-size {
display: flex;
gap: var(--space-3);
}
.base-wrapper {
flex: 2; /* 分配更多空间给base model */
}
/* Base model display and editing styles */
.base-model-display {
display: flex;
align-items: center;
position: relative;
}
.base-model-content {
padding: 2px 4px;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
color: var(--text-color);
flex: 1;
}
.edit-base-model-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-base-model-btn.visible,
.base-model-display:hover .edit-base-model-btn {
opacity: 0.5;
}
.edit-base-model-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-base-model-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.base-model-selector {
width: 100%;
padding: 3px 5px;
background: var(--bg-color);
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs);
color: var(--text-color);
font-size: 0.9em;
outline: none;
margin-right: var(--space-1);
}
.size-wrapper {
flex: 1;
border-left: 1px solid var(--lora-border);
padding-left: var(--space-3);
}
.base-wrapper label,
.size-wrapper label {
display: block;
margin-bottom: 4px;
}
.size-wrapper span {
font-family: monospace;
font-size: 0.9em;
opacity: 0.9;
}
/* New Model Name Header Styles */
.model-name-header {
display: flex;
align-items: center;
width: calc(100% - 40px); /* Avoid overlap with close button */
position: relative;
}
.model-name-content {
margin: 0;
padding: var(--space-1);
border-radius: var(--border-radius-xs);
font-size: 1.5em !important;
font-weight: 600;
line-height: 1.2;
color: var(--text-color);
border: 1px solid transparent;
outline: none;
flex: 1;
}
.model-name-content:focus {
border: 1px solid var(--lora-accent);
background: var(--bg-color);
}
.edit-model-name-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-model-name-btn.visible,
.model-name-header:hover .edit-model-name-btn {
opacity: 0.5;
}
.edit-model-name-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-model-name-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Tab System Styling */
.showcase-tabs {
display: flex;
border-bottom: 1px solid var(--lora-border);
margin-bottom: var(--space-2);
position: relative;
z-index: 2;
}
.tab-btn {
padding: var(--space-1) var(--space-2);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color);
cursor: pointer;
font-size: 0.95em;
transition: all 0.2s;
opacity: 0.7;
position: relative;
}
.tab-btn:hover {
opacity: 1;
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
}
.tab-btn.active {
border-bottom: 2px solid var(--lora-accent);
opacity: 1;
font-weight: 600;
}
.tab-content {
position: relative;
min-height: 100px;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
.view-all-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
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: 13px;
}
.view-all-btn:hover {
opacity: 0.9;
}
/* Loading, error and empty states */
.recipes-loading,
.recipes-error,
.recipes-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
min-height: 200px;
}
.recipes-loading i,
.recipes-error i,
.recipes-empty i {
font-size: 32px;
margin-bottom: 15px;
color: var(--lora-accent);
}
.recipes-error i {
color: var(--lora-error);
}
/* Creator Information Styles */
.creator-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: var(--space-1);
padding: 6px 10px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
max-width: fit-content;
}
[data-theme="dark"] .creator-info {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.creator-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
}
.creator-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-placeholder {
background: var(--lora-accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.creator-username {
font-size: 0.9em;
font-weight: 500;
color: var(--text-color);
}
/* Optional: add hover effect for creator info */
.creator-info:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}

View File

@@ -0,0 +1,68 @@
/* Update Preset Controls styles */
.preset-controls {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.preset-controls select,
.preset-controls input {
padding: var(--space-1);
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
color: var(--text-color);
}
.preset-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.preset-tag {
display: flex;
align-items: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: calc(var(--space-1) * 0.5) var(--space-1);
gap: var(--space-1);
transition: all 0.2s ease;
}
.preset-tag span {
color: var(--lora-accent);
font-size: 0.9em;
}
.preset-tag i {
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-tag:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}
.preset-tag i:hover {
color: var(--lora-error);
opacity: 1;
}
.add-preset-btn {
padding: calc(var(--space-1) * 0.5) var(--space-2);
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: opacity 0.2s;
}
.add-preset-btn:hover {
opacity: 0.9;
}

View File

@@ -0,0 +1,478 @@
/* Showcase Section */
.showcase-section {
position: relative;
margin-top: var(--space-4);
}
.carousel {
transition: max-height 0.3s ease-in-out;
overflow: hidden;
}
.carousel.collapsed {
max-height: 0;
}
.carousel-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.media-wrapper {
position: relative;
width: 100%;
background: var(--lora-surface);
margin-bottom: var(--space-2);
overflow: hidden; /* Ensure metadata panel is contained */
}
.media-wrapper:last-child {
margin-bottom: 0;
}
.media-wrapper img,
.media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.no-examples {
text-align: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
}
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Add styles for blurred showcase content */
.nsfw-media-wrapper {
position: relative;
}
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
/* Position the toggle button at the top left of showcase media */
.showcase-toggle-btn {
position: absolute;
z-index: 3;
}
/* Add styles for showcase media controls */
.media-controls {
position: absolute;
display: flex;
gap: 6px;
z-index: 4;
opacity: 0;
transform: translateY(-5px);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.media-controls.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.media-control-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-color);
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;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
padding: 0;
position: relative;
overflow: hidden;
}
.media-control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.2);
}
.media-control-btn.set-preview-btn:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.media-control-btn.example-delete-btn:hover:not(.disabled) {
background: var(--lora-error);
color: white;
border-color: var(--lora-error);
}
/* Disabled state for delete button */
.media-control-btn.example-delete-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Two-step confirmation for delete button */
.media-control-btn.example-delete-btn .confirm-icon {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--lora-error);
color: white;
font-size: 1em;
opacity: 0;
transition: opacity 0.2s ease;
}
.media-control-btn.example-delete-btn.confirm .fa-trash-alt {
opacity: 0;
}
.media-control-btn.example-delete-btn.confirm .confirm-icon {
opacity: 1;
}
.media-control-btn.example-delete-btn.confirm {
background: var(--lora-error);
color: white;
border-color: var(--lora-error);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7);
}
70% {
box-shadow: 0 0 0 5px rgba(220, 53, 69, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0);
}
}
/* Image Metadata Panel Styles */
.image-metadata-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
padding: var(--space-2);
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
z-index: 5;
max-height: 50%; /* Reduced to take less space */
overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
opacity: 0;
pointer-events: none;
}
/* Show metadata panel only when the 'visible' class is added */
.media-wrapper .image-metadata-panel.visible {
transform: translateY(0);
opacity: 0.98;
pointer-events: auto;
}
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel {
background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
}
.metadata-content {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Styling for parameters tags */
.params-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: var(--space-1);
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--lora-border);
}
.param-tag {
display: inline-flex;
align-items: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 2px 6px;
font-size: 0.8em;
line-height: 1.2;
white-space: nowrap;
}
.param-tag .param-name {
font-weight: 600;
color: var(--text-color);
margin-right: 4px;
opacity: 0.8;
}
.param-tag .param-value {
color: var(--lora-accent);
}
/* Special styling for prompt row */
.metadata-row.prompt-row {
flex-direction: column;
padding-top: 0;
}
.metadata-row.prompt-row + .metadata-row.prompt-row {
margin-top: var(--space-2);
}
.metadata-label {
font-weight: 600;
color: var(--text-color);
opacity: 0.8;
font-size: 0.85em;
display: block;
margin-bottom: 4px;
}
.metadata-prompt-wrapper {
position: relative;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px;
margin-top: 2px;
max-height: 80px; /* Reduced from 120px */
overflow-y: auto;
word-break: break-word;
width: 100%;
box-sizing: border-box;
}
.metadata-prompt {
color: var(--text-color);
font-family: monospace;
font-size: 0.85em;
white-space: pre-wrap;
}
.copy-prompt-btn {
position: absolute;
top: 6px;
right: 6px;
background: transparent;
border: none;
color: var(--text-color);
opacity: 0.6;
cursor: pointer;
padding: 3px;
transition: all 0.2s ease;
}
.copy-prompt-btn:hover {
opacity: 1;
color: var(--lora-accent);
}
/* Scrollbar styling for metadata panel */
.image-metadata-panel::-webkit-scrollbar {
width: 6px;
}
.image-metadata-panel::-webkit-scrollbar-track {
background: transparent;
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* For Firefox */
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* No metadata message styling */
.no-metadata-message {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
color: var(--text-color);
opacity: 0.7;
text-align: center;
font-style: italic;
gap: 8px;
}
.no-metadata-message i {
font-size: 1.1em;
color: var(--lora-accent);
opacity: 0.8;
}
/* Scroll Indicator */
.scroll-indicator {
cursor: pointer;
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em;
color: var(--text-color);
}
.lazy {
opacity: 0;
transition: opacity 0.3s;
}
.lazy[src] {
opacity: 1;
}
/* Example Import Area */
.example-import-area {
margin-top: var(--space-4);
padding: var(--space-2);
}
.example-import-area.empty {
margin-top: var(--space-2);
padding: var(--space-4) var(--space-2);
}
.import-container {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-4);
text-align: center;
transition: all 0.3s ease;
background: var(--lora-surface);
cursor: pointer;
}
.import-container.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: scale(1.01);
}
.import-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding-top: var(--space-1);
}
.import-placeholder i {
font-size: 2.5rem;
/* color: var(--lora-accent); */
opacity: 0.8;
margin-bottom: var(--space-1);
}
.import-placeholder h3 {
margin: 0 0 var(--space-1);
font-size: 1.2rem;
font-weight: 500;
color: var(--text-color);
}
.import-placeholder p {
margin: var(--space-1) 0;
color: var(--text-color);
opacity: 0.8;
}
.import-placeholder .sub-text {
font-size: 0.9em;
opacity: 0.6;
margin: var(--space-1) 0;
}
.import-formats {
font-size: 0.8em !important;
opacity: 0.6 !important;
margin-top: var(--space-2) !important;
}
.select-files-btn {
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* For dark theme */
[data-theme="dark"] .import-container {
background: rgba(255, 255, 255, 0.03);
}

View File

@@ -0,0 +1,148 @@
/* Model Tags styles */
.model-tags {
display: none;
}
.model-tag {
display: none;
}
/* Updated Model Tags styles - improved visibility in light theme */
.model-tags-container {
position: relative;
}
.model-tags-compact {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
}
.model-tag-compact {
/* Updated styles to match info-item appearance */
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
}
/* Style for empty tags placeholder */
.model-tag-empty {
background: rgba(0, 0, 0, 0.02);
border: 1px dashed rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
opacity: 0.7;
font-style: italic;
}
/* Adjust dark theme tag styles */
[data-theme="dark"] .model-tag-compact {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
/* Dark theme for empty tags */
[data-theme="dark"] .model-tag-empty {
background: rgba(255, 255, 255, 0.02);
border: 1px dashed var(--lora-border);
}
.model-tag-more {
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
}
.model-tags-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all 0.2s ease;
pointer-events: none;
}
.model-tags-tooltip.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.tooltip-content {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.tooltip-tag {
/* Updated styles to match info-item appearance */
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 3px 8px;
font-size: 0.75em;
color: var(--text-color);
}
/* Adjust dark theme tooltip tag styles */
[data-theme="dark"] .tooltip-tag {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
/* Model Tags Edit Mode */
.model-tags-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.edit-tags-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-tags-btn.visible,
.model-tags-container:hover .edit-tags-btn {
opacity: 0.5;
}
/* Edit mode active state */
.model-tags-container.edit-mode {
width: 100%;
display: block;
flex-basis: 100%;
grid-column: 1 / -1;
}

View File

@@ -0,0 +1,112 @@
/* Update Trigger Words styles */
.info-item.trigger-words {
padding: var(--space-2);
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
}
/* 调整 trigger words 样式 */
[data-theme="dark"] .info-item.trigger-words {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
/* New header style for trigger words */
.trigger-words-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.trigger-words-content {
margin-bottom: var(--space-1);
}
.trigger-words-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
/* No trigger words message */
.no-trigger-words {
color: var(--text-color);
opacity: 0.7;
font-style: italic;
font-size: 0.9em;
}
/* Trigger word tags in display mode */
.trigger-word-tag {
display: inline-flex;
align-items: center;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 8px;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
position: relative;
}
.trigger-word-content {
color: var(--lora-accent) !important;
font-size: 0.85em;
line-height: 1.4;
word-break: break-word;
}
.trigger-word-tag:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}
.trigger-word-copy {
display: flex;
align-items: center;
color: var(--text-color);
opacity: 0.5;
flex-shrink: 0;
transition: opacity 0.2s;
}
.trained-word-freq {
color: var(--text-color);
font-size: 0.75em;
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
min-width: 20px;
padding: 1px 5px;
text-align: center;
line-height: 1.2;
}
[data-theme="dark"] .trained-word-freq {
background: rgba(255, 255, 255, 0.05);
}
/* Class tokens styling */
.class-tokens-container {
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.class-token-item {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1) !important;
border: 1px solid var(--lora-accent) !important;
}
.token-badge {
background: var(--lora-accent);
color: white;
font-size: 0.7em;
padding: 2px 5px;
border-radius: 8px;
white-space: nowrap;
}

View File

@@ -116,4 +116,105 @@
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Node Selector */
.node-selector {
position: fixed;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 0;
min-width: 200px;
max-width: 350px;
max-height: 400px;
overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: none;
backdrop-filter: blur(10px);
}
.node-item {
padding: 10px 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-color);
background: var(--lora-surface);
transition: background-color 0.2s;
border-bottom: 1px solid var(--border-color);
}
.node-item:last-child {
border-bottom: none;
}
.node-item:hover {
background-color: var(--lora-accent);
color: var(--lora-text);
}
.node-icon-indicator {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.node-icon-indicator i {
color: white;
font-size: 12px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.node-icon-indicator.all-nodes {
background: linear-gradient(45deg, #4a90e2, #357abd);
}
/* Remove old node-color-indicator styles */
.node-color-indicator {
display: none;
}
.send-all-item {
border-top: 1px solid var(--border-color);
font-weight: 500;
background: var(--card-bg);
}
.send-all-item:hover {
background-color: var(--lora-accent);
color: var(--lora-text);
}
.send-all-item i {
width: 16px;
text-align: center;
}
/* Node Selector Header */
.node-selector-header {
padding: 10px 15px;
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
display: flex;
flex-direction: column;
gap: 4px;
}
.selector-action-type {
font-weight: 600;
font-size: 14px;
color: var(--lora-accent);
}
.selector-instruction {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}

View File

@@ -306,18 +306,6 @@ body.modal-open {
width: 100%; /* Full width */
}
/* Migrate control styling */
.migrate-control {
display: flex;
align-items: center;
gap: 8px;
}
.migrate-control input {
flex: 1;
min-width: 0;
}
/* 统一各个 section 的样式 */
.support-section,
.changelog-section,
@@ -375,12 +363,6 @@ body.modal-open {
background: rgba(255, 255, 255, 0.05);
}
/* Add disabled style for setting items */
.setting-item[data-requires-centralized="true"].disabled {
opacity: 0.6;
pointer-events: none;
}
/* Control row with label and input together */
.setting-row {
display: flex;
@@ -783,30 +765,6 @@ input:checked + .toggle-slider:before {
display: block;
}
/* Video embed styles */
.video-embed {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
max-width: 100%;
margin-bottom: var(--space-2);
border-radius: var(--border-radius-sm);
}
.video-embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.video-embed.small {
max-width: 100%;
margin-bottom: var(--space-1);
}
.help-text {
margin: var(--space-2) 0;
}
@@ -972,4 +930,155 @@ input:checked + .toggle-slider:before {
[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);
}

View File

@@ -0,0 +1,321 @@
/* Common Metadata Edit UI Components */
/* Used by both tag editing and trigger words editing interfaces */
/* Edit Button */
.metadata-edit-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
}
.metadata-edit-btn:hover {
opacity: 0.8;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .metadata-edit-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Edit mode active state */
.edit-mode .metadata-edit-btn {
opacity: 0.8;
color: var(--lora-accent);
}
/* Edit Container */
.metadata-edit-container {
padding: var(--space-2);
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
margin-top: var(--space-2);
width: 100%;
box-sizing: border-box;
position: relative;
display: block;
}
[data-theme="dark"] .metadata-edit-container {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
/* Edit Header */
.metadata-edit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--lora-border);
width: 100%;
}
/* Style for the edit button when positioned in the header */
.metadata-header-btn {
display: inline-flex !important;
opacity: 0.8 !important;
color: var(--lora-accent) !important;
margin-left: auto;
}
/* Edit Content */
.metadata-edit-content {
margin-bottom: var(--space-1);
width: 100%;
display: block;
}
/* Items Container */
.metadata-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
margin-bottom: var(--space-2);
width: 100%;
}
/* Individual Item */
.metadata-item {
display: inline-flex;
align-items: center;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 8px;
position: relative;
}
.metadata-item-content {
color: var(--lora-accent) !important;
font-size: 0.85em;
line-height: 1.4;
word-break: break-word;
}
/* Delete Button */
.metadata-delete-btn {
position: absolute;
top: -5px;
right: -5px;
width: 16px;
height: 16px;
background: var(--lora-error);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
transition: transform 0.2s ease;
}
.metadata-delete-btn:hover {
transform: scale(1.1);
}
/* Edit Controls */
.metadata-edit-controls {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-2);
margin-bottom: var(--space-2);
}
.metadata-edit-controls button {
padding: 3px 8px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
}
.metadata-edit-controls button:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}
.metadata-save-btn,
.save-tags-btn {
background: var(--lora-accent) !important;
color: white !important;
border-color: var(--lora-accent) !important;
}
.metadata-save-btn:hover,
.save-tags-btn:hover {
opacity: 0.9;
}
/* Add Form */
.metadata-add-form {
display: flex;
gap: var(--space-1);
position: relative;
width: 100%;
}
.metadata-input {
flex: 1;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9em;
}
.metadata-input:focus {
border-color: var(--lora-accent);
outline: none;
}
/* Suggestions Dropdown */
.metadata-suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
margin-top: 4px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.metadata-suggestions-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.metadata-suggestions-header span {
font-size: 0.9em;
font-weight: 500;
color: var(--text-color);
}
.metadata-suggestions-header small {
font-size: 0.8em;
opacity: 0.7;
}
.metadata-suggestions-container {
max-height: 200px;
overflow-y: auto;
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.metadata-suggestion-item {
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
max-width: 150px;
}
.metadata-suggestion-item:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}
.metadata-suggestion-item.already-added {
opacity: 0.7;
cursor: default;
}
.metadata-suggestion-item.already-added:hover {
background: var(--lora-surface);
border-color: var(--lora-border);
}
.metadata-suggestion-text {
color: var(--lora-accent) !important;
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 4px;
max-width: 100px;
}
.metadata-suggestion-meta {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.added-indicator {
color: var(--lora-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75em;
}
/* No suggestions message */
.no-suggestions {
padding: 16px 12px;
text-align: center;
color: var(--text-color);
opacity: 0.7;
font-style: italic;
font-size: 0.9em;
}
/* Loading indicator */
.metadata-loading {
display: flex;
align-items: center;
justify-content: center;
margin: var(--space-1) 0;
color: var(--text-color);
opacity: 0.7;
font-size: 0.9em;
gap: 8px;
}
.metadata-loading i {
color: var(--lora-accent);
}
/* Dropdown separator */
.dropdown-separator {
height: 1px;
background: var(--lora-border);
margin: 5px 10px;
}

View File

@@ -0,0 +1,520 @@
/* Statistics Page Styles */
.metrics-panel {
margin-bottom: var(--space-3);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.metric-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: var(--space-2);
text-align: center;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.metric-card .metric-icon {
font-size: 2rem;
color: var(--lora-accent);
margin-bottom: var(--space-1);
}
.metric-card .metric-value {
font-size: 1.8rem;
font-weight: bold;
color: var(--text-color);
margin-bottom: 4px;
}
.metric-card .metric-label {
font-size: 0.9rem;
color: oklch(var(--text-color) / 0.7);
}
.metric-card .metric-change {
font-size: 0.8rem;
margin-top: 4px;
}
.metric-change.positive {
color: var(--lora-success);
}
.metric-change.negative {
color: var(--lora-error);
}
/* Dashboard Content */
.dashboard-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
overflow: hidden;
}
.dashboard-tabs {
display: flex;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
.tab-button {
background: none;
border: none;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: all 0.3s ease;
color: var(--text-color);
border-bottom: 3px solid transparent;
white-space: nowrap;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 8px;
}
.tab-button:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.tab-button.active {
color: var(--lora-accent);
border-bottom-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.tab-content {
padding: var(--space-3);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Panel Grid Layout */
.panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: var(--space-3);
align-items: start;
}
.panel-grid .full-width {
grid-column: 1 / -1;
}
/* Chart Containers */
.chart-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
min-height: 300px;
}
.chart-container h3 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.chart-container h3 i {
color: var(--lora-accent);
}
.chart-wrapper {
position: relative;
height: 250px;
}
.chart-wrapper canvas {
max-height: 100%;
}
/* List Containers */
.list-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
min-height: 300px;
}
.list-container h3 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.list-container h3 i {
color: var(--lora-accent);
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
}
.model-item:hover {
border-color: var(--lora-accent);
transform: translateX(2px);
}
.model-item .model-preview {
width: 40px;
height: 40px;
border-radius: var(--border-radius-xs);
margin-right: 12px;
object-fit: cover;
background: var(--border-color);
}
.model-item .model-info {
flex: 1;
min-width: 0;
}
.model-item .model-name {
font-weight: 600;
text-shadow: none;
color: var(--text-color);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-item .model-meta {
font-size: 0.8rem;
color: oklch(var(--text-color) / 0.7);
margin-top: 2px;
}
.model-item .model-usage {
text-align: right;
color: var(--lora-accent);
font-weight: 600;
font-size: 0.9rem;
}
/* Tag Cloud */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: var(--space-2) 0;
max-height: 250px;
overflow-y: auto;
}
.tag-cloud-item {
padding: 4px 8px;
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
border-radius: var(--border-radius-xs);
font-size: 0.8rem;
border: 1px solid oklch(var(--lora-accent) / 0.2);
transition: all 0.2s ease;
cursor: pointer;
}
.tag-cloud-item:hover {
background: oklch(var(--lora-accent) / 0.2);
transform: scale(1.05);
}
.tag-cloud-item.size-1 { font-size: 0.7rem; }
.tag-cloud-item.size-2 { font-size: 0.8rem; }
.tag-cloud-item.size-3 { font-size: 0.9rem; }
.tag-cloud-item.size-4 { font-size: 1.0rem; }
.tag-cloud-item.size-5 { font-size: 1.1rem; font-weight: 600; }
/* Analysis Cards */
.analysis-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
}
.analysis-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-2);
text-align: center;
}
.analysis-card .card-icon {
font-size: 1.5rem;
color: var(--lora-accent);
margin-bottom: 8px;
}
.analysis-card .card-value {
font-size: 1.4rem;
font-weight: bold;
color: var(--text-color);
margin-bottom: 4px;
}
.analysis-card .card-label {
font-size: 0.85rem;
color: oklch(var(--text-color) / 0.7);
}
/* Insights */
.insights-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.insights-container h3 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.2rem;
display: flex;
align-items: center;
gap: 8px;
}
.insights-container h3 i {
color: var(--lora-accent);
}
.insights-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.insight-card {
padding: var(--space-2);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.insight-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.insight-card.type-success {
border-left: 4px solid var(--lora-success);
background: oklch(var(--lora-success) / 0.05);
}
.insight-card.type-warning {
border-left: 4px solid var(--lora-warning);
background: oklch(var(--lora-warning) / 0.05);
}
.insight-card.type-info {
border-left: 4px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.insight-card.type-error {
border-left: 4px solid var(--lora-error);
background: oklch(var(--lora-error) / 0.05);
}
.insight-title {
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
font-size: 1rem;
}
.insight-description {
color: oklch(var(--text-color) / 0.8);
margin-bottom: 8px;
font-size: 0.9rem;
line-height: 1.4;
}
.insight-suggestion {
color: oklch(var(--text-color) / 0.7);
font-size: 0.85rem;
font-style: italic;
}
/* Recommendations Section */
.recommendations-section {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
.recommendations-section h4 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.recommendations-section h4 i {
color: var(--lora-accent);
}
.recommendations-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recommendation-item {
padding: 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
}
.recommendation-item:hover {
border-color: var(--lora-accent);
}
.recommendation-title {
font-weight: 600;
color: var(--text-color);
margin-bottom: 6px;
font-size: 0.9rem;
}
.recommendation-description {
color: oklch(var(--text-color) / 0.8);
font-size: 0.85rem;
line-height: 1.4;
}
/* Loading States */
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: oklch(var(--text-color) / 0.6);
font-size: 0.9rem;
}
.loading-placeholder i {
margin-right: 8px;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Responsive Design */
@media (max-width: 1200px) {
.panel-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
}
@media (max-width: 768px) {
.dashboard-tabs {
flex-wrap: wrap;
}
.tab-button {
flex: 1;
min-width: 0;
font-size: 0.8rem;
padding: 12px 8px;
}
.tab-button i {
display: none;
}
.tab-content {
padding: var(--space-2);
}
.metrics-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-1);
}
.metric-card {
padding: var(--space-1);
}
.metric-card .metric-icon {
font-size: 1.5rem;
}
.metric-card .metric-value {
font-size: 1.4rem;
}
.chart-wrapper {
height: 200px;
}
.model-item .model-preview {
width: 32px;
height: 32px;
}
}
/* Dark mode adjustments */
[data-theme="dark"] .chart-container,
[data-theme="dark"] .list-container,
[data-theme="dark"] .insights-container {
border-color: oklch(var(--border-color) / 0.3);
}
[data-theme="dark"] .metric-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

View File

@@ -7,9 +7,6 @@
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.support-icon {
@@ -33,13 +30,11 @@
.support-content {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.support-content > p {
font-size: 1.1em;
text-align: center;
margin-bottom: var(--space-1);
}
.support-section {
@@ -117,6 +112,28 @@
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Patreon button style */
.patreon-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 20px;
background: #F96854;
color: white;
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
margin-top: var(--space-1);
}
.patreon-button:hover {
background: #E04946;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* QR Code section styles */
.qrcode-toggle {
width: 100%;

View File

@@ -37,13 +37,11 @@
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.version-info {
.update-info .version-info {
display: flex;
flex-direction: column;
gap: 8px;
@@ -70,6 +68,15 @@
color: var(--lora-accent);
}
/* Add styling for git info display */
.git-info {
font-size: 0.85em;
opacity: 0.7;
margin-top: 4px;
font-family: monospace;
color: var(--text-color);
}
.update-link {
display: flex;
align-items: center;

View File

@@ -4,7 +4,7 @@
width: 100%;
position: relative;
overflow-x: hidden; /* Prevent horizontal scrolling */
overflow-y: auto; /* Enable vertical scrolling */
overflow-y: scroll; /* Enable vertical scrolling */
}
.container {

View File

@@ -13,7 +13,13 @@
@import 'components/loading.css';
@import 'components/menu.css';
@import 'components/update-modal.css';
@import 'components/lora-modal.css';
@import 'components/lora-modal/lora-modal.css';
@import 'components/lora-modal/description.css';
@import 'components/lora-modal/tag.css';
@import 'components/lora-modal/preset-tags.css';
@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';
@@ -24,6 +30,7 @@
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
@import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
.initialization-notice {
display: flex;

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -1,164 +1,7 @@
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
/**
* Shared functionality for handling models (loras and checkpoints)
*/
// Generic function to load more models with pagination
export async function loadMoreModels(options = {}) {
const {
resetPage = false,
updateFolders = false,
modelType = 'lora', // 'lora' or 'checkpoint'
createCardFunction,
endpoint = '/api/loras'
} = options;
const pageState = getCurrentPageState();
if (pageState.isLoading || (!pageState.hasMore && !resetPage)) return;
pageState.isLoading = true;
document.body.classList.add('loading');
try {
// Reset to first page if requested
if (resetPage) {
pageState.currentPage = 1;
// Clear grid if resetting
const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid';
const grid = document.getElementById(gridId);
if (grid) grid.innerHTML = '';
}
const params = new URLSearchParams({
page: pageState.currentPage,
page_size: pageState.pageSize || 20,
sort_by: pageState.sortBy
});
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) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters if there's a search term
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());
if (pageState.searchOptions.tags !== undefined) {
params.append('search_tags', pageState.searchOptions.tags.toString());
}
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
// Add filter parameters if active
if (pageState.filters) {
// Handle tags filters
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
// Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags'
if (modelType === 'checkpoint') {
pageState.filters.tags.forEach(tag => {
params.append('tag', tag);
});
} else {
params.append('tags', pageState.filters.tags.join(','));
}
}
// Handle base model filters
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
if (modelType === 'checkpoint') {
pageState.filters.baseModel.forEach(model => {
params.append('base_model', model);
});
} else {
params.append('base_models', pageState.filters.baseModel.join(','));
}
}
}
// Add model-specific parameters
if (modelType === 'lora') {
// Check for recipe-based filtering parameters from session storage
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) {
try {
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
params.append('lora_hashes', filterLoraHashes.join(','));
}
} catch (error) {
console.error('Error parsing lora hashes from session storage:', error);
}
}
}
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
const data = await response.json();
const gridId = modelType === 'checkpoint' ? 'checkpointGrid' : 'loraGrid';
const grid = document.getElementById(gridId);
if (data.items.length === 0 && pageState.currentPage === 1) {
grid.innerHTML = `<div class="no-results">No ${modelType}s found in this folder</div>`;
pageState.hasMore = false;
} else if (data.items.length > 0) {
pageState.hasMore = pageState.currentPage < data.total_pages;
// Append model cards using the provided card creation function
data.items.forEach(model => {
const card = createCardFunction(model);
grid.appendChild(card);
});
// Increment the page number AFTER successful loading
pageState.currentPage++;
} else {
pageState.hasMore = false;
}
if (updateFolders && data.folders) {
updateFolderTags(data.folders);
}
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
} finally {
pageState.isLoading = false;
document.body.classList.remove('loading');
}
}
// New method for virtual scrolling fetch
export async function fetchModelsPage(options = {}) {
const {
@@ -294,7 +137,6 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
try {
pageState.isLoading = true;
document.body.classList.add('loading');
// Reset page counter
pageState.currentPage = 1;
@@ -325,7 +167,6 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
throw error;
} finally {
pageState.isLoading = false;
document.body.classList.remove('loading');
}
}
@@ -347,7 +188,6 @@ export async function loadMoreWithVirtualScroll(options = {}) {
try {
// Start loading state
pageState.isLoading = true;
document.body.classList.add('loading');
// Reset to first page if requested
if (resetPage) {
@@ -380,7 +220,6 @@ export async function loadMoreWithVirtualScroll(options = {}) {
throw error;
} finally {
pageState.isLoading = false;
document.body.classList.remove('loading');
}
}
@@ -482,22 +321,6 @@ export async function deleteModel(filePath, modelType = 'lora') {
}
}
// Reset and reload models
export async function resetAndReload(options = {}) {
const {
updateFolders = false,
modelType = 'lora',
loadMoreFunction
} = options;
const pageState = getCurrentPageState();
// Reset pagination and load more models
if (typeof loadMoreFunction === 'function') {
await loadMoreFunction(true, updateFolders);
}
}
// Generic function to refresh models
export async function refreshModels(options = {}) {
const {
@@ -648,6 +471,11 @@ export async function refreshSingleModelMetadata(filePath, modelType = 'lora') {
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 {
@@ -714,23 +542,17 @@ export async function excludeModel(filePath, modelType = 'lora') {
}
}
// Private methods
// Upload a preview image
async function uploadPreview(filePath, file, modelType = 'lora') {
const loadingOverlay = document.getElementById('loading-overlay');
const loadingStatus = document.querySelector('.loading-status');
export async function uploadPreview(filePath, file, modelType = 'lora', nsfwLevel = 0) {
try {
if (loadingOverlay) loadingOverlay.style.display = 'flex';
if (loadingStatus) loadingStatus.textContent = 'Uploading preview...';
state.loadingManager.showSimpleLoading('Uploading preview...');
const formData = new FormData();
// Use appropriate parameter names and endpoint based on model type
// 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'
@@ -747,56 +569,39 @@ async function uploadPreview(filePath, file, modelType = 'lora') {
}
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 card preview in UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
const previewContainer = card.querySelector('.card-preview');
const oldPreview = previewContainer.querySelector('img, video');
// Update the version timestamp
const timestamp = Date.now();
if (previewVersions) {
previewVersions.set(filePath, timestamp);
// 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 previewUrl = data.preview_url ?
`${data.preview_url}?t=${timestamp}` :
`/api/model/preview_image?path=${encodeURIComponent(filePath)}&t=${timestamp}`;
// Create appropriate element based on file type
if (file.type.startsWith('video/')) {
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.muted = true;
video.loop = true;
video.src = previewUrl;
oldPreview.replaceWith(video);
} else {
const img = document.createElement('img');
img.src = previewUrl;
oldPreview.replaceWith(img);
}
showToast('Preview updated successfully', 'success');
// 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 {
if (loadingOverlay) loadingOverlay.style.display = 'none';
state.loadingManager.hide();
}
}
// Private methods
// Private function to perform the delete operation
async function performDelete(filePath, modelType = 'lora') {
try {

View File

@@ -1,8 +1,5 @@
import { createCheckpointCard } from '../components/CheckpointCard.js';
import {
loadMoreModels,
fetchModelsPage,
resetAndReload as baseResetAndReload,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
@@ -36,43 +33,21 @@ export async function fetchCheckpointsPage(page = 1, pageSize = 100) {
* @returns {Promise<void>}
*/
export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) {
// Check if virtual scroller is available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
modelType: 'checkpoint',
resetPage,
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
} else {
// Fall back to the original implementation if virtual scroller isn't available
return loadMoreModels({
resetPage,
updateFolders,
modelType: 'checkpoint',
createCardFunction: createCheckpointCard,
endpoint: '/api/checkpoints'
});
}
return loadMoreWithVirtualScroll({
modelType: 'checkpoint',
resetPage,
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
}
// Reset and reload checkpoints
export async function resetAndReload(updateFolders = false) {
// Check if virtual scroller is available
if (state.virtualScroller) {
return resetAndReloadWithVirtualScroll({
modelType: 'checkpoint',
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
} else {
// Fall back to original implementation
return baseResetAndReload({
updateFolders,
modelType: 'checkpoint',
loadMoreFunction: loadMoreCheckpoints
});
}
return resetAndReloadWithVirtualScroll({
modelType: 'checkpoint',
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
}
// Refresh checkpoints
@@ -106,11 +81,7 @@ export async function fetchCivitai() {
// Refresh single checkpoint metadata
export async function refreshSingleCheckpointMetadata(filePath) {
const success = await refreshSingleModelMetadata(filePath, 'checkpoint');
if (success) {
// Reload the current view to show updated data
await resetAndReload();
}
await refreshSingleModelMetadata(filePath, 'checkpoint');
}
/**
@@ -138,6 +109,9 @@ export async function saveModelMetadata(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 {
@@ -166,7 +140,7 @@ export async function renameCheckpointFile(filePath, newFileName) {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming checkpoint file...');
const response = await fetch('/api/rename_checkpoint', {
const response = await fetch('/api/checkpoints/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -186,7 +160,6 @@ export async function renameCheckpointFile(filePath, newFileName) {
console.error('Error renaming checkpoint file:', error);
throw error;
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}

View File

@@ -1,8 +1,5 @@
import { createLoraCard } from '../components/LoraCard.js';
import {
loadMoreModels,
fetchModelsPage,
resetAndReload as baseResetAndReload,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
@@ -12,7 +9,7 @@ import {
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state, getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
/**
* Save model metadata to the server
@@ -39,6 +36,9 @@ export async function saveModelMetadata(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 {
@@ -63,26 +63,12 @@ export async function excludeLora(filePath) {
* @returns {Promise<void>}
*/
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
const pageState = getCurrentPageState();
// Check if virtual scroller is available
if (state.virtualScroller) {
return loadMoreWithVirtualScroll({
modelType: 'lora',
resetPage,
updateFolders,
fetchPageFunction: fetchLorasPage
});
} else {
// Fall back to the original implementation if virtual scroller isn't available
return loadMoreModels({
resetPage,
updateFolders,
modelType: 'lora',
createCardFunction: createLoraCard,
endpoint: '/api/loras'
});
}
return loadMoreWithVirtualScroll({
modelType: 'lora',
resetPage,
updateFolders,
fetchPageFunction: fetchLorasPage
});
}
/**
@@ -116,39 +102,12 @@ export async function replacePreview(filePath) {
return replaceModelPreview(filePath, 'lora');
}
export function appendLoraCards(loras) {
// This function is no longer needed with virtual scrolling
// but kept for compatibility
if (state.virtualScroller) {
console.warn('appendLoraCards is deprecated when using virtual scrolling');
} else {
const grid = document.getElementById('loraGrid');
loras.forEach(lora => {
const card = createLoraCard(lora);
grid.appendChild(card);
});
}
}
export async function resetAndReload(updateFolders = false) {
const pageState = getCurrentPageState();
// Check if virtual scroller is available
if (state.virtualScroller) {
return resetAndReloadWithVirtualScroll({
modelType: 'lora',
updateFolders,
fetchPageFunction: fetchLorasPage
});
} else {
// Fall back to original implementation
return baseResetAndReload({
updateFolders,
modelType: 'lora',
loadMoreFunction: loadMoreLoras
});
}
return resetAndReloadWithVirtualScroll({
modelType: 'lora',
updateFolders,
fetchPageFunction: fetchLorasPage
});
}
export async function refreshLoras(fullRebuild = false) {
@@ -161,11 +120,7 @@ export async function refreshLoras(fullRebuild = false) {
}
export async function refreshSingleLoraMetadata(filePath) {
const success = await refreshSingleModelMetadata(filePath, 'lora');
if (success) {
// Reload the current view to show updated data
await resetAndReload();
}
await refreshSingleModelMetadata(filePath, 'lora');
}
export async function fetchModelDescription(modelId, filePath) {
@@ -194,7 +149,7 @@ export async function renameLoraFile(filePath, newFileName) {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming LoRA file...');
const response = await fetch('/api/rename_lora', {
const response = await fetch('/api/loras/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,6 +1,5 @@
import { RecipeCard } from '../components/RecipeCard.js';
import {
fetchModelsPage,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll
} from './baseModelApi.js';
@@ -172,3 +171,44 @@ export function createRecipeCard(recipe) {
});
return recipeCard.element;
}
/**
* Update recipe metadata on the server
* @param {string} filePath - The file path of the recipe (e.g. D:/Workspace/ComfyUI/models/loras/recipes/86b4c335-ecfc-4791-89d2-3746e55a7614.webp)
* @param {Object} updates - The metadata updates to apply
* @returns {Promise<Object>} The updated recipe data
*/
export async function updateRecipeMetadata(filePath, updates) {
try {
state.loadingManager.showSimpleLoading('Saving metadata...');
// Extract recipeId from filePath (basename without extension)
const basename = filePath.split('/').pop().split('\\').pop();
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
const response = await fetch(`/api/recipe/${recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates)
});
const data = await response.json();
if (!data.success) {
showToast(`Failed to update recipe: ${data.error}`, 'error');
throw new Error(data.error || 'Failed to update recipe');
}
state.virtualScroller.updateSingleItem(filePath, updates);
return data;
} catch (error) {
console.error('Error updating recipe:', error);
showToast(`Error updating recipe: ${error.message}`, 'error');
throw error;
} finally {
state.loadingManager.hide();
}
}

View File

@@ -1,10 +1,203 @@
import { showToast, copyToClipboard, openExampleImagesFolder } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, openExampleImagesFolder, openCivitai } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { showCheckpointModal } from './checkpointModal/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js';
import { showDeleteModal } from '../utils/modalUtils.js';
// Add a global event delegation handler
export function setupCheckpointCardEventDelegation() {
const gridElement = document.getElementById('checkpointGrid');
if (!gridElement) return;
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', handleCheckpointCardEvent);
// Add the event delegation handler
gridElement.addEventListener('click', handleCheckpointCardEvent);
}
// Event delegation handler for all checkpoint card events
function handleCheckpointCardEvent(event) {
// Find the closest card element
const card = event.target.closest('.lora-card');
if (!card) return;
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
copyCheckpointName(card);
return;
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
replaceCheckpointPreview(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
openExampleImagesFolder(card.dataset.sha256);
return;
}
// If no specific element was clicked, handle the card click (show modal)
showCheckpointModalFromCard(card);
}
// Helper functions for event handling
function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
}
function showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
} else {
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
}
async function copyCheckpointName(card) {
const checkpointName = card.dataset.file_name;
try {
await copyToClipboard(checkpointName, 'Checkpoint name copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
}
function showCheckpointModalFromCard(card) {
// Get the page-specific previewVersions map
const previewVersions = state.pages.checkpoints.previewVersions || new Map();
const version = previewVersions.get(card.dataset.filepath);
const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Show checkpoint details modal
const checkpointMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
preview_url: versionedPreviewUrl,
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: (() => {
try {
return JSON.parse(card.dataset.tags || '[]');
} catch (e) {
console.error('Failed to parse tags:', e);
return []; // Return empty array on error
}
})(),
modelDescription: card.dataset.modelDescription || ''
};
showCheckpointModal(checkpointMeta);
}
function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}
export function createCheckpointCard(checkpoint) {
const card = document.createElement('div');
card.className = 'lora-card'; // Reuse the same class for styling
@@ -123,162 +316,7 @@ export function createCheckpointCard(checkpoint) {
</div>
`;
// Main card click event
card.addEventListener('click', () => {
// Show checkpoint details modal
const checkpointMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
preview_url: versionedPreviewUrl,
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: (() => {
try {
return JSON.parse(card.dataset.tags || '[]');
} catch (e) {
console.error('Failed to parse tags:', e);
return []; // Return empty array on error
}
})(),
modelDescription: card.dataset.modelDescription || ''
};
showCheckpointModal(checkpointMeta);
});
// Toggle blur button functionality
const toggleBlurBtn = card.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.addEventListener('click', (e) => {
e.stopPropagation();
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = toggleBlurBtn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
}
// Show content button functionality
const showContentBtn = card.querySelector('.show-content-btn');
if (showContentBtn) {
showContentBtn.addEventListener('click', (e) => {
e.stopPropagation();
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
}
// Favorite button click event
card.querySelector('.fa-star')?.addEventListener('click', async e => {
e.stopPropagation();
const starIcon = e.currentTarget;
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
// Update the UI
if (newFavoriteState) {
starIcon.classList.remove('far');
starIcon.classList.add('fas', 'favorite-active');
starIcon.title = 'Remove from favorites';
card.dataset.favorite = 'true';
showToast('Added to favorites', 'success');
} else {
starIcon.classList.remove('fas', 'favorite-active');
starIcon.classList.add('far');
starIcon.title = 'Add to favorites';
card.dataset.favorite = 'false';
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
});
// Copy button click event
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
e.stopPropagation();
const checkpointName = card.dataset.file_name;
try {
await copyToClipboard(checkpointName, 'Checkpoint name copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
// Civitai button click event
if (checkpoint.from_civitai) {
card.querySelector('.fa-globe')?.addEventListener('click', e => {
e.stopPropagation();
openCivitai(checkpoint.model_name);
});
}
// Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation();
showDeleteModal(checkpoint.file_path);
});
// Replace preview button click event
card.querySelector('.fa-image')?.addEventListener('click', e => {
e.stopPropagation();
replaceCheckpointPreview(checkpoint.file_path);
});
// Open example images folder button click event
card.querySelector('.fa-folder-open')?.addEventListener('click', e => {
e.stopPropagation();
openExampleImagesFolder(checkpoint.sha256);
});
// Add autoplayOnHover handlers for video elements if needed
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
@@ -287,52 +325,10 @@ export function createCheckpointCard(checkpoint) {
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause
cardPreview.addEventListener('mouseenter', () => {
videoElement.play();
});
cardPreview.addEventListener('mouseleave', () => {
videoElement.pause();
videoElement.currentTime = 0;
});
// Add mouse events to trigger play/pause using event attributes
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
}
return card;
}
// These functions will be implemented in checkpointApi.js
function openCivitai(modelName) {
// Check if the global function exists (registered by PageControls)
if (window.openCivitai) {
window.openCivitai(modelName);
} else {
// Fallback implementation
const card = document.querySelector(`.lora-card[data-name="${modelName}"]`);
if (!card) return;
const metaData = JSON.parse(card.dataset.meta || '{}');
const civitaiId = metaData.modelId;
const versionId = metaData.id;
// Build URL
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
url += `?modelVersionId=${versionId}`;
}
window.open(url, '_blank');
} else {
// If no ID, try searching by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
}
}
function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}

View File

@@ -1,17 +1,15 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview } from '../../api/checkpointApi.js';
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview, resetAndReload } from '../../api/checkpointApi.js';
import { showToast } from '../../utils/uiHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
import { resetAndReload } from '../../api/checkpointApi.js';
export class CheckpointContextMenu extends BaseContextMenu {
constructor() {
super('checkpointContextMenu', '.lora-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'checkpoint';
this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events
if (this.nsfwSelector) {
@@ -19,30 +17,27 @@ export class CheckpointContextMenu extends BaseContextMenu {
}
}
// Implementation needed by the mixin
async saveModelMetadata(filePath, data) {
return saveModelMetadata(filePath, data);
}
handleMenuAction(action) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
// Otherwise handle checkpoint-specific actions
switch(action) {
case 'details':
// Show checkpoint details
this.currentCard.click();
break;
case 'preview':
// Open example images folder instead of replacing preview
openExampleImagesFolder(this.currentCard.dataset.sha256);
break;
case 'replace-preview':
// Add new action for replacing preview images
replaceCheckpointPreview(this.currentCard.dataset.filepath);
break;
case 'civitai':
// Open civitai page
if (this.currentCard.dataset.from_civitai === 'true') {
if (this.currentCard.querySelector('.fa-globe')) {
this.currentCard.querySelector('.fa-globe').click();
}
} else {
showToast('No CivitAI information available', 'info');
}
break;
case 'delete':
// Delete checkpoint
if (this.currentCard.querySelector('.fa-trash')) {
@@ -59,14 +54,6 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Refresh metadata from CivitAI
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
break;
case 'relink-civitai':
// Handle re-link to Civitai
this.showRelinkCivitaiModal();
break;
case 'set-nsfw':
// Set NSFW level
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
case 'move':
// Move to folder (placeholder)
showToast('Move to folder feature coming soon', 'info');
@@ -74,339 +61,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint');
break;
}
}
}
// NSFW Selector methods
initNSFWSelector() {
// Close button
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
closeBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const level = parseInt(btn.dataset.level);
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await saveModelMetadata(filePath, { preview_nsfw_level: level });
// Update card data
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
let metaData = {};
try {
metaData = JSON.parse(card.dataset.meta || '{}');
} catch (err) {
console.error('Error parsing metadata:', err);
}
metaData.preview_nsfw_level = level;
card.dataset.meta = JSON.stringify(metaData);
card.dataset.nsfwLevel = level.toString();
// Apply blur effect immediately
this.updateCardBlurEffect(card, level);
}
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
this.nsfwSelector.style.display = 'none';
} catch (error) {
showToast(`Failed to set content rating: ${error.message}`, 'error');
}
});
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
this.nsfwSelector.style.display = 'none';
}
});
}
updateCardBlurEffect(card, level) {
// Get user settings for blur threshold
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
// Get card preview container
const previewContainer = card.querySelector('.card-preview');
if (!previewContainer) return;
// Get preview media element
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
if (!previewMedia) return;
// Check if blur should be applied
if (level >= blurThreshold) {
// Add blur class to the preview container
previewContainer.classList.add('blurred');
// Get or create the NSFW overlay
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
if (!nsfwOverlay) {
// Create new overlay
nsfwOverlay = document.createElement('div');
nsfwOverlay.className = 'nsfw-overlay';
// Create and configure the warning content
const warningContent = document.createElement('div');
warningContent.className = 'nsfw-warning';
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Add warning text and show button
warningContent.innerHTML = `
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
`;
// Add click event to the show button
const showBtn = warningContent.querySelector('.show-content-btn');
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
previewContainer.classList.remove('blurred');
nsfwOverlay.style.display = 'none';
// Update toggle button icon if it exists
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
});
nsfwOverlay.appendChild(warningContent);
previewContainer.appendChild(nsfwOverlay);
} else {
// Update existing overlay
const warningText = nsfwOverlay.querySelector('p');
if (warningText) {
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
warningText.textContent = nsfwText;
}
nsfwOverlay.style.display = 'flex';
}
// Get or create the toggle button in the header
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
let toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (!toggleBtn) {
toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-blur-btn';
toggleBtn.title = 'Toggle blur';
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
// Add click event to toggle button
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isBlurred = previewContainer.classList.toggle('blurred');
const icon = toggleBtn.querySelector('i');
// Update icon and overlay visibility
if (isBlurred) {
icon.className = 'fas fa-eye';
nsfwOverlay.style.display = 'flex';
} else {
icon.className = 'fas fa-eye-slash';
nsfwOverlay.style.display = 'none';
}
});
// Add to the beginning of header
cardHeader.insertBefore(toggleBtn, cardHeader.firstChild);
// Update base model label class
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.add('with-toggle');
}
} else {
// Update existing toggle button
toggleBtn.querySelector('i').className = 'fas fa-eye';
}
}
} else {
// Remove blur
previewContainer.classList.remove('blurred');
// Hide overlay if it exists
const overlay = previewContainer.querySelector('.nsfw-overlay');
if (overlay) overlay.style.display = 'none';
// Remove toggle button when content is set to PG or PG13
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
const toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (toggleBtn) {
// Remove the toggle button completely
toggleBtn.remove();
// Update base model label class if it exists
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.remove('with-toggle');
}
}
}
}
}
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
// Get current NSFW level
let currentLevel = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
currentLevel = metaData.preview_nsfw_level || 0;
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
}
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Store reference to current card
selector.dataset.cardPath = card.dataset.filepath;
// Show selector
selector.style.display = 'block';
}
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const modelVersionId = this.extractModelVersionId(url);
if (!modelVersionId) {
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const response = await fetch('/api/checkpoints/relink-civitai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
}
extractModelVersionId(url) {
try {
const parsedUrl = new URL(url);
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return modelVersionId;
} catch (e) {
return null;
}
}
}
// Mix in shared methods
Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -1,16 +1,15 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview, resetAndReload } from '../../api/loraApi.js';
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
export class LoraContextMenu extends BaseContextMenu {
constructor() {
super('loraContextMenu', '.lora-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'lora';
this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events
if (this.nsfwSelector) {
@@ -18,24 +17,23 @@ export class LoraContextMenu extends BaseContextMenu {
}
}
// Use the saveModelMetadata implementation from loraApi
async saveModelMetadata(filePath, data) {
return saveModelMetadata(filePath, data);
}
handleMenuAction(action, menuItem) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
// Otherwise handle lora-specific actions
switch(action) {
case 'detail':
// Trigger the main card click which shows the modal
this.currentCard.click();
break;
case 'civitai':
// Only trigger if the card is from civitai
if (this.currentCard.dataset.from_civitai === 'true') {
if (this.currentCard.dataset.meta === '{}') {
showToast('Please fetch metadata from CivitAI first', 'info');
} else {
this.currentCard.querySelector('.fa-globe')?.click();
}
} else {
showToast('No CivitAI information available', 'info');
}
break;
case 'copyname':
// Generate and copy LoRA syntax
this.copyLoraSyntax();
@@ -48,10 +46,6 @@ export class LoraContextMenu extends BaseContextMenu {
// Send LoRA to workflow (replace mode)
this.sendLoraToWorkflow(true);
break;
case 'preview':
// Open example images folder instead of showing preview image dialog
openExampleImagesFolder(this.currentCard.dataset.sha256);
break;
case 'replace-preview':
// Add a new action for replacing preview images
replacePreview(this.currentCard.dataset.filepath);
@@ -66,19 +60,13 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break;
case 'relink-civitai':
this.showRelinkCivitaiModal();
break;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
}
}
// New method to handle copy syntax functionality
// Specific LoRA methods
copyLoraSyntax() {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
@@ -88,7 +76,6 @@ export class LoraContextMenu extends BaseContextMenu {
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
}
// New method to handle send to workflow functionality
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
@@ -97,341 +84,7 @@ export class LoraContextMenu extends BaseContextMenu {
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
}
// New method to handle re-link to Civitai
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const modelVersionId = this.extractModelVersionId(url);
if (!modelVersionId) {
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const response = await fetch('/api/relink-civitai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
}
extractModelVersionId(url) {
try {
const parsedUrl = new URL(url);
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return modelVersionId;
} catch (e) {
return null;
}
}
// NSFW Selector methods from the original context menu
initNSFWSelector() {
// Close button
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
closeBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const level = parseInt(btn.dataset.level);
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
// Update card data
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
let metaData = {};
try {
metaData = JSON.parse(card.dataset.meta || '{}');
} catch (err) {
console.error('Error parsing metadata:', err);
}
metaData.preview_nsfw_level = level;
card.dataset.meta = JSON.stringify(metaData);
card.dataset.nsfwLevel = level.toString();
// Apply blur effect immediately
this.updateCardBlurEffect(card, level);
}
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
this.nsfwSelector.style.display = 'none';
} catch (error) {
showToast(`Failed to set content rating: ${error.message}`, 'error');
}
});
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
this.nsfwSelector.style.display = 'none';
}
});
}
async saveModelMetadata(filePath, data) {
return saveModelMetadata(filePath, data);
}
updateCardBlurEffect(card, level) {
// Get user settings for blur threshold
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
// Get card preview container
const previewContainer = card.querySelector('.card-preview');
if (!previewContainer) return;
// Get preview media element
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
if (!previewMedia) return;
// Check if blur should be applied
if (level >= blurThreshold) {
// Add blur class to the preview container
previewContainer.classList.add('blurred');
// Get or create the NSFW overlay
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
if (!nsfwOverlay) {
// Create new overlay
nsfwOverlay = document.createElement('div');
nsfwOverlay.className = 'nsfw-overlay';
// Create and configure the warning content
const warningContent = document.createElement('div');
warningContent.className = 'nsfw-warning';
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Add warning text and show button
warningContent.innerHTML = `
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
`;
// Add click event to the show button
const showBtn = warningContent.querySelector('.show-content-btn');
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
previewContainer.classList.remove('blurred');
nsfwOverlay.style.display = 'none';
// Update toggle button icon if it exists
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
});
nsfwOverlay.appendChild(warningContent);
previewContainer.appendChild(nsfwOverlay);
} else {
// Update existing overlay
const warningText = nsfwOverlay.querySelector('p');
if (warningText) {
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
warningText.textContent = nsfwText;
}
nsfwOverlay.style.display = 'flex';
}
// Get or create the toggle button in the header
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
let toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (!toggleBtn) {
toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-blur-btn';
toggleBtn.title = 'Toggle blur';
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
// Add click event to toggle button
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isBlurred = previewContainer.classList.toggle('blurred');
const icon = toggleBtn.querySelector('i');
// Update icon and overlay visibility
if (isBlurred) {
icon.className = 'fas fa-eye';
nsfwOverlay.style.display = 'flex';
} else {
icon.className = 'fas fa-eye-slash';
nsfwOverlay.style.display = 'none';
}
});
// Add to the beginning of header
cardHeader.insertBefore(toggleBtn, cardHeader.firstChild);
// Update base model label class
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.add('with-toggle');
}
} else {
// Update existing toggle button
toggleBtn.querySelector('i').className = 'fas fa-eye';
}
}
} else {
// Remove blur
previewContainer.classList.remove('blurred');
// Hide overlay if it exists
const overlay = previewContainer.querySelector('.nsfw-overlay');
if (overlay) overlay.style.display = 'none';
// Remove toggle button when content is set to PG or PG13
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
const toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (toggleBtn) {
// Remove the toggle button completely
toggleBtn.remove();
// Update base model label class if it exists
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.remove('with-toggle');
}
}
}
}
}
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
// Get current NSFW level
let currentLevel = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
currentLevel = metaData.preview_nsfw_level || 0;
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
}
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Store reference to current card
selector.dataset.cardPath = card.dataset.filepath;
// Show selector
selector.style.display = 'block';
}
}
// Mix in shared methods
Object.assign(LoraContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -0,0 +1,226 @@
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
// NSFW Selector methods
initNSFWSelector() {
// Close button
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
closeBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const level = parseInt(btn.dataset.level);
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
this.nsfwSelector.style.display = 'none';
} catch (error) {
showToast(`Failed to set content rating: ${error.message}`, 'error');
}
});
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
this.nsfwSelector.style.display = 'none';
}
});
},
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
// Get current NSFW level
let currentLevel = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
currentLevel = metaData.preview_nsfw_level || 0;
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
}
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Store reference to current card
selector.dataset.cardPath = card.dataset.filepath;
// Show selector
selector.style.display = 'block';
},
// Civitai re-linking methods
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const { modelId, modelVersionId } = this.extractModelVersionId(url);
if (!modelId) {
errorDiv.textContent = 'Invalid URL format. Must include model ID.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const endpoint = this.modelType === 'checkpoint' ?
'/api/checkpoints/relink-civitai' :
'/api/relink-civitai';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await this.resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
// Auto-focus the URL input field after modal is shown
setTimeout(() => urlInput.focus(), 50);
},
extractModelVersionId(url) {
try {
// Handle all three URL formats:
// 1. https://civitai.com/models/649516
// 2. https://civitai.com/models/649516?modelVersionId=726676
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
const parsedUrl = new URL(url);
// Extract model ID from path
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
// Extract model version ID from query parameters
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
},
// Common action handlers
handleCommonMenuActions(action) {
switch(action) {
case 'preview':
openExampleImagesFolder(this.currentCard.dataset.sha256);
return true;
case 'civitai':
if (this.currentCard.dataset.from_civitai === 'true') {
if (this.currentCard.querySelector('.fa-globe')) {
this.currentCard.querySelector('.fa-globe').click();
} else {
showToast('Please fetch metadata from CivitAI first', 'info');
}
} else {
showToast('No CivitAI information available', 'info');
}
return true;
case 'relink-civitai':
this.showRelinkCivitaiModal();
return true;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
return true;
default:
return false;
}
}
};

View File

@@ -1,11 +1,31 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
super('recipeContextMenu', '.lora-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'recipe';
// Initialize NSFW Level Selector events
if (this.nsfwSelector) {
this.initNSFWSelector();
}
}
// Use the updateRecipeMetadata implementation from recipeApi
async saveModelMetadata(filePath, data) {
return updateRecipeMetadata(filePath, data);
}
// Override resetAndReload for recipe context
async resetAndReload() {
const { resetAndReload } = await import('../../api/recipeApi.js');
return resetAndReload();
}
showMenu(x, y, card) {
@@ -31,6 +51,12 @@ export class RecipeContextMenu extends BaseContextMenu {
}
handleMenuAction(action) {
// First try to handle with common actions from ModelContextMenuMixin
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
// Handle recipe-specific actions
const recipeId = this.currentCard.dataset.id;
switch(action) {
@@ -256,4 +282,7 @@ export class RecipeContextMenu extends BaseContextMenu {
}
}
}
}
}
// Mix in shared methods from ModelContextMenuMixin
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -1,3 +1,4 @@
export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';

View File

@@ -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('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}
@@ -46,9 +47,21 @@ export class HeaderManager {
// Handle theme toggle
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
// Set initial state based on current theme
const currentTheme = localStorage.getItem('lm_theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
themeToggle.addEventListener('click', () => {
if (typeof toggleTheme === 'function') {
toggleTheme();
const newTheme = toggleTheme();
// Update tooltip based on next toggle action
if (newTheme === 'light') {
themeToggle.title = "Switch to dark theme";
} else if (newTheme === 'dark') {
themeToggle.title = "Switch to auto theme";
} else {
themeToggle.title = "Switch to light theme";
}
}
});
}
@@ -109,14 +122,32 @@ export class HeaderManager {
});
}
// Handle help toggle
// const helpToggle = document.querySelector('.help-toggle');
// if (helpToggle) {
// helpToggle.addEventListener('click', () => {
// if (window.modalManager) {
// window.modalManager.toggleModal('helpModal');
// }
// });
// }
// Hide search functionality on Statistics page
this.updateHeaderForPage();
}
updateHeaderForPage() {
const headerSearch = document.getElementById('headerSearch');
if (this.currentPage === 'statistics' && headerSearch) {
headerSearch.classList.add('disabled');
// Disable search functionality
const searchInput = headerSearch.querySelector('#searchInput');
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = true;
searchInput.placeholder = 'Search not available on statistics page';
}
searchButtons.forEach(btn => btn.disabled = true);
} else if (headerSearch) {
headerSearch.classList.remove('disabled');
// Re-enable search functionality
const searchInput = headerSearch.querySelector('#searchInput');
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = false;
}
searchButtons.forEach(btn => btn.disabled = false);
}
}
}

View File

@@ -4,7 +4,6 @@ import { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
import { showDeleteModal } from '../utils/modalUtils.js';
// Add a global event delegation handler
export function setupLoraCardEventDelegation() {
@@ -46,7 +45,7 @@ function handleLoraCardEvent(event) {
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.name);
openCivitai(card.dataset.filepath);
}
return;
}
@@ -71,7 +70,7 @@ function handleLoraCardEvent(event) {
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
openExampleImagesFolder(card.dataset.sha256);
handleExampleImagesAccess(card);
return;
}
@@ -163,18 +162,9 @@ async function toggleFavorite(card) {
favorite: newFavoriteState
});
// Update the UI
if (newFavoriteState) {
starIcon.classList.remove('far');
starIcon.classList.add('fas', 'favorite-active');
starIcon.title = 'Remove from favorites';
card.dataset.favorite = 'true';
showToast('Added to favorites', 'success');
} else {
starIcon.classList.remove('fas', 'favorite-active');
starIcon.classList.add('far');
starIcon.title = 'Add to favorites';
card.dataset.favorite = 'false';
showToast('Removed from favorites', 'success');
}
} catch (error) {
@@ -201,6 +191,142 @@ function copyLoraSyntax(card) {
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
}
// New function to handle example images access
async function handleExampleImagesAccess(card) {
const modelHash = card.dataset.sha256;
try {
// Check if example images exist
const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`);
const data = await response.json();
if (data.has_images) {
// If images exist, open the folder directly (existing behavior)
openExampleImagesFolder(modelHash);
} else {
// If no images exist, show the new modal
showExampleAccessModal(card);
}
} catch (error) {
console.error('Error checking for example images:', error);
showToast('Error checking for example images', 'error');
}
}
// Function to show the example access modal
function showExampleAccessModal(card) {
const modal = document.getElementById('exampleAccessModal');
if (!modal) return;
// Get download button and determine if download should be enabled
const downloadBtn = modal.querySelector('#downloadExamplesBtn');
let hasRemoteExamples = false;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
hasRemoteExamples = metaData.images &&
Array.isArray(metaData.images) &&
metaData.images.length > 0 &&
metaData.images[0].url;
} catch (e) {
console.error('Error parsing meta data:', e);
}
// Enable or disable download button
if (downloadBtn) {
if (hasRemoteExamples) {
downloadBtn.classList.remove('disabled');
downloadBtn.removeAttribute('title'); // Remove any previous tooltip
downloadBtn.onclick = () => {
modalManager.closeModal('exampleAccessModal');
// Open settings modal and scroll to example images section
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
modalManager.showModal('settingsModal');
// Scroll to example images section after modal is visible
setTimeout(() => {
const exampleSection = settingsModal.querySelector('.settings-section:nth-child(5)'); // Example Images section
if (exampleSection) {
exampleSection.scrollIntoView({ behavior: 'smooth' });
}
}, 300);
}
};
} else {
downloadBtn.classList.add('disabled');
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
downloadBtn.onclick = null;
}
}
// Set up import button
const importBtn = modal.querySelector('#importExamplesBtn');
if (importBtn) {
importBtn.onclick = () => {
modalManager.closeModal('exampleAccessModal');
// Get the lora data from card dataset
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
// Other properties needed for showLoraModal
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
return {};
}
})(),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
};
// Show the lora modal
showLoraModal(loraMeta);
// Scroll to import area after modal is visible
setTimeout(() => {
const importArea = document.querySelector('.example-import-area');
if (importArea) {
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// First make sure showcase tab is visible
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
if (tabBtn && !tabBtn.classList.contains('active')) {
tabBtn.click();
}
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
scrollIndicator.click();
}
}
// Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' });
}
}
}, 500);
};
}
// Show the modal
modalManager.showModal('exampleAccessModal');
}
export function createLoraCard(lora) {
const card = document.createElement('div');
card.className = 'lora-card';

View File

@@ -2,6 +2,9 @@
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 { LoadingManager } from '../managers/LoadingManager.js';
export class ModelDuplicatesManager {
constructor(pageManager, modelType = 'loras') {
@@ -11,10 +14,18 @@ export class ModelDuplicatesManager {
this.selectedForDeletion = new Set();
this.modelType = modelType; // Use the provided modelType or default to 'loras'
// Verification tracking
this.verifiedGroups = new Set(); // Track which groups have been verified
this.mismatchedFiles = new Map(); // Map file paths to actual hashes for mismatched files
// Loading manager for verification process
this.loadingManager = new LoadingManager();
// Bind methods
this.renderModelCard = this.renderModelCard.bind(this);
this.renderTooltip = this.renderTooltip.bind(this);
this.checkDuplicatesCount = this.checkDuplicatesCount.bind(this);
this.handleVerifyHashes = this.handleVerifyHashes.bind(this);
// Keep track of which controls need to be re-enabled
this.disabledControls = [];
@@ -245,14 +256,34 @@ export class ModelDuplicatesManager {
// Create group header
const header = document.createElement('div');
header.className = 'duplicate-group-header';
// Create verification status badge
const verificationBadge = document.createElement('span');
verificationBadge.className = 'verification-badge';
if (this.verifiedGroups.has(group.hash)) {
verificationBadge.classList.add('verified');
verificationBadge.innerHTML = '<i class="fas fa-check-circle"></i> Verified';
} else {
verificationBadge.classList.add('metadata');
verificationBadge.innerHTML = '<i class="fas fa-tag"></i> Metadata Hash';
}
header.innerHTML = `
<span>Duplicate Group #${groupIndex + 1} (${group.models.length} models with same hash: ${group.hash})</span>
<span>
<button class="btn-verify-hashes" data-hash="${group.hash}" title="Recalculate SHA256 hashes to verify if these are true duplicates">
<i class="fas fa-fingerprint"></i> Verify Hashes
</button>
<button class="btn-select-all" onclick="modelDuplicatesManager.toggleSelectAllInGroup('${group.hash}')">
Select All
</button>
</span>
`;
// Insert verification badge after the group title
const headerFirstSpan = header.querySelector('span:first-child');
headerFirstSpan.appendChild(verificationBadge);
groupDiv.appendChild(header);
// Create cards container
@@ -285,6 +316,15 @@ export class ModelDuplicatesManager {
groupDiv.appendChild(cardsDiv);
modelGrid.appendChild(groupDiv);
// Add event listener to the verify hashes button
const verifyButton = header.querySelector('.btn-verify-hashes');
if (verifyButton) {
verifyButton.addEventListener('click', (e) => {
e.stopPropagation();
this.handleVerifyHashes(group);
});
}
});
}
@@ -295,6 +335,14 @@ export class ModelDuplicatesManager {
card.dataset.hash = model.sha256;
card.dataset.filePath = model.file_path;
// Check if this model is a mismatched file
const isMismatched = this.mismatchedFiles.has(model.file_path);
// Add mismatched class if needed
if (isMismatched) {
card.classList.add('hash-mismatch');
}
// Create card content using structure similar to createLoraCard in LoraCard.js
const previewContainer = document.createElement('div');
previewContainer.className = 'card-preview';
@@ -336,6 +384,19 @@ export class ModelDuplicatesManager {
previewContainer.appendChild(preview);
// Add hash mismatch badge if needed
if (isMismatched) {
const mismatchBadge = document.createElement('div');
mismatchBadge.className = 'mismatch-badge';
mismatchBadge.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Different Hash';
previewContainer.appendChild(mismatchBadge);
}
// Mark as latest if applicable
if (model.is_latest) {
card.classList.add('latest');
}
// Move tooltip listeners to the preview container for consistent behavior
// regardless of whether the preview is an image or video
previewContainer.addEventListener('mouseover', () => this.renderTooltip(card, model));
@@ -373,6 +434,12 @@ export class ModelDuplicatesManager {
card.classList.add('duplicate-selected');
}
// Disable checkbox for mismatched files
if (isMismatched) {
checkbox.disabled = true;
checkbox.title = "This file has a different actual hash and can't be selected";
}
// Add change event to checkbox
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
@@ -386,6 +453,11 @@ export class ModelDuplicatesManager {
return;
}
// Don't toggle if it's a mismatched file
if (isMismatched) {
return;
}
// Toggle checkbox state
checkbox.checked = !checkbox.checked;
this.toggleCardSelection(model.file_path, card, checkbox);
@@ -404,8 +476,12 @@ export class ModelDuplicatesManager {
const tooltip = document.createElement('div');
tooltip.className = 'model-tooltip';
// Check if this model is a mismatched file and get the actual hash
const isMismatched = this.mismatchedFiles.has(model.file_path);
const actualHash = isMismatched ? this.mismatchedFiles.get(model.file_path) : null;
// Add model information to tooltip
tooltip.innerHTML = `
let tooltipContent = `
<div class="tooltip-header">${model.model_name}</div>
<div class="tooltip-info">
<div><strong>Version:</strong> ${model.civitai?.name || 'Unknown'}</div>
@@ -413,9 +489,17 @@ export class ModelDuplicatesManager {
<div><strong>Path:</strong> ${model.file_path}</div>
<div><strong>Base Model:</strong> ${model.base_model || 'Unknown'}</div>
<div><strong>Modified:</strong> ${formatDate(model.modified)}</div>
</div>
<div><strong>Metadata Hash:</strong> <span class="hash-value">${model.sha256}</span></div>
`;
// Add actual hash information if available
if (isMismatched && actualHash) {
tooltipContent += `<div class="hash-mismatch-info"><strong>Actual Hash:</strong> <span class="hash-value">${actualHash}</span></div>`;
}
tooltipContent += `</div>`;
tooltip.innerHTML = tooltipContent;
// Position tooltip relative to card
const cardRect = card.getBoundingClientRect();
tooltip.style.top = `${cardRect.top + window.scrollY - 10}px`;
@@ -536,11 +620,43 @@ export class ModelDuplicatesManager {
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
// Exit duplicate mode if deletions were successful
// If models were successfully deleted
if (data.total_deleted > 0) {
// Check duplicates count after deletion
this.checkDuplicatesCount();
this.exitDuplicateMode();
// Reload model data with updated folders
if (this.modelType === 'loras') {
await resetAndReloadLoras(true);
} else {
await resetAndReloadCheckpoints(true);
}
// Check if there are still duplicates
try {
const endpoint = `/api/${this.modelType}/find-duplicates`;
const dupResponse = await fetch(endpoint);
if (!dupResponse.ok) {
throw new Error(`Failed to get duplicates: ${dupResponse.statusText}`);
}
const dupData = await dupResponse.json();
const remainingDuplicatesCount = (dupData.duplicates || []).length;
// Update badge count
this.updateDuplicatesBadge(remainingDuplicatesCount);
// If no more duplicates, exit duplicate mode
if (remainingDuplicatesCount === 0) {
this.exitDuplicateMode();
} else {
// If duplicates remain, refresh duplicate groups display
this.duplicateGroups = dupData.duplicates || [];
this.selectedForDeletion.clear();
this.renderDuplicateGroups();
this.updateSelectedCount();
}
} catch (error) {
console.error('Error checking remaining duplicates:', error);
}
}
} catch (error) {
@@ -596,4 +712,73 @@ export class ModelDuplicatesManager {
}
});
}
// Handle verify hashes button click
async handleVerifyHashes(group) {
try {
const groupHash = group.hash;
// Check if already verified
if (this.verifiedGroups.has(groupHash)) {
showToast('This group has already been verified', 'info');
return;
}
// Show loading state
this.loadingManager.showSimpleLoading('Verifying hashes...');
// Get file paths for all models in the group
const filePaths = group.models.map(model => model.file_path);
// Make API request to verify hashes
const response = await fetch(`/api/${this.modelType}/verify-duplicates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_paths: filePaths })
});
if (!response.ok) {
throw new Error(`Verification failed: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error during verification');
}
// Process verification results
const verifiedAsDuplicates = data.verified_as_duplicates;
const mismatchedFiles = data.mismatched_files || [];
// Update mismatchedFiles map
if (data.new_hash_map) {
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
this.mismatchedFiles.set(path, hash);
});
}
// Mark this group as verified
this.verifiedGroups.add(groupHash);
// Re-render the duplicate groups to show verification status
this.renderDuplicateGroups();
// Show appropriate toast message
if (mismatchedFiles.length > 0) {
showToast(`Verification complete. ${mismatchedFiles.length} file(s) have different actual hashes.`, 'warning');
} else {
showToast('Verification complete. All files are confirmed duplicates.', 'success');
}
} catch (error) {
console.error('Error verifying hashes:', error);
showToast('Failed to verify hashes: ' + error.message, 'error');
} finally {
// Hide loading state
this.loadingManager.hide();
}
}
}

View File

@@ -2,6 +2,8 @@
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -16,8 +18,9 @@ class RecipeCard {
createCardElement() {
const card = document.createElement('div');
card.className = 'lora-card';
card.dataset.filePath = this.recipe.file_path;
card.dataset.filepath = this.recipe.file_path;
card.dataset.title = this.recipe.title;
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
@@ -41,15 +44,34 @@ class RecipeCard {
const pageState = getCurrentPageState();
const isDuplicatesMode = pageState.duplicatesMode;
// NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
card.innerHTML = `
${!isDuplicatesMode ? `<div class="recipe-indicator" title="Recipe">R</div>` : ''}
<div class="card-preview">
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
<img src="${imageUrl}" alt="${this.recipe.title}">
${!isDuplicatesMode ? `
<div class="card-header">
<div class="base-model-wrapper">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
</div>
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
${baseModel ? `<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModel}">${baseModel}</span>` : ''}
<div class="card-actions">
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
@@ -57,6 +79,14 @@ class RecipeCard {
</div>
</div>
` : ''}
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
<div class="card-footer">
<div class="model-info">
<span class="model-name">${this.recipe.title}</span>
@@ -71,7 +101,7 @@ class RecipeCard {
</div>
`;
this.attachEventListeners(card, isDuplicatesMode);
this.attachEventListeners(card, isDuplicatesMode, shouldBlur);
return card;
}
@@ -81,7 +111,27 @@ class RecipeCard {
return `${missingCount} of ${totalCount} LoRAs missing`;
}
attachEventListeners(card, isDuplicatesMode) {
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred
if (shouldBlur) {
const toggleBtn = card.querySelector('.toggle-blur-btn');
const showBtn = card.querySelector('.show-content-btn');
if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleBlurContent(card);
});
}
if (showBtn) {
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showBlurredContent(card);
});
}
}
// Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) {
card.addEventListener('click', () => {
@@ -108,7 +158,42 @@ class RecipeCard {
}
}
// Replace copyRecipeSyntax with sendRecipeToWorkflow
toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
}
showBlurredContent(card) {
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
sendRecipeToWorkflow(replaceMode = false) {
try {
// Get recipe ID
@@ -141,6 +226,7 @@ class RecipeCard {
try {
// Get recipe ID
const recipeId = this.recipe.id;
const filePath = this.recipe.file_path;
if (!recipeId) {
showToast('Cannot delete recipe: Missing recipe ID', 'error');
return;
@@ -184,6 +270,7 @@ class RecipeCard {
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
@@ -227,7 +314,7 @@ class RecipeCard {
.then(data => {
showToast('Recipe deleted successfully', 'success');
window.recipeManager.loadRecipes();
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})

View File

@@ -3,6 +3,7 @@ import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { updateRecipeCard } from '../utils/cardUpdater.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
class RecipeModal {
constructor() {
@@ -117,6 +118,7 @@ class RecipeModal {
// Store the recipe ID for copy syntax API call
this.recipeId = recipe.id;
this.filePath = recipe.file_path;
// Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact');
@@ -522,7 +524,19 @@ class RecipeModal {
titleContainer.querySelector('.content-text').textContent = newTitle;
// Update the recipe on the server
this.updateRecipeMetadata({ title: newTitle });
updateRecipeMetadata(this.filePath, { title: newTitle })
.then(data => {
// Show success toast
showToast('Recipe name updated successfully', 'success');
// Update the current recipe object
this.currentRecipe.title = newTitle;
})
.catch(error => {
// Error is handled in the API function
// Reset the UI if needed
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
});
}
// Hide editor
@@ -580,64 +594,20 @@ class RecipeModal {
if (tagsChanged) {
// Update the recipe on the server
this.updateRecipeMetadata({ tags: newTags });
// Update tags in the UI
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (newTags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = newTags.slice(0, maxVisibleTags);
const remainingTags = newTags.length > maxVisibleTags ? newTags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
updateRecipeMetadata(this.filePath, { tags: newTags })
.then(data => {
// Show success toast
showToast('Recipe tags updated successfully', 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
newTags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
// Update the current recipe object
this.currentRecipe.tags = newTags;
}
// Hide editor
@@ -646,6 +616,62 @@ class RecipeModal {
}
}
// Helper method to update tags display
updateTagsDisplay(tagsContainer, tags) {
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = tags.slice(0, maxVisibleTags);
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
}
cancelTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
@@ -660,41 +686,66 @@ class RecipeModal {
}
}
// Update recipe metadata on the server
async updateRecipeMetadata(updates) {
try {
const response = await fetch(`/api/recipe/${this.recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates)
});
const data = await response.json();
if (data.success) {
// 显示保存成功的提示
if (updates.title) {
showToast('Recipe name updated successfully', 'success');
} else if (updates.tags) {
showToast('Recipe tags updated successfully', 'success');
} else {
showToast('Recipe updated successfully', 'success');
}
// 更新当前recipe对象的属性
Object.assign(this.currentRecipe, updates);
// Update the recipe card in the UI
updateRecipeCard(this.recipeId, updates);
} else {
showToast(`Failed to update recipe: ${data.error}`, 'error');
// Setup source URL handlers
setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
// Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide');
sourceUrlEditor.classList.add('active');
sourceUrlInput.focus();
});
// Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
sourceUrlInput.value = this.currentRecipe.source_path || '';
});
// Save new source URL
sourceUrlSaveBtn.addEventListener('click', () => {
const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl !== this.currentRecipe.source_path) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
.then(data => {
// Show success toast
showToast('Source URL updated successfully', 'success');
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL';
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
newSourceUrl.startsWith('https://')) ?
'Click to open source URL' : 'No valid URL';
// Update the current recipe object
this.currentRecipe.source_path = newSourceUrl;
})
.catch(error => {
// Error is handled in the API function
});
}
} catch (error) {
console.error('Error updating recipe:', error);
showToast(`Error updating recipe: ${error.message}`, 'error');
}
// Hide editor
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
});
// Open source URL in a new tab if it's valid
sourceUrlText.addEventListener('click', () => {
const url = sourceUrlText.textContent.trim();
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
}
});
}
// Setup copy buttons for prompts and recipe syntax
@@ -950,13 +1001,6 @@ class RecipeModal {
// Remove .safetensors extension if present
fileName = fileName.replace(/\.safetensors$/, '');
// Get the deleted lora data
const deletedLora = this.currentRecipe.loras[loraIndex];
if (!deletedLora) {
showToast('Error: Could not find the LoRA in the recipe', 'error');
return;
}
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
// Call API to reconnect the LoRA
@@ -967,7 +1011,7 @@ class RecipeModal {
},
body: JSON.stringify({
recipe_id: this.recipeId,
lora_data: deletedLora,
lora_index: loraIndex,
target_name: fileName
})
});
@@ -989,13 +1033,10 @@ class RecipeModal {
setTimeout(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
// Refresh recipes list
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
setTimeout(() => {
window.recipeManager.loadRecipes(true);
}, 1000);
}
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
loras: this.currentRecipe.loras
});
} else {
showToast(`Error: ${result.error}`, 'error');
}
@@ -1065,56 +1106,6 @@ class RecipeModal {
});
});
}
// New method to set up source URL handlers
setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
// Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide');
sourceUrlEditor.classList.add('active');
sourceUrlInput.focus();
});
// Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
sourceUrlInput.value = this.currentRecipe.source_path || '';
});
// Save new source URL
sourceUrlSaveBtn.addEventListener('click', () => {
const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl && newSourceUrl !== this.currentRecipe.source_path) {
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl;
sourceUrlText.title = newSourceUrl.startsWith('http://') || newSourceUrl.startsWith('https://') ? 'Click to open source URL' : 'No valid URL';
// Update the recipe on the server
this.updateRecipeMetadata({ source_path: newSourceUrl });
}
// Hide editor
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
});
// Open source URL in a new tab if it's valid
sourceUrlText.addEventListener('click', () => {
const url = sourceUrlText.textContent.trim();
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
}
});
}
}
export { RecipeModal };

View File

@@ -4,7 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
/**
@@ -114,16 +114,6 @@ export function setupModelNameEditing(filePath) {
await saveModelMetadata(filePath, { model_name: newModelName });
// Update the corresponding checkpoint card's dataset and display
updateCheckpointCard(filePath, { model_name: newModelName });
// BUGFIX: Directly update the card's dataset.name attribute to ensure
// it's correctly read when reopening the modal
const checkpointCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (checkpointCard) {
checkpointCard.dataset.name = newModelName;
}
showToast('Model name updated successfully', 'success');
} catch (error) {
console.error('Error updating model name:', error);
@@ -300,9 +290,6 @@ async function saveBaseModel(filePath, originalValue) {
try {
await saveModelMetadata(filePath, { base_model: newBaseModel });
// Update the card with the new base model
updateCheckpointCard(filePath, { base_model: newBaseModel });
showToast('Base model updated successfully', 'success');
} catch (error) {
showToast('Failed to update base model', 'error');
@@ -425,30 +412,10 @@ export function setupFileNameEditing(filePath) {
if (result.success) {
showToast('File name updated successfully', 'success');
// Get the new file path from the result
const pathParts = filePath.split(/[\\/]/);
pathParts.pop(); // Remove old filename
const newFilePath = [...pathParts, newFileName].join('/');
const newFilePath = filePath.replace(originalValue, newFileName);
// Update the checkpoint card with new file path
updateCheckpointCard(filePath, {
filepath: newFilePath,
file_name: newFileName
});
// Update the file name display in the modal
document.querySelector('#file-name').textContent = newFileName;
// Update the modal's data-filepath attribute
const modalContent = document.querySelector('#checkpointModal .modal-content');
if (modalContent) {
modalContent.dataset.filepath = newFilePath;
}
// Reload the page after a short delay to reflect changes
setTimeout(() => {
window.location.reload();
}, 1500);
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
this.textContent = newFileName;
} else {
throw new Error(result.error || 'Unknown error');
}

View File

@@ -0,0 +1,471 @@
/**
* ModelTags.js
* Module for handling checkpoint model tag editing functionality
*/
import { showToast } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/checkpointApi.js';
// Preset tag suggestions
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing', 'base model',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
// Create a named function so we can remove it later
let saveTagsHandler = null;
/**
* Set up tag editing mode
*/
export function setupTagEditMode() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
// Store original tags for restoring on cancel
let originalTags = [];
// Remove any previously attached click handler
if (editBtn._hasClickHandler) {
editBtn.removeEventListener('click', editBtn._clickHandler);
}
// Create new handler and store reference
const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container');
const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact');
const tagsEditContainer = tagsSection.querySelector('.metadata-edit-container');
if (isEditMode) {
// Enter edit mode
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing";
// Get all tags from tooltip, not just the visible ones in compact display
originalTags = Array.from(
tagsSection.querySelectorAll('.tooltip-tag')
).map(tag => tag.textContent);
// Hide compact display, show edit container
compactTagsDisplay.style.display = 'none';
// If edit container doesn't exist yet, create it
if (!tagsEditContainer) {
const editContainer = document.createElement('div');
editContainer.className = 'metadata-edit-container';
// Move the edit button inside the container header for better visibility
const editBtnClone = editBtn.cloneNode(true);
editBtnClone.classList.add('metadata-header-btn');
// Create edit UI with edit button in the header
editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML);
tagsSection.appendChild(editContainer);
// Setup the tag input field behavior
setupTagInput();
// Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
// Setup delete buttons for existing tags
setupDeleteButtons();
// Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
if (newEditBtn) {
newEditBtn.addEventListener('click', function() {
editBtn.click();
});
}
// Hide the original button when in edit mode
editBtn.style.display = 'none';
} else {
// Just show the existing edit container
tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none';
}
} else {
// Exit edit mode
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit tags";
editBtn.style.display = 'block';
// Show compact display, hide edit container
compactTagsDisplay.style.display = 'flex';
if (tagsEditContainer) tagsEditContainer.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original tags
restoreOriginalTags(tagsSection, originalTags);
} else {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
}
};
// Store the handler reference on the button itself
editBtn._clickHandler = editBtnClickHandler;
editBtn._hasClickHandler = true;
editBtn.addEventListener('click', editBtnClickHandler);
// Clean up any previous document click handler
if (saveTagsHandler) {
document.removeEventListener('click', saveTagsHandler);
}
// Create new save handler and store reference
saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) {
saveTags();
}
};
// Add the new handler
document.addEventListener('click', saveTagsHandler);
}
/**
* Create the tag editing UI
* @param {Array} currentTags - Current tags
* @param {string} editBtnHTML - HTML for the edit button to include in header
* @returns {string} HTML markup for tag editing UI
*/
function createTagEditUI(currentTags, editBtnHTML = '') {
return `
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>Edit Tags</label>
${editBtnHTML}
</div>
<div class="metadata-items">
${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}">
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
<div class="metadata-edit-controls">
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* Create suggestions dropdown with preset tags
* @param {Array} existingTags - Already added tags
* @returns {HTMLElement} - Dropdown element
*/
function createSuggestionsDropdown(existingTags = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
dropdown.appendChild(header);
// Create tag container
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each preset tag as a suggestion
PRESET_TAGS.forEach(tag => {
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
item.innerHTML = `
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
addNewTag(tag);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateSuggestionsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
/**
* Set up tag input behavior
*/
function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
if (tagInput) {
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag(this.value);
this.value = ''; // Clear input after adding
}
});
}
}
/**
* Set up delete buttons for tags
*/
function setupDeleteButtons() {
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.metadata-item');
tag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
});
}
/**
* Add a new tag
* @param {string} tag - Tag to add
*/
function addNewTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items');
if (!tagsContainer) return;
// Validation: Check length
if (tag.length > 30) {
showToast('Tag should not exceed 30 characters', 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('Maximum 30 tags allowed', 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
if (existingTags.includes(tag)) {
showToast('This tag already exists', 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
newTag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
tagsContainer.appendChild(newTag);
// Update status of items in the suggestions dropdown
updateSuggestionsDropdown();
}
/**
* Update status of items in the suggestions dropdown
*/
function updateSuggestionsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current tags
const currentTags = document.querySelectorAll('.metadata-item');
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const tagText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingTags.includes(tagText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
item.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const tag = item.querySelector('.metadata-suggestion-text').textContent;
addNewTag(tag);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus the input
if (input) input.focus();
};
}
}
});
}
/**
* Restore original tags when canceling edit
* @param {HTMLElement} section - The tags section
* @param {Array} originalTags - Original tags array
*/
function restoreOriginalTags(section, originalTags) {
// Nothing to do here as we're just hiding the edit UI
// and showing the original compact tags which weren't modified
}
/**
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
if (!tagsChanged) {
// No changes made, just exit edit mode without API call
editBtn.dataset.skipRestore = "true";
editBtn.click();
return;
}
try {
// Save tags metadata
await saveModelMetadata(filePath, { tags: tags });
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
}
}

View File

@@ -1,346 +0,0 @@
/**
* ShowcaseView.js
* Handles showcase content (images, videos) display for checkpoint modal
*/
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop
} from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
/**
* Render showcase content
* @param {Array} images - Array of images/videos to show
* @param {string} modelHash - Model hash for identifying local files
* @param {Array} exampleFiles - Local example files already fetched
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, exampleFiles = []) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting
const showOnlySFW = state.settings.show_only_sfw;
let filteredImages = images;
let hiddenCount = 0;
if (showOnlySFW) {
filteredImages = images.filter(img => {
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const isSfw = nsfwLevel < NSFW_LEVELS.R;
if (!isSfw) hiddenCount++;
return isSfw;
});
}
// Show message if no images are available after filtering
if (filteredImages.length === 0) {
return `
<div class="no-examples">
<p>All example images are filtered due to NSFW content settings</p>
<p class="nsfw-filter-info">Your settings are currently set to show only safe-for-work content</p>
<p>You can change this in Settings <i class="fas fa-cog"></i></p>
</div>
`;
}
// Show hidden content notification if applicable
const hiddenNotification = hiddenCount > 0 ?
`<div class="nsfw-filter-notification">
<i class="fas fa-eye-slash"></i> ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
</div>` : '';
return `
<div class="scroll-indicator" onclick="toggleShowcase(this)">
<i class="fas fa-chevron-down"></i>
<span>Scroll or click to show ${filteredImages.length} examples</span>
</div>
<div class="carousel collapsed">
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => {
// Find matching file in our list of actual files
let localFile = null;
if (exampleFiles.length > 0) {
// Try to find the corresponding file by index first
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Calculate appropriate aspect ratio
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Check if this is a video or image
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}).join('')}
</div>
</div>
`;
}
/**
* Generate media wrapper HTML for an image or video
* @param {Object} media - Media object with image or video data
* @returns {string} HTML content
*/
function generateMediaWrapper(media, urls) {
// Calculate appropriate aspect ratio
const aspectRatio = (media.height / media.width) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
// Check if media should be blurred
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Extract metadata from the media
const meta = media.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${media.width}x${media.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Check if this is a video or image
if (media.type === 'video') {
return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
}
return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
}
/**
* Generate metadata panel HTML
*/
function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) {
// Create unique IDs for prompt copying
const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15);
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) {
content += `
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
`;
}
if (!hasParams && !hasPrompts) {
content += `
<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
<span>No generation parameters available</span>
</div>
`;
}
if (prompt) {
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
`;
}
if (negativePrompt) {
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
`;
}
content += '</div></div>';
return content;
}
/**
* Generate video wrapper HTML
*/
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
/**
* Generate image wrapper HTML
*/
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${media.width}"
height="${media.height}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
// Use the shared setupShowcaseScroll function with the correct modal ID
export { setupShowcaseScroll, scrollToTop, toggleShowcase };
// Initialize the showcase scroll when this module is imported
document.addEventListener('DOMContentLoaded', () => {
setupShowcaseScroll('checkpointModal');
});

View File

@@ -3,19 +3,23 @@
*
* Modularized checkpoint modal component that handles checkpoint model details display
*/
import { showToast, getExampleImageFiles, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from '../shared/showcase/ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing
import { saveModelMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/**
* Display the checkpoint modal with the given checkpoint data
@@ -46,7 +50,7 @@ export function showCheckpointModal(checkpoint) {
<span class="creator-username">${checkpoint.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(checkpoint.tags || [])}
${renderCompactTags(checkpoint.tags || [], checkpoint.file_path)}
</header>
<div class="modal-body">
@@ -102,7 +106,7 @@ export function showCheckpointModal(checkpoint) {
</div>
</div>
<div class="showcase-section" data-checkpoint-id="${checkpoint.civitai?.modelId || ''}">
<div class="showcase-section" data-model-hash="${checkpoint.sha256 || ''}" data-filepath="${checkpoint.file_path}">
<div class="showcase-tabs">
<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
@@ -137,9 +141,10 @@ export function showCheckpointModal(checkpoint) {
modalManager.showModal('checkpointModal', content);
setupEditableFields(checkpoint.file_path);
setupShowcaseScroll();
setupShowcaseScroll('checkpointModal');
setupTabSwitching();
setupTagTooltip();
setupTagEditMode(); // Initialize tag editing functionality
setupModelNameEditing(checkpoint.file_path);
setupBaseModelEditing(checkpoint.file_path);
setupFileNameEditing(checkpoint.file_path);
@@ -149,68 +154,12 @@ export function showCheckpointModal(checkpoint) {
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
}
// Load example images asynchronously
loadExampleImages(checkpoint.civitai?.images, checkpoint.sha256, checkpoint.file_path);
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects
* @param {string} modelHash - Model hash for fetching local files
* @param {string} filePath - File path for fetching local files
*/
async function loadExampleImages(images, modelHash, filePath) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
// Choose endpoint based on centralized examples setting
const useCentralized = state.global.settings.useCentralizedExamples !== false;
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
// Use different params based on endpoint
const params = useCentralized ?
`model_hash=${modelHash}` :
`file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel) {
// Only initialize if we actually have examples and they're expanded
if (!carousel.classList.contains('collapsed')) {
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
}
}
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
}
}
// Load example images asynchronously - merge regular and custom images
const regularImages = checkpoint.civitai?.images || [];
const customImages = checkpoint.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, checkpoint.sha256);
}
/**
@@ -261,9 +210,6 @@ async function saveNotes(filePath) {
try {
await saveModelMetadata(filePath, { notes: content });
// Update the corresponding checkpoint card's dataset
updateCheckpointCard(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');

View File

@@ -27,27 +27,35 @@ export function formatFileSize(bytes) {
/**
* Render compact tags
* @param {Array} tags - Array of tags
* @param {string} filePath - File path for the edit button
* @returns {string} HTML content
*/
export function renderCompactTags(tags) {
if (!tags || tags.length === 0) return '';
export function renderCompactTags(tags, filePath = '') {
// Remove the early return and always render the container
const tagsList = tags || [];
// Display up to 5 tags, with a tooltip indicator if there are more
const visibleTags = tags.slice(0, 5);
const remainingCount = Math.max(0, tags.length - 5);
const visibleTags = tagsList.slice(0, 5);
const remainingCount = Math.max(0, tagsList.length - 5);
return `
<div class="model-tags-container">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
<div class="model-tags-header">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
</div>
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${tags.length > 0 ?
${tagsList.length > 0 ?
`<div class="model-tags-tooltip">
<div class="tooltip-content">
${tags.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
${tagsList.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
</div>` :
''}

View File

@@ -4,7 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { updateLoraCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js';
/**
@@ -115,16 +115,6 @@ export function setupModelNameEditing(filePath) {
await saveModelMetadata(filePath, { model_name: newModelName });
// Update the corresponding lora card's dataset and display
updateLoraCard(filePath, { model_name: newModelName });
// BUGFIX: Directly update the card's dataset.name attribute to ensure
// it's correctly read when reopening the modal
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
loraCard.dataset.name = newModelName;
}
showToast('Model name updated successfully', 'success');
} catch (error) {
console.error('Error updating model name:', error);
@@ -142,40 +132,6 @@ export function setupModelNameEditing(filePath) {
}
}
/**
* 保存模型名称
* @param {string} filePath - 文件路径
*/
async function saveModelName(filePath) {
const modelNameElement = document.querySelector('.model-name-content');
const newModelName = modelNameElement.textContent.trim();
// Validate model name
if (!newModelName) {
showToast('Model name cannot be empty', 'error');
return;
}
// Check if model name is too long (limit to 100 characters)
if (newModelName.length > 100) {
showToast('Model name is too long (maximum 100 characters)', 'error');
// Truncate the displayed text
modelNameElement.textContent = newModelName.substring(0, 100);
return;
}
try {
await saveModelMetadata(filePath, { model_name: newModelName });
// Update the corresponding lora card's dataset and display
updateLoraCard(filePath, { model_name: newModelName });
showToast('Model name updated successfully', 'success');
} catch (error) {
showToast('Failed to update model name', 'error');
}
}
/**
* 设置基础模型编辑功能
* @param {string} filePath - 文件路径
@@ -338,9 +294,6 @@ async function saveBaseModel(filePath, originalValue) {
try {
await saveModelMetadata(filePath, { base_model: newBaseModel });
// Update the corresponding lora card's dataset
updateLoraCard(filePath, { base_model: newBaseModel });
showToast('Base model updated successfully', 'success');
} catch (error) {
showToast('Failed to update base model', 'error');
@@ -467,8 +420,8 @@ export function setupFileNameEditing(filePath) {
// Get the new file path and update the card
const newFilePath = filePath.replace(originalValue, newFileName);
// Pass the new file_name in the updates object for proper card update
updateLoraCard(filePath, { file_name: newFileName }, newFilePath);
;
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
} else {
throw new Error(result.error || 'Unknown error');
}

View File

@@ -0,0 +1,471 @@
/**
* ModelTags.js
* Module for handling model tag editing functionality
*/
import { showToast } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';
// Preset tag suggestions
const PRESET_TAGS = [
'character', 'style', 'concept', 'clothing',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
// Create a named function so we can remove it later
let saveTagsHandler = null;
/**
* Set up tag editing mode
*/
export function setupTagEditMode() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
// Store original tags for restoring on cancel
let originalTags = [];
// Remove any previously attached click handler
if (editBtn._hasClickHandler) {
editBtn.removeEventListener('click', editBtn._clickHandler);
}
// Create new handler and store reference
const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container');
const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact');
const tagsEditContainer = tagsSection.querySelector('.metadata-edit-container');
if (isEditMode) {
// Enter edit mode
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing";
// Get all tags from tooltip, not just the visible ones in compact display
originalTags = Array.from(
tagsSection.querySelectorAll('.tooltip-tag')
).map(tag => tag.textContent);
// Hide compact display, show edit container
compactTagsDisplay.style.display = 'none';
// If edit container doesn't exist yet, create it
if (!tagsEditContainer) {
const editContainer = document.createElement('div');
editContainer.className = 'metadata-edit-container';
// Move the edit button inside the container header for better visibility
const editBtnClone = editBtn.cloneNode(true);
editBtnClone.classList.add('metadata-header-btn');
// Create edit UI with edit button in the header
editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML);
tagsSection.appendChild(editContainer);
// Setup the tag input field behavior
setupTagInput();
// Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
// Setup delete buttons for existing tags
setupDeleteButtons();
// Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
if (newEditBtn) {
newEditBtn.addEventListener('click', function() {
editBtn.click();
});
}
// Hide the original button when in edit mode
editBtn.style.display = 'none';
} else {
// Just show the existing edit container
tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none';
}
} else {
// Exit edit mode
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit tags";
editBtn.style.display = 'block';
// Show compact display, hide edit container
compactTagsDisplay.style.display = 'flex';
if (tagsEditContainer) tagsEditContainer.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original tags
restoreOriginalTags(tagsSection, originalTags);
} else {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
}
};
// Store the handler reference on the button itself
editBtn._clickHandler = editBtnClickHandler;
editBtn._hasClickHandler = true;
editBtn.addEventListener('click', editBtnClickHandler);
// Clean up any previous document click handler
if (saveTagsHandler) {
document.removeEventListener('click', saveTagsHandler);
}
// Create new save handler and store reference
saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) {
saveTags();
}
};
// Add the new handler
document.addEventListener('click', saveTagsHandler);
}
/**
* Create the tag editing UI
* @param {Array} currentTags - Current tags
* @param {string} editBtnHTML - HTML for the edit button to include in header
* @returns {string} HTML markup for tag editing UI
*/
function createTagEditUI(currentTags, editBtnHTML = '') {
return `
<div class="metadata-edit-content">
<div class="metadata-edit-header">
<label>Edit Tags</label>
${editBtnHTML}
</div>
<div class="metadata-items">
${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}">
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
<div class="metadata-edit-controls">
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="metadata-add-form">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* Create suggestions dropdown with preset tags
* @param {Array} existingTags - Already added tags
* @returns {HTMLElement} - Dropdown element
*/
function createSuggestionsDropdown(existingTags = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
dropdown.appendChild(header);
// Create tag container
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each preset tag as a suggestion
PRESET_TAGS.forEach(tag => {
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
item.innerHTML = `
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
addNewTag(tag);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateSuggestionsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
/**
* Set up tag input behavior
*/
function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
if (tagInput) {
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag(this.value);
this.value = ''; // Clear input after adding
}
});
}
}
/**
* Set up delete buttons for tags
*/
function setupDeleteButtons() {
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.metadata-item');
tag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
});
}
/**
* Add a new tag
* @param {string} tag - Tag to add
*/
function addNewTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items');
if (!tagsContainer) return;
// Validation: Check length
if (tag.length > 30) {
showToast('Tag should not exceed 30 characters', 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('Maximum 30 tags allowed', 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
if (existingTags.includes(tag)) {
showToast('This tag already exists', 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
newTag.remove();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
tagsContainer.appendChild(newTag);
// Update status of items in the suggestions dropdown
updateSuggestionsDropdown();
}
/**
* Update status of items in the suggestions dropdown
*/
function updateSuggestionsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current tags
const currentTags = document.querySelectorAll('.metadata-item');
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const tagText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingTags.includes(tagText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
item.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const tag = item.querySelector('.metadata-suggestion-text').textContent;
addNewTag(tag);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus the input
if (input) input.focus();
};
}
}
});
}
/**
* Restore original tags when canceling edit
* @param {HTMLElement} section - The tags section
* @param {Array} originalTags - Original tags array
*/
function restoreOriginalTags(section, originalTags) {
// Nothing to do here as we're just hiding the edit UI
// and showing the original compact tags which weren't modified
}
/**
* Save tags
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags);
if (!tagsChanged) {
// No changes made, just exit edit mode without API call
editBtn.dataset.skipRestore = "true";
editBtn.click();
return;
}
try {
// Save tags metadata
await saveModelMetadata(filePath, { tags: tags });
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Exit edit mode
editBtn.click();
showToast('Tags updated successfully', 'success');
} catch (error) {
console.error('Error saving tags:', error);
showToast('Failed to update tags', 'error');
}
}

View File

@@ -62,6 +62,5 @@ window.removePreset = async function(key) {
usage_tips: newPresetsJson
});
loraCard.dataset.usage_tips = newPresetsJson;
document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets);
};

View File

@@ -102,13 +102,10 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
card.dataset.id = recipe.id || '';
card.innerHTML = `
<div class="recipe-indicator" title="Recipe">R</div>
<div class="card-preview">
<img src="${imageUrl}" alt="${recipe.title}" loading="lazy">
<div class="card-header">
<div class="base-model-wrapper">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
</div>
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
<div class="card-actions">
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
</div>

View File

@@ -1,280 +0,0 @@
/**
* ShowcaseView.js
* 处理LoRA模型展示内容图片、视频的功能模块
*/
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop
} from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
/**
* 获取展示内容并进行渲染
* @param {Array} images - 要展示的图片/视频数组
* @param {Array} exampleFiles - Local example files already fetched
* @returns {Promise<string>} HTML内容
*/
export function renderShowcaseContent(images, exampleFiles = []) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting
const showOnlySFW = state.settings.show_only_sfw;
let filteredImages = images;
let hiddenCount = 0;
if (showOnlySFW) {
filteredImages = images.filter(img => {
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const isSfw = nsfwLevel < NSFW_LEVELS.R;
if (!isSfw) hiddenCount++;
return isSfw;
});
}
// Show message if no images are available after filtering
if (filteredImages.length === 0) {
return `
<div class="no-examples">
<p>All example images are filtered due to NSFW content settings</p>
<p class="nsfw-filter-info">Your settings are currently set to show only safe-for-work content</p>
<p>You can change this in Settings <i class="fas fa-cog"></i></p>
</div>
`;
}
// Show hidden content notification if applicable
const hiddenNotification = hiddenCount > 0 ?
`<div class="nsfw-filter-notification">
<i class="fas fa-eye-slash"></i> ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
</div>` : '';
return `
<div class="scroll-indicator" onclick="toggleShowcase(this)">
<i class="fas fa-chevron-down"></i>
<span>Scroll or click to show ${filteredImages.length} examples</span>
</div>
<div class="carousel collapsed">
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => {
// Find matching file in our list of actual files
let localFile = null;
if (exampleFiles.length > 0) {
// Try to find the corresponding file by index first
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// 计算适当的展示高度
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800;
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
// Check if image should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}).join('')}
</div>
</div>
`;
}
/**
* Generate metadata panel HTML
*/
function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) {
// Create unique IDs for prompt copying
const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15);
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) {
content += `
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
`;
}
if (!hasParams && !hasPrompts) {
content += `
<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
<span>No generation parameters available</span>
</div>
`;
}
if (prompt) {
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
`;
}
if (negativePrompt) {
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
`;
}
content += '</div></div>';
return content;
}
/**
* 生成视频包装HTML
*/
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
/**
* 生成图片包装HTML
*/
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${img.width}"
height="${img.height}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
// Use the shared setupShowcaseScroll function with the correct modal ID
export { setupShowcaseScroll, scrollToTop, toggleShowcase };
// Initialize the showcase scroll when this module is imported
document.addEventListener('DOMContentLoaded', () => {
setupShowcaseScroll('loraModal');
});

View File

@@ -39,17 +39,17 @@ async function fetchTrainedWords(filePath) {
*/
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
const dropdown = document.createElement('div');
dropdown.className = 'trained-words-dropdown';
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'trained-words-header';
header.className = 'metadata-suggestions-header';
// No suggestions case
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
header.innerHTML = '<span>No suggestions available</span>';
dropdown.appendChild(header);
dropdown.innerHTML += '<div class="no-trained-words">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
dropdown.innerHTML += '<div class="no-suggestions">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
return dropdown;
}
@@ -62,7 +62,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
if (classTokens) {
// Add class tokens header
const classTokensHeader = document.createElement('div');
classTokensHeader.className = 'trained-words-header';
classTokensHeader.className = 'metadata-suggestions-header';
classTokensHeader.innerHTML = `
<span>Class Token</span>
<small>Add to your prompt for best results</small>
@@ -75,11 +75,11 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
// Create a special item for the class token
const tokenItem = document.createElement('div');
tokenItem.className = `trained-word-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `Class token: ${classTokens}`;
tokenItem.innerHTML = `
<span class="trained-word-text">${classTokens}</span>
<div class="trained-word-meta">
<span class="metadata-suggestion-text">${classTokens}</span>
<div class="metadata-suggestion-meta">
<span class="token-badge">Class Token</span>
${existingWords.includes(classTokens) ?
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
@@ -93,7 +93,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
addNewTriggerWord(classTokens);
// Also populate the input field for potential editing
const input = document.querySelector('.new-trigger-word-input');
const input = document.querySelector('.metadata-input');
if (input) input.value = classTokens;
// Focus on the input
@@ -125,18 +125,18 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
// Create tag container for trained words
const container = document.createElement('div');
container.className = 'trained-words-container';
container.className = 'metadata-suggestions-container';
// Add each trained word as a tag
trainedWords.forEach(([word, frequency]) => {
const isAdded = existingWords.includes(word);
const item = document.createElement('div');
item.className = `trained-word-item ${isAdded ? 'already-added' : ''}`;
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = word; // Show full word on hover if truncated
item.innerHTML = `
<span class="trained-word-text">${word}</span>
<div class="trained-word-meta">
<span class="metadata-suggestion-text">${word}</span>
<div class="metadata-suggestion-meta">
<span class="trained-word-freq">${frequency}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
</div>
@@ -148,7 +148,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
addNewTriggerWord(word);
// Also populate the input field for potential editing
const input = document.querySelector('.new-trigger-word-input');
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus on the input
@@ -165,7 +165,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
dropdown.appendChild(container);
} else if (!classTokens) {
// If we have neither class tokens nor trained words
dropdown.innerHTML += '<div class="no-trained-words">No word suggestions found in this model. You can manually enter trigger words.</div>';
dropdown.innerHTML += '<div class="no-suggestions">No word suggestions found in this model. You can manually enter trigger words.</div>';
}
return dropdown;
@@ -182,7 +182,7 @@ export function renderTriggerWords(words, filePath) {
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
<label>Trigger Words</label>
<button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
@@ -190,13 +190,13 @@ export function renderTriggerWords(words, filePath) {
<span class="no-trigger-words">No trigger word needed</span>
<div class="trigger-words-tags" style="display:none;"></div>
</div>
<div class="trigger-words-edit-controls" style="display:none;">
<button class="save-trigger-words-btn" title="Save changes">
<div class="metadata-edit-controls" style="display:none;">
<button class="metadata-save-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="add-trigger-word-form" style="display:none;">
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
<div class="metadata-add-form" style="display:none;">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
@@ -205,7 +205,7 @@ export function renderTriggerWords(words, filePath) {
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
<label>Trigger Words</label>
<button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
@@ -217,20 +217,20 @@ export function renderTriggerWords(words, filePath) {
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
</div>
</div>
<div class="trigger-words-edit-controls" style="display:none;">
<button class="save-trigger-words-btn" title="Save changes">
<div class="metadata-edit-controls" style="display:none;">
<button class="metadata-save-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="add-trigger-word-form" style="display:none;">
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
<div class="metadata-add-form" style="display:none;">
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
@@ -257,8 +257,8 @@ export function setupTriggerWordsEditMode() {
// Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls');
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
@@ -284,7 +284,7 @@ export function setupTriggerWordsEditMode() {
triggerWordTags.forEach(tag => {
tag.onclick = null;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.delete-trigger-word-btn');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
if (copyIcon) copyIcon.style.display = 'none';
if (deleteBtn) {
@@ -300,7 +300,7 @@ export function setupTriggerWordsEditMode() {
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'trained-words-loading';
loadingIndicator.className = 'metadata-loading';
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
addForm.appendChild(loadingIndicator);
@@ -354,13 +354,13 @@ export function setupTriggerWordsEditMode() {
}
// Remove dropdown if present
const dropdown = document.querySelector('.trained-words-dropdown');
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
}
});
// Set up input for adding trigger words
const triggerWordInput = document.querySelector('.new-trigger-word-input');
const triggerWordInput = document.querySelector('.metadata-input');
if (triggerWordInput) {
// Add keydown event to input
@@ -374,13 +374,13 @@ export function setupTriggerWordsEditMode() {
}
// Set up save button
const saveBtn = document.querySelector('.save-trigger-words-btn');
const saveBtn = document.querySelector('.metadata-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', saveTriggerWords);
}
// Set up delete buttons
document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => {
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
// Remove any existing listeners to avoid duplication
btn.removeEventListener('click', deleteTriggerWord);
btn.addEventListener('click', deleteTriggerWord);
@@ -410,7 +410,7 @@ function resetTriggerWordsUIState(section) {
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.delete-trigger-word-btn');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(word);
@@ -456,7 +456,7 @@ function restoreOriginalTriggerWords(section, originalWords) {
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
@@ -525,13 +525,13 @@ function addNewTriggerWord(word) {
<span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i>
</span>
<button class="delete-trigger-word-btn" onclick="event.stopPropagation();">
<button class="metadata-delete-btn" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
tagsContainer.appendChild(newTag);
@@ -544,7 +544,7 @@ function addNewTriggerWord(word) {
* Update status of items in the trained words dropdown
*/
function updateTrainedWordsDropdown() {
const dropdown = document.querySelector('.trained-words-dropdown');
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current trigger words
@@ -552,8 +552,8 @@ function updateTrainedWordsDropdown() {
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Update status of each item in dropdown
dropdown.querySelectorAll('.trained-word-item').forEach(item => {
const wordText = item.querySelector('.trained-word-text').textContent;
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingWords.includes(wordText);
if (isAdded) {
@@ -562,7 +562,7 @@ function updateTrainedWordsDropdown() {
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
const meta = item.querySelector('.trained-word-meta');
const meta = item.querySelector('.metadata-suggestion-meta');
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
@@ -582,11 +582,11 @@ function updateTrainedWordsDropdown() {
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const word = item.querySelector('.trained-word-text').textContent;
const word = item.querySelector('.metadata-suggestion-text').textContent;
addNewTriggerWord(word);
// Also populate the input field
const input = document.querySelector('.new-trigger-word-input');
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus the input
@@ -619,28 +619,6 @@ async function saveTriggerWords() {
// Exit edit mode without restoring original trigger words
editBtn.click();
// Update the LoRA card's dataset
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
try {
// Create a proper structure for civitai data
let civitaiData = {};
// Parse existing data if available
if (loraCard.dataset.meta) {
civitaiData = JSON.parse(loraCard.dataset.meta);
}
// Update trainedWords property
civitaiData.trainedWords = words;
// Update the meta dataset attribute with the full civitai data
loraCard.dataset.meta = JSON.stringify(civitaiData);
} catch (e) {
console.error('Error updating civitai data:', e);
}
}
// If we saved an empty array and there's a no-trigger-words element, show it
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');

View File

@@ -3,13 +3,18 @@
*
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
*/
import { showToast, copyToClipboard, getExampleImageFiles } from '../../utils/uiHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import {
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from '../shared/showcase/ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { loadRecipesForLora } from './RecipeTab.js'; // Add import for recipe tab
import { loadRecipesForLora } from './RecipeTab.js';
import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing
import {
setupModelNameEditing,
setupBaseModelEditing,
@@ -17,8 +22,6 @@ import {
} from './ModelMetadata.js';
import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateLoraCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/**
* 显示LoRA模型弹窗
@@ -52,7 +55,7 @@ export function showLoraModal(lora) {
<span class="creator-username">${lora.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(lora.tags || [])}
${renderCompactTags(lora.tags || [], lora.file_path)}
</header>
<div class="modal-body">
@@ -113,12 +116,9 @@ export function showLoraModal(lora) {
</div>
${renderTriggerWords(escapedWords, lora.file_path)}
<div class="info-item notes">
<label>Additional Notes</label>
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
<button class="save-btn" onclick="saveNotes('${lora.file_path}')">
<i class="fas fa-save"></i>
</button>
</div>
</div>
<div class="info-item full-width">
@@ -128,7 +128,7 @@ export function showLoraModal(lora) {
</div>
</div>
<div class="showcase-section" data-lora-id="${lora.civitai?.modelId || ''}">
<div class="showcase-section" data-model-hash="${lora.sha256 || ''}" data-filepath="${lora.file_path}">
<div class="showcase-tabs">
<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
@@ -160,7 +160,7 @@ export function showLoraModal(lora) {
</div>
</div>
<button class="back-to-top" onclick="scrollToTop(this)">
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div>
@@ -168,15 +168,24 @@ export function showLoraModal(lora) {
</div>
`;
modalManager.showModal('loraModal', content);
modalManager.showModal('loraModal', content, null, function() {
// Clean up all handlers when modal closes
const modalElement = document.getElementById('loraModal');
if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
});
setupEditableFields(lora.file_path);
setupShowcaseScroll();
setupShowcaseScroll('loraModal');
setupTabSwitching();
setupTagTooltip();
setupTriggerWordsEditMode();
setupModelNameEditing(lora.file_path);
setupBaseModelEditing(lora.file_path);
setupFileNameEditing(lora.file_path);
setupTagEditMode(); // Initialize tag editing functionality
setupEventHandlers(lora.file_path);
// If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) {
@@ -186,90 +195,53 @@ export function showLoraModal(lora) {
// Load recipes for this Lora
loadRecipesForLora(lora.model_name, lora.sha256);
// Load example images asynchronously
loadExampleImages(lora.civitai?.images, lora.sha256, lora.file_path);
// Load example images asynchronously - merge regular and custom images
const regularImages = lora.civitai?.images || [];
const customImages = lora.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, lora.sha256);
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects
* @param {string} modelHash - Model hash for fetching local files
* @param {string} filePath - File path for fetching local files
* Sets up event handlers using event delegation
* @param {string} filePath - Path to the model file
*/
async function loadExampleImages(images, modelHash, filePath) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
function setupEventHandlers(filePath) {
const modalElement = document.getElementById('loraModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
// First fetch local example files
let localFiles = [];
try {
// Choose endpoint based on centralized examples setting
const useCentralized = state.global.settings.useCentralizedExamples !== false;
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
// Use different params based on endpoint
const params = useCentralized ?
`model_hash=${modelHash}` :
`file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
const action = target.dataset.action;
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel) {
// Only initialize if we actually have examples and they're expanded
if (!carousel.classList.contains('collapsed')) {
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
}
}
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
switch (action) {
case 'close-modal':
modalManager.closeModal('loraModal');
break;
case 'scroll-to-top':
scrollToTop(target);
break;
}
}
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick;
}
// Copy file name function
window.copyFileName = async function(fileName) {
try {
await copyToClipboard(fileName, 'File name copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
};
// Add save note function
window.saveNotes = async function(filePath) {
async function saveNotes(filePath) {
const content = document.querySelector('.notes-content').textContent;
try {
await saveModelMetadata(filePath, { notes: content });
// Update the corresponding lora card's dataset
updateLoraCard(filePath, { notes: content });
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
@@ -334,9 +306,6 @@ function setupEditableFields(filePath) {
usage_tips: newPresetsJson
});
// Update the card with the new usage tips
updateLoraCard(filePath, { usage_tips: newPresetsJson });
presetTags.innerHTML = renderPresetTags(currentPresets);
presetSelector.value = '';
@@ -366,7 +335,4 @@ function setupEditableFields(filePath) {
addPresetBtn.click();
}
});
}
// Export functions for global access
export { toggleShowcase, scrollToTop };
}

View File

@@ -26,27 +26,35 @@ export function formatFileSize(bytes) {
/**
* 渲染紧凑标签
* @param {Array} tags - 标签数组
* @param {string} filePath - 文件路径,用于编辑按钮
* @returns {string} HTML内容
*/
export function renderCompactTags(tags) {
if (!tags || tags.length === 0) return '';
export function renderCompactTags(tags, filePath = '') {
// Remove the early return and always render the container
const tagsList = tags || [];
// Display up to 5 tags, with a tooltip indicator if there are more
const visibleTags = tags.slice(0, 5);
const remainingCount = Math.max(0, tags.length - 5);
const visibleTags = tagsList.slice(0, 5);
const remainingCount = Math.max(0, tagsList.length - 5);
return `
<div class="model-tags-container">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
<div class="model-tags-header">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
</div>
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${tags.length > 0 ?
${tagsList.length > 0 ?
`<div class="model-tags-tooltip">
<div class="tooltip-content">
${tags.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
${tagsList.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
</div>` :
''}

View File

@@ -0,0 +1,94 @@
/**
* MediaRenderers.js
* HTML generators for media items (images/videos) in the showcase
*/
/**
* Generate video wrapper HTML
* @param {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML
* @param {string} localUrl - Local file URL
* @param {string} remoteUrl - Remote file URL
* @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content
*/
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
${mediaControlsHtml}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
data-nsfw-level="${nsfwLevel}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
/**
* Generate image wrapper HTML
* @param {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML
* @param {string} localUrl - Local file URL
* @param {string} remoteUrl - Remote file URL
* @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content
*/
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
${mediaControlsHtml}
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
data-nsfw-level="${nsfwLevel}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${media.width}"
height="${media.height}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}

View File

@@ -0,0 +1,592 @@
/**
* MediaUtils.js
* Media-specific utility functions for showcase components
* (Moved from uiHelpers.js to better organize code)
*/
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { uploadPreview } from '../../../api/baseModelApi.js';
/**
* Try to load local image first, fall back to remote if local fails
* @param {HTMLImageElement} imgElement - The image element to update
* @param {Object} urls - Object with local URLs {primary, fallback} and remote URL
*/
export function tryLocalImageOrFallbackToRemote(imgElement, urls) {
const { primary: localUrl, fallback: fallbackUrl } = urls.local || {};
const remoteUrl = urls.remote;
// If no local options, use remote directly
if (!localUrl) {
imgElement.src = remoteUrl;
return;
}
// Try primary local URL
const testImg = new Image();
testImg.onload = () => {
// Primary local image loaded successfully
imgElement.src = localUrl;
};
testImg.onerror = () => {
// Try fallback URL if available
if (fallbackUrl) {
const fallbackImg = new Image();
fallbackImg.onload = () => {
imgElement.src = fallbackUrl;
};
fallbackImg.onerror = () => {
// Both local options failed, use remote
imgElement.src = remoteUrl;
};
fallbackImg.src = fallbackUrl;
} else {
// No fallback, use remote
imgElement.src = remoteUrl;
}
};
testImg.src = localUrl;
}
/**
* Try to load local video first, fall back to remote if local fails
* @param {HTMLVideoElement} videoElement - The video element to update
* @param {Object} urls - Object with local URLs {primary} and remote URL
*/
export function tryLocalVideoOrFallbackToRemote(videoElement, urls) {
const { primary: localUrl } = urls.local || {};
const remoteUrl = urls.remote;
// Only try local if we have a local path
if (localUrl) {
// Try to fetch local file headers to see if it exists
fetch(localUrl, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localUrl;
const source = videoElement.querySelector('source');
if (source) source.src = localUrl;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
videoElement.load();
}
}
/**
* Initialize lazy loading for images and videos in a container
* @param {HTMLElement} container - The container with lazy-loadable elements
*/
export function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
// Get URLs from data attributes
const localUrls = {
primary: element.dataset.localSrc || null,
fallback: element.dataset.localFallbackSrc || null
};
const remoteUrl = element.dataset.remoteSrc;
const urls = {
local: localUrls,
remote: remoteUrl
};
// Check if element is a video or image
if (element.tagName.toLowerCase() === 'video') {
tryLocalVideoOrFallbackToRemote(element, urls);
} else {
tryLocalImageOrFallbackToRemote(element, urls);
}
element.classList.remove('lazy');
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyLoad(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyElements.forEach(element => observer.observe(element));
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
export function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* Initialize metadata panel interaction handlers
* @param {HTMLElement} container - Container element with media wrappers
*/
export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel and controls when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
if (metadataPanel) {
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
}
});
}
/**
* Initialize NSFW content blur toggle handlers
* @param {HTMLElement} container - Container element with media wrappers
*/
export function initNsfwBlurHandlers(container) {
// Handle toggle blur buttons
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
toggleButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
const isBlurred = media.classList.toggle('blurred');
const icon = btn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
});
// Handle "Show" buttons in overlays
const showButtons = container.querySelectorAll('.show-content-btn');
showButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
media.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
});
}
/**
* Initialize media control buttons event handlers
* @param {HTMLElement} container - Container with media wrappers
*/
export function initMediaControlHandlers(container) {
// Find all delete buttons in the container
const deleteButtons = container.querySelectorAll('.example-delete-btn');
deleteButtons.forEach(btn => {
// Set initial state
btn.dataset.state = 'initial';
btn.addEventListener('click', async function(e) {
e.stopPropagation();
// Explicitly check for disabled state
if (this.classList.contains('disabled')) {
return; // Don't do anything if button is disabled
}
const shortId = this.dataset.shortId;
const btnState = this.dataset.state;
if (!shortId) return;
// Handle two-step confirmation
if (btnState === 'initial') {
// First click: show confirmation state
this.dataset.state = 'confirm';
this.classList.add('confirm');
this.title = 'Click again to confirm deletion';
// Auto-reset after 3 seconds
setTimeout(() => {
if (this.dataset.state === 'confirm') {
this.dataset.state = 'initial';
this.classList.remove('confirm');
this.title = 'Delete this example';
}
}, 3000);
return;
}
// Second click within 3 seconds: proceed with deletion
if (btnState === 'confirm') {
this.disabled = true;
this.classList.remove('confirm');
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
// Get model hash from URL or data attribute
const mediaWrapper = this.closest('.media-wrapper');
const modelHashAttr = document.querySelector('.showcase-section')?.dataset;
const modelHash = modelHashAttr?.modelHash;
try {
// Call the API to delete the custom example
const response = await fetch('/api/delete-example-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hash: modelHash,
short_id: shortId
})
});
const result = await response.json();
if (result.success) {
// Success: remove the media wrapper from the DOM
mediaWrapper.style.opacity = '0';
mediaWrapper.style.height = '0';
mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s';
setTimeout(() => {
mediaWrapper.remove();
}, 600);
// Show success toast
showToast('Example image deleted', 'success');
// Create an update object with only the necessary properties
const updateData = {
civitai: {
customImages: result.custom_images || []
}
};
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
} else {
// Show error message
showToast(result.error || 'Failed to delete example image', 'error');
// Reset button state
this.disabled = false;
this.dataset.state = 'initial';
this.classList.remove('confirm');
this.innerHTML = '<i class="fas fa-trash-alt"></i>';
this.title = 'Delete this example';
}
} catch (error) {
console.error('Error deleting example image:', error);
showToast('Failed to delete example image', 'error');
// Reset button state
this.disabled = false;
this.dataset.state = 'initial';
this.classList.remove('confirm');
this.innerHTML = '<i class="fas fa-trash-alt"></i>';
this.title = 'Delete this example';
}
}
});
});
// Initialize set preview buttons
initSetPreviewHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers
// Any click handlers or other functionality can still be added here
}
/**
* Initialize set preview button handlers
* @param {HTMLElement} container - Container with media wrappers
*/
function initSetPreviewHandlers(container) {
const previewButtons = container.querySelectorAll('.set-preview-btn');
const modelType = state.currentPageType == 'loras' ? 'lora' : 'checkpoint';
previewButtons.forEach(btn => {
btn.addEventListener('click', async function(e) {
e.stopPropagation();
// Show loading state
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
this.disabled = true;
try {
// Get the model file path from showcase section data attribute
const showcaseSection = document.querySelector('.showcase-section');
const modelHash = showcaseSection?.dataset.modelHash;
const modelFilePath = showcaseSection?.dataset.filepath;
if (!modelFilePath) {
throw new Error('Could not determine model file path');
}
// Get the media wrapper and media element
const mediaWrapper = this.closest('.media-wrapper');
const mediaElement = mediaWrapper.querySelector('img, video');
if (!mediaElement) {
throw new Error('Media element not found');
}
// Get NSFW level from the wrapper or media element
const nsfwLevel = parseInt(mediaWrapper.dataset.nsfwLevel || mediaElement.dataset.nsfwLevel || '0', 10);
// Get local file path if available
const useLocalFile = mediaElement.dataset.localSrc && !mediaElement.dataset.localSrc.includes('undefined');
if (useLocalFile) {
// We have a local file, use it directly
const response = await fetch(mediaElement.dataset.localSrc);
const blob = await response.blob();
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);
} else {
// We need to download the remote file first
const response = await fetch(mediaElement.src);
const blob = await response.blob();
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);
}
} catch (error) {
console.error('Error setting preview:', error);
showToast('Failed to set preview image', 'error');
} finally {
// Restore button state
this.innerHTML = '<i class="fas fa-image"></i>';
this.disabled = false;
}
});
});
}
/**
* Position media controls within the actual rendered media rectangle
* @param {HTMLElement} mediaWrapper - The wrapper containing the media and controls
*/
export function positionMediaControlsInMediaRect(mediaWrapper) {
const mediaElement = mediaWrapper.querySelector('img, video');
const controlsElement = mediaWrapper.querySelector('.media-controls');
if (!mediaElement || !controlsElement) return;
// Get wrapper dimensions
const wrapperRect = mediaWrapper.getBoundingClientRect();
// Calculate the actual rendered media rectangle
const mediaRect = getRenderedMediaRect(
mediaElement,
wrapperRect.width,
wrapperRect.height
);
// Calculate the position for controls - place them inside the actual media area
const padding = 8; // Padding from the edge of the media
// Position at top-right inside the actual media rectangle
controlsElement.style.top = `${mediaRect.top + padding}px`;
controlsElement.style.right = `${wrapperRect.width - mediaRect.right + padding}px`;
// Also position any toggle blur buttons in the same way but on the left
const toggleBlurBtn = mediaWrapper.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.style.top = `${mediaRect.top + padding}px`;
toggleBlurBtn.style.left = `${mediaRect.left + padding}px`;
}
}
/**
* Position all media controls in a container
* @param {HTMLElement} container - Container with media wrappers
*/
export function positionAllMediaControls(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
positionMediaControlsInMediaRect(wrapper);
});
}

View File

@@ -0,0 +1,83 @@
/**
* MetadataPanel.js
* Generates metadata panels for showcase media items
*/
/**
* Generate metadata panel HTML
* @param {boolean} hasParams - Whether there are generation parameters
* @param {boolean} hasPrompts - Whether there are prompts
* @param {string} prompt - Prompt text
* @param {string} negativePrompt - Negative prompt text
* @param {string} size - Image size
* @param {string} seed - Generation seed
* @param {string} model - Model used
* @param {string} steps - Steps used
* @param {string} sampler - Sampler used
* @param {string} cfgScale - CFG scale
* @param {string} clipSkip - Clip skip value
* @returns {string} HTML content
*/
export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) {
// Create unique IDs for prompt copying
const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15);
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) {
content += `
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
`;
}
if (!hasParams && !hasPrompts) {
content += `
<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
<span>No generation parameters available</span>
</div>
`;
}
if (prompt) {
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
`;
}
if (negativePrompt) {
content += `
<div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
`;
}
content += '</div></div>';
return content;
}

View File

@@ -0,0 +1,589 @@
/**
* ShowcaseView.js
* Shared showcase component for displaying examples in model modals (Lora/Checkpoint)
*/
import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { NSFW_LEVELS } from '../../../utils/constants.js';
import {
initLazyLoading,
initNsfwBlurHandlers,
initMetadataPanelHandlers,
initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects (both regular and custom)
* @param {string} modelHash - Model hash for fetching local files
*/
export async function loadExampleImages(images, modelHash) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
const endpoint = '/api/example-image-files';
const params = `model_hash=${modelHash}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the example import functionality
initExampleImport(modelHash, showcaseTab);
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
}
}
}
/**
* Render showcase content
* @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files
* @param {boolean} startExpanded - Whether to start in expanded state
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
if (!images?.length) {
// Show empty state with import interface
return renderImportInterface(true);
}
// Filter images based on SFW setting
const showOnlySFW = state.settings.show_only_sfw;
let filteredImages = images;
let hiddenCount = 0;
if (showOnlySFW) {
filteredImages = images.filter(img => {
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const isSfw = nsfwLevel < NSFW_LEVELS.R;
if (!isSfw) hiddenCount++;
return isSfw;
});
}
// Show message if no images are available after filtering
if (filteredImages.length === 0) {
return `
<div class="no-examples">
<p>All example images are filtered due to NSFW content settings</p>
<p class="nsfw-filter-info">Your settings are currently set to show only safe-for-work content</p>
<p>You can change this in Settings <i class="fas fa-cog"></i></p>
</div>
`;
}
// Show hidden content notification if applicable
const hiddenNotification = hiddenCount > 0 ?
`<div class="nsfw-filter-notification">
<i class="fas fa-eye-slash"></i> ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
</div>` : '';
return `
<div class="scroll-indicator" onclick="toggleShowcase(this)">
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
</div>
<div class="carousel ${startExpanded ? '' : 'collapsed'}">
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
</div>
${renderImportInterface(false)}
</div>
`;
}
/**
* Render a single media item (image or video)
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the media item
*/
function renderMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Calculate appropriate aspect ratio
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Determine if this is a custom image (has id property)
const isCustomImage = Boolean(img.id);
// Create the media control buttons HTML
const mediaControlsHtml = `
<div class="media-controls">
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"
${!isCustomImage ? 'disabled' : ''}>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-check confirm-icon"></i>
</button>
</div>
`;
// Generate the appropriate wrapper based on media type
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
/**
* Find the matching local file for an image
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
return localFile;
}
/**
* Render the import interface for example images
* @param {boolean} isEmpty - Whether there are no existing examples
* @returns {string} HTML content for import interface
*/
function renderImportInterface(isEmpty) {
return `
<div class="example-import-area ${isEmpty ? 'empty' : ''}">
<div class="import-container" id="exampleImportContainer">
<div class="import-placeholder">
<i class="fas fa-cloud-upload-alt"></i>
<h3>${isEmpty ? 'No example images available' : 'Add more examples'}</h3>
<p>Drag & drop images or videos here</p>
<p class="sub-text">or</p>
<button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files
</button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
</div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
<div class="import-progress-container" style="display: none;">
<div class="import-progress">
<div class="progress-bar"></div>
</div>
<span class="progress-text">Importing files...</span>
</div>
</div>
</div>
`;
}
/**
* Initialize the example import functionality
* @param {string} modelHash - The SHA256 hash of the model
* @param {Element} container - The container element for the import area
*/
export function initExampleImport(modelHash, container) {
if (!container) return;
const importContainer = container.querySelector('#exampleImportContainer');
const fileInput = container.querySelector('#exampleFilesInput');
const selectFilesBtn = container.querySelector('#selectExampleFilesBtn');
// Set up file selection button
if (selectFilesBtn) {
selectFilesBtn.addEventListener('click', () => {
fileInput.click();
});
}
// Handle file selection
if (fileInput) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
}
});
}
// Set up drag and drop
if (importContainer) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop area on drag over
['dragenter', 'dragover'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.add('highlight');
}, false);
});
// Remove highlight on drag leave
['dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.remove('highlight');
}, false);
});
// Handle dropped files
importContainer.addEventListener('drop', (e) => {
const files = Array.from(e.dataTransfer.files);
handleImportFiles(files, modelHash, importContainer);
}, false);
}
}
/**
* Handle the file import process
* @param {File[]} files - Array of files to import
* @param {string} modelHash - The SHA256 hash of the model
* @param {Element} importContainer - The container element for import UI
*/
async function handleImportFiles(files, modelHash, importContainer) {
// Filter for supported file types
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos];
const validFiles = files.filter(file => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
return supportedExtensions.includes(ext);
});
if (validFiles.length === 0) {
alert('No supported files selected. Please select image or video files.');
return;
}
try {
// Use FormData to upload files
const formData = new FormData();
formData.append('model_hash', modelHash);
validFiles.forEach(file => {
formData.append('files', file);
});
// Call API to import files
const response = await fetch('/api/import-example-images', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to import example files');
}
// Get updated local files
const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const updatedFilesResult = await updatedFilesResponse.json();
if (!updatedFilesResult.success) {
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
}
// Re-render the showcase content
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// Get the updated images from the result
const regularImages = result.regular_images || [];
const customImages = result.custom_images || [];
// Combine both arrays for rendering
const allImages = [...regularImages, ...customImages];
console.log("Regular images:", regularImages);
console.log("Custom images:", customImages);
console.log("Combined images:", allImages);
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
// Re-initialize showcase functionality
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the import UI for the new content
initExampleImport(modelHash, showcaseTab);
showToast('Example images imported successfully', 'success');
// Update VirtualScroller if available
if (state.virtualScroller && result.model_file_path) {
// Create an update object with only the necessary properties
const updateData = {
civitai: {
images: regularImages,
customImages: customImages
}
};
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
}
}
} catch (error) {
console.error('Error importing examples:', error);
showToast('Failed to import example images', 'error');
}
}
/**
* Toggle showcase expansion
* @param {HTMLElement} element - The scroll indicator element
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initShowcaseContent(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
// Make sure any open metadata panels get closed
const carouselContainer = carousel.querySelector('.carousel-container');
if (carouselContainer) {
carouselContainer.style.height = '0';
setTimeout(() => {
carouselContainer.style.height = '';
}, 300);
}
}
}
/**
* Initialize all showcase content interactions
* @param {HTMLElement} carousel - The carousel element
*/
export function initShowcaseContent(carousel) {
if (!carousel) return;
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Add window resize handler
const resizeHandler = () => positionAllMediaControls(carousel);
window.removeEventListener('resize', resizeHandler);
window.addEventListener('resize', resizeHandler);
// Handle images loading which might change dimensions
const mediaElements = carousel.querySelectorAll('img, video');
mediaElements.forEach(media => {
media.addEventListener('load', () => positionAllMediaControls(carousel));
if (media.tagName === 'VIDEO') {
media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
}
});
}
/**
* Scroll to top of modal content
* @param {HTMLElement} button - Back to top button
*/
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
/**
* Set up showcase scroll functionality
* @param {string} modalId - ID of the modal element
*/
export function setupShowcaseScroll(modalId) {
// Listen for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const modal = document.getElementById(modalId);
if (modal && modal.querySelector('.modal-content')) {
setupBackToTopButton(modal.querySelector('.modal-content'));
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Try to set up the button immediately in case the modal is already open
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
/**
* Set up back-to-top button
* @param {HTMLElement} modalContent - Modal content element
*/
function setupBackToTopButton(modalContent) {
// Remove any existing scroll listeners to avoid duplicates
modalContent.onscroll = null;
// Add new scroll listener
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
// Trigger a scroll event to check initial position
modalContent.dispatchEvent(new Event('scroll'));
}

View File

@@ -42,6 +42,9 @@ export class AppCore {
exampleImagesManager.initialize();
// Initialize the help manager
helpManager.initialize();
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
// Mark as initialized
this.initialized = true;

View File

@@ -1,6 +1,6 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { showLoraModal, toggleShowcase, scrollToTop } from './components/loraModal/index.js';
import { showLoraModal } from './components/loraModal/index.js';
import { loadMoreLoras } from './api/loraApi.js';
import { updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
@@ -43,8 +43,6 @@ class LoraPageManager {
window.closeExcludeModal = closeExcludeModal;
window.downloadManager = this.downloadManager;
window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase;
window.scrollToTop = scrollToTop;
// Bulk operations
window.toggleBulkMode = () => bulkManager.toggleBulkMode();

View File

@@ -18,6 +18,8 @@ export class BulkManager {
if (!state.loraMetadataCache) {
state.loraMetadataCache = new Map();
}
this.stripMaxThumbnails = 50; // Maximum thumbnails to show in strip
}
initialize() {
@@ -29,6 +31,35 @@ export class BulkManager {
if (selectedCount) {
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
}
// Add global keyboard event listener for Ctrl+A
document.addEventListener('keydown', (e) => {
// Check if it's Ctrl+A (or Cmd+A on Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
// First check if any modal is currently open - if so, don't handle Ctrl+A
if (modalManager.isAnyModalOpen()) {
return; // Exit early - let the browser handle Ctrl+A within the modal
}
// Check if search input is currently focused - if so, don't handle Ctrl+A
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
return; // Exit early - let the browser handle Ctrl+A within the search input
}
// Prevent default browser "Select All" behavior
e.preventDefault();
// If not in bulk mode, enable it first
if (!state.bulkMode) {
this.toggleBulkMode();
// Small delay to ensure DOM is updated
setTimeout(() => this.selectAllVisibleLoras(), 50);
} else {
this.selectAllVisibleLoras();
}
}
});
}
toggleBulkMode() {
@@ -404,10 +435,24 @@ export class BulkManager {
// Clear existing thumbnails
container.innerHTML = '';
// Add a thumbnail for each selected LoRA
for (const filepath of state.selectedLoras) {
// Get all selected loras
const selectedLoras = Array.from(state.selectedLoras);
// Create counter if we have more thumbnails than we'll show
if (selectedLoras.length > this.stripMaxThumbnails) {
const counter = document.createElement('div');
counter.className = 'strip-counter';
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedLoras.length} selected`;
container.appendChild(counter);
}
// Limit the number of thumbnails to display
const thumbnailsToShow = selectedLoras.slice(0, this.stripMaxThumbnails);
// Add a thumbnail for each selected LoRA (limited to max)
thumbnailsToShow.forEach(filepath => {
const metadata = state.loraMetadataCache.get(filepath);
if (!metadata) continue;
if (!metadata) return;
const thumbnail = document.createElement('div');
thumbnail.className = 'selected-thumbnail';
@@ -444,7 +489,7 @@ export class BulkManager {
});
container.appendChild(thumbnail);
}
});
}
deselectItem(filepath) {
@@ -466,6 +511,47 @@ export class BulkManager {
this.hideThumbnailStrip();
}
}
// Add method to select all visible loras
selectAllVisibleLoras() {
// Only select loras already in the VirtualScroller's data model
if (!state.virtualScroller || !state.virtualScroller.items) {
showToast('Unable to select all items', 'error');
return;
}
const oldCount = state.selectedLoras.size;
// Add all loaded loras to the selection set
state.virtualScroller.items.forEach(item => {
if (item && item.file_path) {
state.selectedLoras.add(item.file_path);
// Add to metadata cache if not already there
if (!state.loraMetadataCache.has(item.file_path)) {
state.loraMetadataCache.set(item.file_path, {
fileName: item.file_name,
usageTips: item.usage_tips || '{}',
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
isVideo: item.is_video || false,
modelName: item.name || item.file_name
});
}
}
});
// Update visual state
this.applySelectionState();
// Show success message
const newlySelected = state.selectedLoras.size - oldCount;
showToast(`Selected ${newlySelected} additional LoRAs`, 'success');
// Update thumbnail strip if visible
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
}
}
// Create a singleton instance

Some files were not shown because too many files have changed in this diff Show More