mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23ab04d90 | ||
|
|
d50dde6cf6 | ||
|
|
fcb1fb39be | ||
|
|
b0ef74f802 | ||
|
|
f332aef41d | ||
|
|
1f91a3da8e | ||
|
|
16840c321d | ||
|
|
c109e392ad | ||
|
|
5e69671366 | ||
|
|
52d23d9b75 | ||
|
|
4c4e6d7a7b | ||
|
|
03b6e78705 | ||
|
|
24c01141d7 | ||
|
|
6dc2811af4 | ||
|
|
e6425dce32 | ||
|
|
95e2ff5f1e | ||
|
|
92ac487128 | ||
|
|
3250fa89cb | ||
|
|
7475de366b | ||
|
|
affb507b37 | ||
|
|
3320b80150 | ||
|
|
fb2b69b787 | ||
|
|
29a05f6533 | ||
|
|
9fa3fac973 | ||
|
|
904b0d104a | ||
|
|
1d31dae110 | ||
|
|
476ecb7423 | ||
|
|
4eb67cf6da | ||
|
|
a5a9f7ed83 | ||
|
|
c0b029e228 | ||
|
|
9bebcc9a4b | ||
|
|
ac7d23011c | ||
|
|
491e09b7b5 | ||
|
|
192bc237bf | ||
|
|
f041f4a114 | ||
|
|
2546580377 | ||
|
|
8fbf2ab56d | ||
|
|
ea727aad2e | ||
|
|
5520aecbba | ||
|
|
6b738a4769 | ||
|
|
903a8050b3 | ||
|
|
31b032429d | ||
|
|
2bcf341f04 | ||
|
|
ca6f45b359 | ||
|
|
2a67cec16b | ||
|
|
1800afe31b | ||
|
|
8c6311355d | ||
|
|
91801dff85 | ||
|
|
be594133f0 | ||
|
|
8a538d117e | ||
|
|
8d9118cbee | ||
|
|
b67464ea13 | ||
|
|
33334da0bb | ||
|
|
40ce2baa7b | ||
|
|
1134466cc0 | ||
|
|
92341111ad | ||
|
|
4956d6781f | ||
|
|
63562240c4 | ||
|
|
84d801cf14 | ||
|
|
b56fe4ca68 | ||
|
|
6c83c65e02 | ||
|
|
a83f020fcc | ||
|
|
7f9a3bf272 | ||
|
|
f80e266d02 | ||
|
|
7bef562541 | ||
|
|
b2428f607c | ||
|
|
8303196b57 | ||
|
|
987b8c8742 | ||
|
|
e60a579b85 | ||
|
|
be8edafed0 | ||
|
|
a258a18fa4 | ||
|
|
59010ca431 | ||
|
|
75f3764e6c | ||
|
|
867ffd1163 | ||
|
|
6acccbbb94 | ||
|
|
b2c4efab45 | ||
|
|
408a435b71 | ||
|
|
36d3cd93d5 | ||
|
|
b36fea002e | ||
|
|
52acbd954a | ||
|
|
f6709a55c3 | ||
|
|
7b374d747b | ||
|
|
fd480a9360 | ||
|
|
ec8b228867 | ||
|
|
401200050b | ||
|
|
29160bd6e5 | ||
|
|
3c9e402bc0 | ||
|
|
ff4d0f0208 | ||
|
|
f82908221c | ||
|
|
4246908f2e | ||
|
|
f64597afd2 | ||
|
|
975ff2672d | ||
|
|
e90ba31784 | ||
|
|
a4074c93bc | ||
|
|
7a8b7598c7 | ||
|
|
cd0d832f14 | ||
|
|
5b0becaaf2 | ||
|
|
9817bac2fe | ||
|
|
f6bd48cfcd | ||
|
|
01843b8f2b | ||
|
|
94ed81de5e | ||
|
|
0700b8f399 | ||
|
|
d62cff9841 | ||
|
|
083f4805b2 | ||
|
|
8e5bfd379e | ||
|
|
2366f143d8 | ||
|
|
e997f5bc1b | ||
|
|
842beec7cc | ||
|
|
d2268fc9e0 | ||
|
|
a98e26139f | ||
|
|
522a3ea88b | ||
|
|
d7949fbc30 | ||
|
|
6df083a1d5 | ||
|
|
4dc80e7f6e | ||
|
|
c2a8508513 | ||
|
|
159193ef43 | ||
|
|
1f37ffb105 | ||
|
|
919fed05c5 | ||
|
|
1814f83bee | ||
|
|
1823840456 | ||
|
|
623c28bfc3 | ||
|
|
3079131337 | ||
|
|
a34ade0120 | ||
|
|
e9ada70088 | ||
|
|
597cc48248 | ||
|
|
ec3f857ef1 | ||
|
|
383b4de539 | ||
|
|
1bf9326604 | ||
|
|
d9f5459d46 | ||
|
|
e45a1b1e19 | ||
|
|
331ad8f644 | ||
|
|
52fa88b04c | ||
|
|
8895a64d24 | ||
|
|
fdec535559 | ||
|
|
6c5559ae2d | ||
|
|
9f54622b17 | ||
|
|
03b6f4b378 | ||
|
|
af4cbe2332 | ||
|
|
141f72963a | ||
|
|
3d3c66e12f | ||
|
|
ee84571bdb | ||
|
|
6500936aad | ||
|
|
32d2b6c013 | ||
|
|
05df40977d | ||
|
|
5d7a1dcde5 | ||
|
|
9c45d9db6c |
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Always use English for comments.
|
||||||
85
README.md
85
README.md
@@ -34,6 +34,33 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.9.2
|
||||||
|
* **Bulk Auto-Organization Action** - Added a new bulk auto-organization feature. You can now select multiple models and automatically organize them according to your current path template settings for streamlined management.
|
||||||
|
* **Bug Fixes** - Addressed several bugs to improve stability and reliability.
|
||||||
|
|
||||||
|
### v0.9.1
|
||||||
|
* **Enhanced Bulk Operations** - Improved bulk operations with Marquee Selection and a bulk operation context menu, providing a more intuitive, desktop-application-like user experience.
|
||||||
|
* **New Bulk Actions** - Added bulk operations for adding tags and setting base models to multiple models simultaneously.
|
||||||
|
|
||||||
|
### v0.9.0
|
||||||
|
* **UI Overhaul for Enhanced Navigation** - Replaced the top flat folder tags with a new folder sidebar and breadcrumb navigation system for a more intuitive folder browsing and selection experience.
|
||||||
|
* **Dual-Mode Folder Sidebar** - The new folder sidebar offers two display modes: 'List Mode,' which mirrors the classic folder view, and 'Tree Mode,' which presents a hierarchical folder structure for effortless navigation through nested directories.
|
||||||
|
* **Internationalization Support** - Introduced multi-language support, now available in English, Simplified Chinese, Traditional Chinese, Spanish, Japanese, Korean, French, Russian, and German. Feedback from native speakers is welcome to improve the translations.
|
||||||
|
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
|
||||||
|
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
|
||||||
|
|
||||||
|
### v0.8.30
|
||||||
|
* **Automatic Model Path Correction** - Added auto-correction for model paths in built-in nodes such as Load Checkpoint, Load Diffusion Model, Load LoRA, and other custom nodes with similar functionality. Workflows containing outdated or incorrect model paths will now be automatically updated to reflect the current location of your models.
|
||||||
|
* **Node UI Enhancements** - Improved node interface for a smoother and more intuitive user experience.
|
||||||
|
* **Bug Fixes** - Addressed various bugs to enhance stability and reliability.
|
||||||
|
|
||||||
|
### v0.8.29
|
||||||
|
* **Enhanced Recipe Imports** - Improved recipe importing with new target folder selection, featuring path input autocomplete and interactive folder tree navigation. Added a "Use Default Path" option when downloading missing LoRAs.
|
||||||
|
* **WanVideo Lora Select Node Update** - Updated the WanVideo Lora Select node with a 'merge_loras' option to match the counterpart node in the WanVideoWrapper node package.
|
||||||
|
* **Autocomplete Conflict Resolution** - Resolved an autocomplete feature conflict in LoRA nodes with pysssss autocomplete.
|
||||||
|
* **Improved Download Functionality** - Enhanced download functionality with resumable downloads and improved error handling.
|
||||||
|
* **Bug Fixes** - Addressed several bugs for improved stability and performance.
|
||||||
|
|
||||||
### v0.8.28
|
### v0.8.28
|
||||||
* **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI.
|
* **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI.
|
||||||
* **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience.
|
* **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience.
|
||||||
@@ -50,61 +77,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
|
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
|
||||||
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
|
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
|
||||||
|
|
||||||
### v0.8.25
|
|
||||||
* **LoRA List Reordering**
|
|
||||||
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
|
|
||||||
- Keyboard Shortcuts:
|
|
||||||
- Arrow keys: Navigate between LoRAs
|
|
||||||
- Ctrl/Cmd + Arrow: Move selected LoRA up/down
|
|
||||||
- Ctrl/Cmd + Home/End: Move selected LoRA to top/bottom
|
|
||||||
- Delete/Backspace: Remove selected LoRA
|
|
||||||
- Context Menu: Right-click for quick actions like Move Up, Move Down, Move to Top, Move to Bottom.
|
|
||||||
* **Bulk Operations for Checkpoints & Embeddings**
|
|
||||||
- Bulk Mode: Select multiple checkpoints or embeddings for batch actions.
|
|
||||||
- Bulk Refresh: Update Civitai metadata for selected models.
|
|
||||||
- Bulk Delete: Remove multiple models at once.
|
|
||||||
- Bulk Move (Embeddings): Move selected embeddings to a different folder.
|
|
||||||
* **New Setting: Auto Download Example Images**
|
|
||||||
- Automatically fetch example images for models missing previews (requires download location to be set). Enabled by default.
|
|
||||||
* **General Improvements**
|
|
||||||
- Various user experience enhancements and stability fixes.
|
|
||||||
|
|
||||||
### v0.8.22
|
|
||||||
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
|
|
||||||
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
|
|
||||||
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
|
|
||||||
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
|
|
||||||
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
|
|
||||||
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
|
|
||||||
|
|
||||||
### v0.8.20
|
|
||||||
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
|
|
||||||
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
|
|
||||||
* **WanVideo Integration** - Introduced WanVideo Lora Select (LoraManager) node compatible with ComfyUI-WanVideoWrapper for streamlined lora usage in video workflows, including a template workflow to help you get started quickly
|
|
||||||
|
|
||||||
### 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
|
|
||||||
* **Improved TriggerWord Control** - Enhanced TriggerWord Toggle node with new default_active switch to set the initial state (active/inactive) when trigger words are added
|
|
||||||
* **Centralized Example Management** - Added "Migrate Existing Example Images" feature to consolidate downloaded example images from model folders into central storage with customizable naming patterns
|
|
||||||
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
|
|
||||||
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
|
|
||||||
|
|
||||||
[View Update History](./update_logs.md)
|
[View Update History](./update_logs.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -296,3 +268,6 @@ Join our Discord community for support, discussions, and updates:
|
|||||||
[Discord Server](https://discord.gg/vcqNrWVFvM)
|
[Discord Server](https://discord.gg/vcqNrWVFvM)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#willmiao/ComfyUI-Lora-Manager&Date)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from .py.lora_manager import LoraManager
|
from .py.lora_manager import LoraManager
|
||||||
from .py.nodes.lora_loader import LoraManagerLoader
|
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
from .py.nodes.lora_stacker import LoraStacker
|
from .py.nodes.lora_stacker import LoraStacker
|
||||||
from .py.nodes.save_image import SaveImage
|
from .py.nodes.save_image import SaveImage
|
||||||
@@ -10,6 +10,7 @@ from .py.metadata_collector import init as init_metadata_collector
|
|||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||||
|
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||||
LoraStacker.NAME: LoraStacker,
|
LoraStacker.NAME: LoraStacker,
|
||||||
SaveImage.NAME: SaveImage,
|
SaveImage.NAME: SaveImage,
|
||||||
|
|||||||
182
docs/EventManagementImplementation.md
Normal file
182
docs/EventManagementImplementation.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Event Management Implementation Summary
|
||||||
|
|
||||||
|
## What Has Been Implemented
|
||||||
|
|
||||||
|
### 1. Enhanced EventManager Class
|
||||||
|
- **Location**: `static/js/utils/EventManager.js`
|
||||||
|
- **Features**:
|
||||||
|
- Priority-based event handling
|
||||||
|
- Conditional execution based on application state
|
||||||
|
- Element filtering (target/exclude selectors)
|
||||||
|
- Mouse button filtering
|
||||||
|
- Automatic cleanup with cleanup functions
|
||||||
|
- State tracking for app modes
|
||||||
|
- Error handling for event handlers
|
||||||
|
|
||||||
|
### 2. BulkManager Integration
|
||||||
|
- **Location**: `static/js/managers/BulkManager.js`
|
||||||
|
- **Migrated Events**:
|
||||||
|
- Global keyboard shortcuts (Ctrl+A, Escape, B key)
|
||||||
|
- Marquee selection events (mousedown, mousemove, mouseup, contextmenu)
|
||||||
|
- State synchronization with EventManager
|
||||||
|
- **Benefits**:
|
||||||
|
- Centralized priority handling
|
||||||
|
- Conditional execution based on modal state
|
||||||
|
- Better coordination with other components
|
||||||
|
|
||||||
|
### 3. UIHelpers Integration
|
||||||
|
- **Location**: `static/js/utils/uiHelpers.js`
|
||||||
|
- **Migrated Events**:
|
||||||
|
- Mouse position tracking for node selector positioning
|
||||||
|
- Node selector click events (outside clicks and selection)
|
||||||
|
- State management for node selector
|
||||||
|
- **Benefits**:
|
||||||
|
- Reduced direct DOM listeners
|
||||||
|
- Coordinated state tracking
|
||||||
|
- Better cleanup
|
||||||
|
|
||||||
|
### 4. ModelCard Integration
|
||||||
|
- **Location**: `static/js/components/shared/ModelCard.js`
|
||||||
|
- **Migrated Events**:
|
||||||
|
- Model card click delegation
|
||||||
|
- Action button handling (star, globe, copy, etc.)
|
||||||
|
- Better return value handling for event propagation
|
||||||
|
- **Benefits**:
|
||||||
|
- Single event listener for all model cards
|
||||||
|
- Priority-based execution
|
||||||
|
- Better event flow control
|
||||||
|
|
||||||
|
### 5. Documentation and Initialization
|
||||||
|
- **EventManagerDocs.md**: Comprehensive documentation
|
||||||
|
- **eventManagementInit.js**: Initialization and global handlers
|
||||||
|
- **Features**:
|
||||||
|
- Global escape key handling
|
||||||
|
- Modal state synchronization
|
||||||
|
- Error handling
|
||||||
|
- Analytics integration points
|
||||||
|
- Cleanup on page unload
|
||||||
|
|
||||||
|
## Application States Tracked
|
||||||
|
|
||||||
|
1. **bulkMode**: When bulk selection mode is active
|
||||||
|
2. **marqueeActive**: When marquee selection is in progress
|
||||||
|
3. **modalOpen**: When any modal dialog is open
|
||||||
|
4. **nodeSelectorActive**: When node selector popup is visible
|
||||||
|
|
||||||
|
## Priority Levels Used
|
||||||
|
|
||||||
|
- **250+**: Critical system events (escape keys)
|
||||||
|
- **200+**: High priority system events (modal close)
|
||||||
|
- **100-199**: Application-level shortcuts (bulk operations)
|
||||||
|
- **80-99**: UI interactions (marquee selection)
|
||||||
|
- **60-79**: Component interactions (model cards)
|
||||||
|
- **10-49**: Tracking and monitoring
|
||||||
|
- **1-9**: Analytics and low-priority tasks
|
||||||
|
|
||||||
|
## Event Flow Examples
|
||||||
|
|
||||||
|
### Bulk Mode Toggle (B key)
|
||||||
|
1. **Priority 100**: BulkManager keyboard handler catches 'b' key
|
||||||
|
2. Toggles bulk mode state
|
||||||
|
3. Updates EventManager state
|
||||||
|
4. Updates UI accordingly
|
||||||
|
5. Stops propagation (returns true)
|
||||||
|
|
||||||
|
### Marquee Selection
|
||||||
|
1. **Priority 80**: BulkManager mousedown handler (only in .models-container, excluding cards/buttons)
|
||||||
|
2. Starts marquee selection
|
||||||
|
3. **Priority 90**: BulkManager mousemove handler (only when marquee active)
|
||||||
|
4. Updates selection rectangle
|
||||||
|
5. **Priority 90**: BulkManager mouseup handler ends selection
|
||||||
|
|
||||||
|
### Model Card Click
|
||||||
|
1. **Priority 60**: ModelCard delegation handler checks for specific elements
|
||||||
|
2. If action button: handles action and stops propagation
|
||||||
|
3. If general card click: continues to other handlers
|
||||||
|
4. Bulk selection may also handle the event if in bulk mode
|
||||||
|
|
||||||
|
## Remaining Event Listeners (Not Yet Migrated)
|
||||||
|
|
||||||
|
### High Priority for Migration
|
||||||
|
1. **SearchManager keyboard events** - Global search shortcuts
|
||||||
|
2. **ModalManager escape handling** - Already integrated with initialization
|
||||||
|
3. **Scroll-based events** - Back to top, virtual scrolling
|
||||||
|
4. **Resize events** - Panel positioning, responsive layouts
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
1. **Form input events** - Tag inputs, settings forms
|
||||||
|
2. **Component-specific events** - Recipe modal, showcase view
|
||||||
|
3. **Sidebar events** - Resize handling, toggle events
|
||||||
|
|
||||||
|
### Low Priority (Can Remain As-Is)
|
||||||
|
1. **VirtualScroller events** - Performance-critical, specialized
|
||||||
|
2. **Component lifecycle events** - Modal open/close callbacks
|
||||||
|
3. **One-time setup events** - Theme initialization, etc.
|
||||||
|
|
||||||
|
## Benefits Achieved
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
- **Reduced DOM listeners**: From ~15+ individual listeners to ~5 coordinated handlers
|
||||||
|
- **Conditional execution**: Handlers only run when conditions are met
|
||||||
|
- **Priority ordering**: Important events handled first
|
||||||
|
- **Better memory management**: Automatic cleanup prevents leaks
|
||||||
|
|
||||||
|
### Coordination Improvements
|
||||||
|
- **State synchronization**: All components aware of app state
|
||||||
|
- **Event flow control**: Proper propagation stopping
|
||||||
|
- **Conflict resolution**: Priority system prevents conflicts
|
||||||
|
- **Debugging**: Centralized event handling for easier debugging
|
||||||
|
|
||||||
|
### Code Quality Improvements
|
||||||
|
- **Consistent patterns**: All event handling follows same patterns
|
||||||
|
- **Better separation of concerns**: Event logic separated from business logic
|
||||||
|
- **Error handling**: Centralized error catching and reporting
|
||||||
|
- **Documentation**: Clear patterns for future development
|
||||||
|
|
||||||
|
## Next Steps (Recommendations)
|
||||||
|
|
||||||
|
### 1. Migrate Search Events
|
||||||
|
```javascript
|
||||||
|
// In SearchManager.js
|
||||||
|
eventManager.addHandler('keydown', 'search-shortcuts', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
this.focusSearchInput();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, { priority: 120 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Integrate Resize Events
|
||||||
|
```javascript
|
||||||
|
// Create ResizeManager
|
||||||
|
eventManager.addHandler('resize', 'layout-resize', debounce((e) => {
|
||||||
|
this.updateLayoutDimensions();
|
||||||
|
}, 250), { priority: 50 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add Debug Mode
|
||||||
|
```javascript
|
||||||
|
// In EventManager.js
|
||||||
|
if (window.DEBUG_EVENTS) {
|
||||||
|
console.log(`Event ${eventType} handled by ${source} (priority: ${priority})`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create Event Analytics
|
||||||
|
```javascript
|
||||||
|
// Track event patterns for optimization
|
||||||
|
eventManager.addHandler('*', 'analytics', (e) => {
|
||||||
|
this.trackEventUsage(e.type, performance.now());
|
||||||
|
}, { priority: 1 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. **Verify bulk mode interactions** work correctly
|
||||||
|
2. **Test marquee selection** in various scenarios
|
||||||
|
3. **Check modal state synchronization**
|
||||||
|
4. **Verify node selector** positioning and cleanup
|
||||||
|
5. **Test keyboard shortcuts** don't conflict
|
||||||
|
6. **Verify proper cleanup** when components are destroyed
|
||||||
|
|
||||||
|
The centralized event management system provides a solid foundation for coordinated, efficient event handling across the application while maintaining good performance and code organization.
|
||||||
301
docs/EventManagerDocs.md
Normal file
301
docs/EventManagerDocs.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# Centralized Event Management System
|
||||||
|
|
||||||
|
This document describes the centralized event management system that coordinates event handling across the ComfyUI LoRA Manager application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `EventManager` class provides a centralized way to handle DOM events with priority-based execution, conditional execution based on application state, and proper cleanup mechanisms.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Priority-based execution**: Handlers with higher priority run first
|
||||||
|
- **Conditional execution**: Handlers can be executed based on application state
|
||||||
|
- **Element filtering**: Handlers can target specific elements or exclude others
|
||||||
|
- **Automatic cleanup**: Cleanup functions are called when handlers are removed
|
||||||
|
- **State tracking**: Tracks application states like bulk mode, modal open, etc.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Importing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { eventManager } from './EventManager.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Event Handlers
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
eventManager.addHandler('click', 'myComponent', (event) => {
|
||||||
|
console.log('Button clicked!');
|
||||||
|
return true; // Stop propagation to other handlers
|
||||||
|
}, {
|
||||||
|
priority: 100,
|
||||||
|
targetSelector: '.my-button',
|
||||||
|
skipWhenModalOpen: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing Event Handlers
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Remove specific handler
|
||||||
|
eventManager.removeHandler('click', 'myComponent');
|
||||||
|
|
||||||
|
// Remove all handlers for a component
|
||||||
|
eventManager.removeAllHandlersForSource('myComponent');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Application State
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Set state
|
||||||
|
eventManager.setState('bulkMode', true);
|
||||||
|
eventManager.setState('modalOpen', true);
|
||||||
|
|
||||||
|
// Get state
|
||||||
|
const isBulkMode = eventManager.getState('bulkMode');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available States
|
||||||
|
|
||||||
|
- `bulkMode`: Whether bulk selection mode is active
|
||||||
|
- `marqueeActive`: Whether marquee selection is in progress
|
||||||
|
- `modalOpen`: Whether any modal is currently open
|
||||||
|
- `nodeSelectorActive`: Whether the node selector popup is active
|
||||||
|
|
||||||
|
## Handler Options
|
||||||
|
|
||||||
|
### Priority
|
||||||
|
Higher numbers = higher priority. Handlers run in descending priority order.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
priority: 100 // High priority
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Execution
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
onlyInBulkMode: true, // Only run when bulk mode is active
|
||||||
|
onlyWhenMarqueeActive: true, // Only run when marquee selection is active
|
||||||
|
skipWhenModalOpen: true, // Skip when any modal is open
|
||||||
|
skipWhenNodeSelectorActive: true, // Skip when node selector is active
|
||||||
|
onlyWhenNodeSelectorActive: true // Only run when node selector is active
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Element Filtering
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
targetSelector: '.model-card', // Only handle events on matching elements
|
||||||
|
excludeSelector: 'button, input', // Exclude events from these elements
|
||||||
|
button: 0 // Only handle specific mouse button (0=left, 1=middle, 2=right)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup Functions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
cleanup: () => {
|
||||||
|
// Custom cleanup logic
|
||||||
|
console.log('Handler cleaned up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### BulkManager Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class BulkManager {
|
||||||
|
registerEventHandlers() {
|
||||||
|
// High priority keyboard shortcuts
|
||||||
|
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
|
||||||
|
return this.handleGlobalKeyboard(e);
|
||||||
|
}, {
|
||||||
|
priority: 100,
|
||||||
|
skipWhenModalOpen: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marquee selection
|
||||||
|
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
|
||||||
|
return this.handleMarqueeStart(e);
|
||||||
|
}, {
|
||||||
|
priority: 80,
|
||||||
|
skipWhenModalOpen: true,
|
||||||
|
targetSelector: '.models-container',
|
||||||
|
excludeSelector: '.model-card, button, input',
|
||||||
|
button: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
||||||
|
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class ModalManager {
|
||||||
|
showModal(modalId) {
|
||||||
|
// Update state when modal opens
|
||||||
|
eventManager.setState('modalOpen', true);
|
||||||
|
this.displayModal(modalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(modalId) {
|
||||||
|
// Update state when modal closes
|
||||||
|
eventManager.setState('modalOpen', false);
|
||||||
|
this.hideModal(modalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Event Delegation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export function setupComponentEvents() {
|
||||||
|
eventManager.addHandler('click', 'myComponent-actions', (event) => {
|
||||||
|
const button = event.target.closest('.action-button');
|
||||||
|
if (!button) return false;
|
||||||
|
|
||||||
|
this.handleAction(button.dataset.action);
|
||||||
|
return true; // Stop propagation
|
||||||
|
}, {
|
||||||
|
priority: 60,
|
||||||
|
targetSelector: '.component-container'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Descriptive Source Names
|
||||||
|
Use the format `componentName-purposeDescription`:
|
||||||
|
```javascript
|
||||||
|
// Good
|
||||||
|
'bulkManager-marqueeSelection'
|
||||||
|
'nodeSelector-clickOutside'
|
||||||
|
'modelCard-delegation'
|
||||||
|
|
||||||
|
// Avoid
|
||||||
|
'bulk'
|
||||||
|
'click'
|
||||||
|
'handler1'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Appropriate Priorities
|
||||||
|
- 200+: Critical system events (escape keys, critical modals)
|
||||||
|
- 100-199: High priority application events (keyboard shortcuts)
|
||||||
|
- 50-99: Normal UI interactions (buttons, cards)
|
||||||
|
- 1-49: Low priority events (tracking, analytics)
|
||||||
|
|
||||||
|
### 3. Use Conditional Execution
|
||||||
|
Instead of checking state inside handlers, use options:
|
||||||
|
```javascript
|
||||||
|
// Good
|
||||||
|
eventManager.addHandler('click', 'bulk-action', handler, {
|
||||||
|
onlyInBulkMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avoid
|
||||||
|
eventManager.addHandler('click', 'bulk-action', (e) => {
|
||||||
|
if (!state.bulkMode) return;
|
||||||
|
// handler logic
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Clean Up Properly
|
||||||
|
Always clean up handlers when components are destroyed:
|
||||||
|
```javascript
|
||||||
|
class MyComponent {
|
||||||
|
constructor() {
|
||||||
|
this.registerEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
eventManager.removeAllHandlersForSource('myComponent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Return Values Matter
|
||||||
|
- Return `true` to stop event propagation to other handlers
|
||||||
|
- Return `false` or `undefined` to continue with other handlers
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Direct Event Listeners
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.my-button')) {
|
||||||
|
this.handleClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```javascript
|
||||||
|
eventManager.addHandler('click', 'myComponent-button', (e) => {
|
||||||
|
this.handleClick(e);
|
||||||
|
}, {
|
||||||
|
targetSelector: '.my-button'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Event Delegation
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```javascript
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('.model-card');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
if (e.target.closest('.action-btn')) {
|
||||||
|
this.handleAction(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```javascript
|
||||||
|
eventManager.addHandler('click', 'container-actions', (e) => {
|
||||||
|
const card = e.target.closest('.model-card');
|
||||||
|
if (!card) return false;
|
||||||
|
|
||||||
|
if (e.target.closest('.action-btn')) {
|
||||||
|
this.handleAction(e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
targetSelector: '.container'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Benefits
|
||||||
|
|
||||||
|
1. **Reduced DOM listeners**: Single listener per event type instead of multiple
|
||||||
|
2. **Conditional execution**: Handlers only run when conditions are met
|
||||||
|
3. **Priority ordering**: Important handlers run first, avoiding unnecessary work
|
||||||
|
4. **Automatic cleanup**: Prevents memory leaks from orphaned listeners
|
||||||
|
5. **Centralized debugging**: All event handling flows through one system
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
Enable debug logging to trace event handling:
|
||||||
|
```javascript
|
||||||
|
// Add to EventManager.js for debugging
|
||||||
|
console.log(`Handling ${eventType} event with ${handlers.length} handlers`);
|
||||||
|
```
|
||||||
|
|
||||||
|
The event manager provides a foundation for coordinated, efficient event handling across the entire application.
|
||||||
1174
locales/de.json
Normal file
1174
locales/de.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/en.json
Normal file
1174
locales/en.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/es.json
Normal file
1174
locales/es.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/fr.json
Normal file
1174
locales/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/ja.json
Normal file
1174
locales/ja.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/ko.json
Normal file
1174
locales/ko.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/ru.json
Normal file
1174
locales/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/zh-CN.json
Normal file
1174
locales/zh-CN.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
locales/zh-TW.json
Normal file
1174
locales/zh-TW.json
Normal file
File diff suppressed because it is too large
Load Diff
24
py/config.py
24
py/config.py
@@ -18,6 +18,7 @@ class Config:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates')
|
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates')
|
||||||
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
|
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
|
||||||
|
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
|
||||||
# Path mapping dictionary, target to link mapping
|
# Path mapping dictionary, target to link mapping
|
||||||
self._path_mappings = {}
|
self._path_mappings = {}
|
||||||
# Static route mapping dictionary, target to route mapping
|
# Static route mapping dictionary, target to route mapping
|
||||||
@@ -268,19 +269,26 @@ class Config:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def get_preview_static_url(self, preview_path: str) -> str:
|
def get_preview_static_url(self, preview_path: str) -> str:
|
||||||
"""Convert local preview path to static URL"""
|
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
|
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
|
||||||
|
|
||||||
|
# Find longest matching path (most specific match)
|
||||||
|
best_match = ""
|
||||||
|
best_route = ""
|
||||||
|
|
||||||
for path, route in self._route_mappings.items():
|
for path, route in self._route_mappings.items():
|
||||||
if real_path.startswith(path):
|
if real_path.startswith(path) and len(path) > len(best_match):
|
||||||
relative_path = os.path.relpath(real_path, path).replace(os.sep, '/')
|
best_match = path
|
||||||
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
best_route = route
|
||||||
safe_path = '/'.join(safe_parts)
|
|
||||||
return f'{route}/{safe_path}'
|
if best_match:
|
||||||
|
relative_path = os.path.relpath(real_path, best_match).replace(os.sep, '/')
|
||||||
|
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
||||||
|
safe_path = '/'.join(safe_parts)
|
||||||
|
return f'{best_route}/{safe_path}'
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Global config instance
|
# Global config instance
|
||||||
|
|||||||
@@ -145,7 +145,12 @@ class LoraManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Add static route for locales JSON files
|
||||||
|
if os.path.exists(config.i18n_path):
|
||||||
|
app.router.add_static('/locales', config.i18n_path)
|
||||||
|
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
|
||||||
|
|
||||||
# Add static route for plugin assets
|
# Add static route for plugin assets
|
||||||
app.router.add_static('/loras_static', config.static_path)
|
app.router.add_static('/loras_static', config.static_path)
|
||||||
|
|
||||||
@@ -198,18 +203,267 @@ class LoraManager:
|
|||||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||||
|
|
||||||
# Create low-priority initialization tasks
|
# Create low-priority initialization tasks
|
||||||
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init')
|
init_tasks = [
|
||||||
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init')
|
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
|
||||||
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_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')
|
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'),
|
||||||
|
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
|
||||||
|
]
|
||||||
|
|
||||||
await ExampleImagesMigration.check_and_run_migrations()
|
await ExampleImagesMigration.check_and_run_migrations()
|
||||||
|
|
||||||
|
# Schedule post-initialization tasks to run after scanners complete
|
||||||
|
asyncio.create_task(
|
||||||
|
cls._run_post_initialization_tasks(init_tasks),
|
||||||
|
name='post_init_tasks'
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("LoRA Manager: All services initialized and background tasks scheduled")
|
logger.info("LoRA Manager: All services initialized and background tasks scheduled")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
|
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _run_post_initialization_tasks(cls, init_tasks):
|
||||||
|
"""Run post-initialization tasks after all scanners complete"""
|
||||||
|
try:
|
||||||
|
logger.debug("LoRA Manager: Waiting for scanner initialization to complete...")
|
||||||
|
|
||||||
|
# Wait for all scanner initialization tasks to complete
|
||||||
|
await asyncio.gather(*init_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
logger.debug("LoRA Manager: Scanner initialization completed, starting post-initialization tasks...")
|
||||||
|
|
||||||
|
# Run post-initialization tasks
|
||||||
|
post_tasks = [
|
||||||
|
asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
|
||||||
|
asyncio.create_task(cls._cleanup_example_images_folders(), name='cleanup_example_images'),
|
||||||
|
# Add more post-initialization tasks here as needed
|
||||||
|
# asyncio.create_task(cls._another_post_task(), name='another_task'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Run all post-initialization tasks
|
||||||
|
results = await asyncio.gather(*post_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
task_name = post_tasks[i].get_name()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.error(f"Post-initialization task '{task_name}' failed: {result}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Post-initialization task '{task_name}' completed successfully")
|
||||||
|
|
||||||
|
logger.debug("LoRA Manager: All post-initialization tasks completed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _cleanup_backup_files(cls):
|
||||||
|
"""Clean up .bak files in all model roots"""
|
||||||
|
try:
|
||||||
|
logger.debug("Starting cleanup of .bak files in model directories...")
|
||||||
|
|
||||||
|
# Collect all model roots
|
||||||
|
all_roots = set()
|
||||||
|
all_roots.update(config.loras_roots)
|
||||||
|
all_roots.update(config.base_models_roots)
|
||||||
|
all_roots.update(config.embeddings_roots)
|
||||||
|
|
||||||
|
total_deleted = 0
|
||||||
|
total_size_freed = 0
|
||||||
|
|
||||||
|
for root_path in all_roots:
|
||||||
|
if not os.path.exists(root_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_count, size_freed = await cls._cleanup_backup_files_in_directory(root_path)
|
||||||
|
total_deleted += deleted_count
|
||||||
|
total_size_freed += size_freed
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
logger.debug(f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up .bak files in {root_path}: {e}")
|
||||||
|
|
||||||
|
# Yield control periodically
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
if total_deleted > 0:
|
||||||
|
logger.debug(f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024*1024):.2f} MB total")
|
||||||
|
else:
|
||||||
|
logger.debug("Backup cleanup completed: no .bak files found")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during backup file cleanup: {e}", exc_info=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _cleanup_backup_files_in_directory(cls, directory_path: str):
|
||||||
|
"""Clean up .bak files in a specific directory recursively
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory_path: Path to the directory to clean
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[int, int]: (number of files deleted, total size freed in bytes)
|
||||||
|
"""
|
||||||
|
deleted_count = 0
|
||||||
|
size_freed = 0
|
||||||
|
visited_paths = set()
|
||||||
|
|
||||||
|
def cleanup_recursive(path):
|
||||||
|
nonlocal deleted_count, size_freed
|
||||||
|
|
||||||
|
try:
|
||||||
|
real_path = os.path.realpath(path)
|
||||||
|
if real_path in visited_paths:
|
||||||
|
return
|
||||||
|
visited_paths.add(real_path)
|
||||||
|
|
||||||
|
with os.scandir(path) as it:
|
||||||
|
for entry in it:
|
||||||
|
try:
|
||||||
|
if entry.is_file(follow_symlinks=True) and entry.name.endswith('.bak'):
|
||||||
|
file_size = entry.stat().st_size
|
||||||
|
os.remove(entry.path)
|
||||||
|
deleted_count += 1
|
||||||
|
size_freed += file_size
|
||||||
|
logger.debug(f"Deleted .bak file: {entry.path}")
|
||||||
|
|
||||||
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
|
cleanup_recursive(entry.path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not delete .bak file {entry.path}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error scanning directory {path} for .bak files: {e}")
|
||||||
|
|
||||||
|
# Run the recursive cleanup in a thread pool to avoid blocking
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, cleanup_recursive, directory_path)
|
||||||
|
|
||||||
|
return deleted_count, size_freed
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _cleanup_example_images_folders(cls):
|
||||||
|
"""Clean up invalid or empty folders in example images directory"""
|
||||||
|
try:
|
||||||
|
example_images_path = settings.get('example_images_path')
|
||||||
|
if not example_images_path or not os.path.exists(example_images_path):
|
||||||
|
logger.debug("Example images path not configured or doesn't exist, skipping cleanup")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"Starting cleanup of example images folders in: {example_images_path}")
|
||||||
|
|
||||||
|
# Get all scanner instances to check hash validity
|
||||||
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
|
||||||
|
total_folders_checked = 0
|
||||||
|
empty_folders_removed = 0
|
||||||
|
invalid_hash_folders_removed = 0
|
||||||
|
|
||||||
|
# Scan the example images directory
|
||||||
|
try:
|
||||||
|
with os.scandir(example_images_path) as it:
|
||||||
|
for entry in it:
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
folder_name = entry.name
|
||||||
|
folder_path = entry.path
|
||||||
|
total_folders_checked += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if folder is empty
|
||||||
|
is_empty = cls._is_folder_empty(folder_path)
|
||||||
|
if is_empty:
|
||||||
|
logger.debug(f"Removing empty example images folder: {folder_name}")
|
||||||
|
await cls._remove_folder_safely(folder_path)
|
||||||
|
empty_folders_removed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if folder name is a valid SHA256 hash (64 hex characters)
|
||||||
|
if len(folder_name) != 64 or not all(c in '0123456789abcdefABCDEF' for c in folder_name):
|
||||||
|
logger.debug(f"Removing invalid hash folder: {folder_name}")
|
||||||
|
await cls._remove_folder_safely(folder_path)
|
||||||
|
invalid_hash_folders_removed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if hash exists in any of the scanners
|
||||||
|
hash_exists = (
|
||||||
|
lora_scanner.has_hash(folder_name) or
|
||||||
|
checkpoint_scanner.has_hash(folder_name) or
|
||||||
|
embedding_scanner.has_hash(folder_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hash_exists:
|
||||||
|
logger.debug(f"Removing example images folder for deleted model: {folder_name}")
|
||||||
|
await cls._remove_folder_safely(folder_path)
|
||||||
|
invalid_hash_folders_removed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.debug(f"Keeping valid example images folder: {folder_name}")
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing example images folder {folder_name}: {e}")
|
||||||
|
|
||||||
|
# Yield control periodically
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error scanning example images directory: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Log final cleanup report
|
||||||
|
total_removed = empty_folders_removed + invalid_hash_folders_removed
|
||||||
|
if total_removed > 0:
|
||||||
|
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
|
||||||
|
f"removed {empty_folders_removed} empty folders and {invalid_hash_folders_removed} "
|
||||||
|
f"folders for deleted/invalid models (total: {total_removed} removed)")
|
||||||
|
else:
|
||||||
|
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
|
||||||
|
f"no cleanup needed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during example images cleanup: {e}", exc_info=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_folder_empty(cls, folder_path: str) -> bool:
|
||||||
|
"""Check if a folder is empty
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to the folder to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if folder is empty, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with os.scandir(folder_path) as it:
|
||||||
|
return not any(it)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error checking if folder is empty {folder_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _remove_folder_safely(cls, folder_path: str):
|
||||||
|
"""Safely remove a folder and all its contents
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path: Path to the folder to remove
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, shutil.rmtree, folder_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to remove folder {folder_path}: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _cleanup(cls, app):
|
async def _cleanup(cls, app):
|
||||||
"""Cleanup resources using ServiceRegistry"""
|
"""Cleanup resources using ServiceRegistry"""
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ NODE_EXTRACTORS = {
|
|||||||
"KSamplerAdvanced": KSamplerAdvancedExtractor,
|
"KSamplerAdvanced": KSamplerAdvancedExtractor,
|
||||||
"SamplerCustom": KSamplerAdvancedExtractor,
|
"SamplerCustom": KSamplerAdvancedExtractor,
|
||||||
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
|
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
|
||||||
|
"ClownsharKSampler_Beta": SamplerExtractor,
|
||||||
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
||||||
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
|
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
|
||||||
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
|
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from nodes import LoraLoader
|
from nodes import LoraLoader
|
||||||
from comfy.comfy_types import IO # type: ignore
|
from comfy.comfy_types import IO # type: ignore
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
@@ -17,7 +18,8 @@ class LoraManagerLoader:
|
|||||||
"model": ("MODEL",),
|
"model": ("MODEL",),
|
||||||
# "clip": ("CLIP",),
|
# "clip": ("CLIP",),
|
||||||
"text": (IO.STRING, {
|
"text": (IO.STRING, {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
|
"pysssss.autocomplete": False,
|
||||||
"dynamicPrompts": True,
|
"dynamicPrompts": True,
|
||||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||||
@@ -128,4 +130,142 @@ class LoraManagerLoader:
|
|||||||
|
|
||||||
formatted_loras_text = " ".join(formatted_loras)
|
formatted_loras_text = " ".join(formatted_loras)
|
||||||
|
|
||||||
|
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||||
|
|
||||||
|
class LoraManagerTextLoader:
|
||||||
|
NAME = "LoRA Text Loader (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/loaders"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"model": ("MODEL",),
|
||||||
|
"lora_syntax": (IO.STRING, {
|
||||||
|
"defaultInput": True,
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"clip": ("CLIP",),
|
||||||
|
"lora_stack": ("LORA_STACK",),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||||
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
|
FUNCTION = "load_loras_from_text"
|
||||||
|
|
||||||
|
def parse_lora_syntax(self, text):
|
||||||
|
"""Parse LoRA syntax from text input."""
|
||||||
|
# Pattern to match <lora:name:strength> or <lora:name:model_strength:clip_strength>
|
||||||
|
pattern = r'<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>'
|
||||||
|
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||||
|
|
||||||
|
loras = []
|
||||||
|
for match in matches:
|
||||||
|
lora_name = match[0].strip()
|
||||||
|
model_strength = float(match[1])
|
||||||
|
clip_strength = float(match[2]) if match[2] else model_strength
|
||||||
|
|
||||||
|
loras.append({
|
||||||
|
'name': lora_name,
|
||||||
|
'model_strength': model_strength,
|
||||||
|
'clip_strength': clip_strength
|
||||||
|
})
|
||||||
|
|
||||||
|
return loras
|
||||||
|
|
||||||
|
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
||||||
|
"""Load LoRAs based on text syntax input."""
|
||||||
|
loaded_loras = []
|
||||||
|
all_trigger_words = []
|
||||||
|
|
||||||
|
# Check if model is a Nunchaku Flux model - simplified approach
|
||||||
|
is_nunchaku_model = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_wrapper = model.model.diffusion_model
|
||||||
|
# Check if model is a Nunchaku Flux model using only class name
|
||||||
|
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
|
||||||
|
is_nunchaku_model = True
|
||||||
|
logger.info("Detected Nunchaku Flux model")
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
# Not a model with the expected structure
|
||||||
|
pass
|
||||||
|
|
||||||
|
# First process lora_stack if available
|
||||||
|
if lora_stack:
|
||||||
|
for lora_path, model_strength, clip_strength in lora_stack:
|
||||||
|
# Apply the LoRA using the appropriate loader
|
||||||
|
if is_nunchaku_model:
|
||||||
|
# Use our custom function for Flux models
|
||||||
|
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||||
|
# clip remains unchanged for Nunchaku models
|
||||||
|
else:
|
||||||
|
# Use default loader for standard models
|
||||||
|
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||||
|
|
||||||
|
# Extract lora name for trigger words lookup
|
||||||
|
lora_name = extract_lora_name(lora_path)
|
||||||
|
_, trigger_words = get_lora_info(lora_name)
|
||||||
|
|
||||||
|
all_trigger_words.extend(trigger_words)
|
||||||
|
# Add clip strength to output if different from model strength (except for Nunchaku models)
|
||||||
|
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||||
|
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||||
|
else:
|
||||||
|
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||||
|
|
||||||
|
# Parse and process LoRAs from text syntax
|
||||||
|
parsed_loras = self.parse_lora_syntax(lora_syntax)
|
||||||
|
for lora in parsed_loras:
|
||||||
|
lora_name = lora['name']
|
||||||
|
model_strength = lora['model_strength']
|
||||||
|
clip_strength = lora['clip_strength']
|
||||||
|
|
||||||
|
# Get lora path and trigger words
|
||||||
|
lora_path, trigger_words = get_lora_info(lora_name)
|
||||||
|
|
||||||
|
# Apply the LoRA using the appropriate loader
|
||||||
|
if is_nunchaku_model:
|
||||||
|
# For Nunchaku models, use our custom function
|
||||||
|
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||||
|
# clip remains unchanged
|
||||||
|
else:
|
||||||
|
# Use default loader for standard models
|
||||||
|
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||||
|
|
||||||
|
# Include clip strength in output if different from model strength and not a Nunchaku model
|
||||||
|
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||||
|
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||||
|
else:
|
||||||
|
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||||
|
|
||||||
|
# Add trigger words to collection
|
||||||
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
|
# use ',, ' to separate trigger words for group mode
|
||||||
|
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
|
||||||
|
# Format loaded_loras with support for both formats
|
||||||
|
formatted_loras = []
|
||||||
|
for item in loaded_loras:
|
||||||
|
parts = item.split(":")
|
||||||
|
lora_name = parts[0].strip()
|
||||||
|
strength_parts = parts[1].strip().split(",")
|
||||||
|
|
||||||
|
if len(strength_parts) > 1:
|
||||||
|
# Different model and clip strengths
|
||||||
|
model_str = strength_parts[0].strip()
|
||||||
|
clip_str = strength_parts[1].strip()
|
||||||
|
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
|
||||||
|
else:
|
||||||
|
# Same strength for both
|
||||||
|
model_str = strength_parts[0].strip()
|
||||||
|
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
|
||||||
|
|
||||||
|
formatted_loras_text = " ".join(formatted_loras)
|
||||||
|
|
||||||
return (model, clip, trigger_words_text, formatted_loras_text)
|
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||||
@@ -17,6 +17,7 @@ class LoraStacker:
|
|||||||
"required": {
|
"required": {
|
||||||
"text": (IO.STRING, {
|
"text": (IO.STRING, {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
|
"pysssss.autocomplete": False,
|
||||||
"dynamicPrompts": True,
|
"dynamicPrompts": True,
|
||||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ class WanVideoLoraSelect:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load the LORA model with less VRAM usage, slower loading"}),
|
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||||
|
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||||
"text": (IO.STRING, {
|
"text": (IO.STRING, {
|
||||||
"multiline": True,
|
"multiline": True,
|
||||||
|
"pysssss.autocomplete": False,
|
||||||
"dynamicPrompts": True,
|
"dynamicPrompts": True,
|
||||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||||
@@ -29,7 +31,7 @@ class WanVideoLoraSelect:
|
|||||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||||
FUNCTION = "process_loras"
|
FUNCTION = "process_loras"
|
||||||
|
|
||||||
def process_loras(self, text, low_mem_load=False, **kwargs):
|
def process_loras(self, text, low_mem_load=False, merge_loras=True, **kwargs):
|
||||||
loras_list = []
|
loras_list = []
|
||||||
all_trigger_words = []
|
all_trigger_words = []
|
||||||
active_loras = []
|
active_loras = []
|
||||||
@@ -38,6 +40,9 @@ class WanVideoLoraSelect:
|
|||||||
prev_lora = kwargs.get('prev_lora', None)
|
prev_lora = kwargs.get('prev_lora', None)
|
||||||
if prev_lora is not None:
|
if prev_lora is not None:
|
||||||
loras_list.extend(prev_lora)
|
loras_list.extend(prev_lora)
|
||||||
|
|
||||||
|
if not merge_loras:
|
||||||
|
low_mem_load = False # Unmerged LoRAs don't need low_mem_load
|
||||||
|
|
||||||
# Get blocks if available
|
# Get blocks if available
|
||||||
blocks = kwargs.get('blocks', {})
|
blocks = kwargs.get('blocks', {})
|
||||||
@@ -65,6 +70,7 @@ class WanVideoLoraSelect:
|
|||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
"merge_loras": merge_loras,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add to list and collect active loras
|
# Add to list and collect active loras
|
||||||
|
|||||||
128
py/nodes/wanvideo_lora_select_from_text.py
Normal file
128
py/nodes/wanvideo_lora_select_from_text.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from comfy.comfy_types import IO
|
||||||
|
import folder_paths
|
||||||
|
from ..utils.utils import get_lora_info
|
||||||
|
from .utils import any_type
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 初始化日志记录器
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 定义新节点的类
|
||||||
|
class WanVideoLoraSelectFromText:
|
||||||
|
# 节点在UI中显示的名称
|
||||||
|
NAME = "WanVideo Lora Select From Text (LoraManager)"
|
||||||
|
# 节点所属的分类
|
||||||
|
CATEGORY = "Lora Manager/stackers"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||||
|
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||||
|
"lora_syntax": (IO.STRING, {
|
||||||
|
"multiline": True,
|
||||||
|
"defaultInput": True,
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
"optional": {
|
||||||
|
"prev_lora": ("WANVIDLORA",),
|
||||||
|
"blocks": ("BLOCKS",)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
||||||
|
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||||
|
|
||||||
|
FUNCTION = "process_loras_from_syntax"
|
||||||
|
|
||||||
|
def process_loras_from_syntax(self, lora_syntax, low_mem_load=False, merge_lora=True, **kwargs):
|
||||||
|
text_to_process = lora_syntax
|
||||||
|
|
||||||
|
blocks = kwargs.get('blocks', {})
|
||||||
|
selected_blocks = blocks.get("selected_blocks", {})
|
||||||
|
layer_filter = blocks.get("layer_filter", "")
|
||||||
|
|
||||||
|
loras_list = []
|
||||||
|
all_trigger_words = []
|
||||||
|
active_loras = []
|
||||||
|
|
||||||
|
prev_lora = kwargs.get('prev_lora', None)
|
||||||
|
if prev_lora is not None:
|
||||||
|
loras_list.extend(prev_lora)
|
||||||
|
|
||||||
|
if not merge_lora:
|
||||||
|
low_mem_load = False
|
||||||
|
|
||||||
|
parts = text_to_process.split('<lora:')
|
||||||
|
for part in parts[1:]:
|
||||||
|
end_index = part.find('>')
|
||||||
|
if end_index == -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = part[:end_index]
|
||||||
|
lora_parts = content.split(':')
|
||||||
|
|
||||||
|
lora_name_raw = ""
|
||||||
|
model_strength = 1.0
|
||||||
|
clip_strength = 1.0
|
||||||
|
|
||||||
|
if len(lora_parts) == 2:
|
||||||
|
lora_name_raw = lora_parts[0].strip()
|
||||||
|
try:
|
||||||
|
model_strength = float(lora_parts[1])
|
||||||
|
clip_strength = model_strength
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logger.warning(f"Invalid strength for LoRA '{lora_name_raw}'. Skipping.")
|
||||||
|
continue
|
||||||
|
elif len(lora_parts) >= 3:
|
||||||
|
lora_name_raw = lora_parts[0].strip()
|
||||||
|
try:
|
||||||
|
model_strength = float(lora_parts[1])
|
||||||
|
clip_strength = float(lora_parts[2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logger.warning(f"Invalid strengths for LoRA '{lora_name_raw}'. Skipping.")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
||||||
|
|
||||||
|
lora_item = {
|
||||||
|
"path": folder_paths.get_full_path("loras", lora_path),
|
||||||
|
"strength": model_strength,
|
||||||
|
"name": lora_path.split(".")[0],
|
||||||
|
"blocks": selected_blocks,
|
||||||
|
"layer_filter": layer_filter,
|
||||||
|
"low_mem_load": low_mem_load,
|
||||||
|
"merge_loras": merge_lora,
|
||||||
|
}
|
||||||
|
|
||||||
|
loras_list.append(lora_item)
|
||||||
|
active_loras.append((lora_name_raw, model_strength, clip_strength))
|
||||||
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
|
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
|
||||||
|
formatted_loras = []
|
||||||
|
for name, model_strength, clip_strength in active_loras:
|
||||||
|
if abs(model_strength - clip_strength) > 0.001:
|
||||||
|
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}:{str(clip_strength).strip()}>")
|
||||||
|
else:
|
||||||
|
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}>")
|
||||||
|
|
||||||
|
active_loras_text = " ".join(formatted_loras)
|
||||||
|
|
||||||
|
return (loras_list, trigger_words_text, active_loras_text)
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
"WanVideoLoraSelectFromText": WanVideoLoraSelectFromText
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"WanVideoLoraSelectFromText": "WanVideo Lora Select From Text (LoraManager)"
|
||||||
|
}
|
||||||
|
,
|
||||||
@@ -11,6 +11,7 @@ import jinja2
|
|||||||
from ..utils.routes_common import ModelRouteUtils
|
from ..utils.routes_common import ModelRouteUtils
|
||||||
from ..services.websocket_manager import ws_manager
|
from ..services.websocket_manager import ws_manager
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
|
from ..services.server_i18n import server_i18n
|
||||||
from ..utils.utils import calculate_relative_path_for_model
|
from ..utils.utils import calculate_relative_path_for_model
|
||||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -48,12 +49,14 @@ class BaseModelRoutes(ABC):
|
|||||||
app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai)
|
app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai)
|
||||||
app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview)
|
app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview)
|
||||||
app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata)
|
app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata)
|
||||||
|
app.router.add_post(f'/api/{prefix}/add-tags', self.add_tags)
|
||||||
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
|
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
|
||||||
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
||||||
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
|
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
|
||||||
app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
|
app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
|
||||||
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
|
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
|
||||||
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
|
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
|
||||||
|
app.router.add_post(f'/api/{prefix}/auto-organize', self.auto_organize_models)
|
||||||
app.router.add_get(f'/api/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
|
app.router.add_get(f'/api/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
|
||||||
|
|
||||||
# Common query routes
|
# Common query routes
|
||||||
@@ -69,6 +72,8 @@ class BaseModelRoutes(ABC):
|
|||||||
app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
|
app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
|
||||||
app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
|
app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
|
||||||
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
|
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
|
||||||
|
app.router.add_get(f'/api/{prefix}/metadata', self.get_model_metadata)
|
||||||
|
app.router.add_get(f'/api/{prefix}/model-description', self.get_model_description)
|
||||||
|
|
||||||
# Autocomplete route
|
# Autocomplete route
|
||||||
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
|
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
|
||||||
@@ -111,30 +116,36 @@ class BaseModelRoutes(ABC):
|
|||||||
if not self.template_env or not template_name:
|
if not self.template_env or not template_name:
|
||||||
return web.Response(text="Template environment or template name not set", status=500)
|
return web.Response(text="Template environment or template name not set", status=500)
|
||||||
|
|
||||||
if is_initializing:
|
# Get user's language setting
|
||||||
rendered = self.template_env.get_template(template_name).render(
|
user_language = settings.get('language', 'en')
|
||||||
folders=[],
|
|
||||||
is_initializing=True,
|
# Set server-side i18n locale
|
||||||
settings=settings,
|
server_i18n.set_locale(user_language)
|
||||||
request=request
|
|
||||||
)
|
# Add i18n filter to the template environment if not already added
|
||||||
else:
|
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||||
|
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||||
|
self.template_env._i18n_filter_added = True
|
||||||
|
|
||||||
|
# Prepare template context
|
||||||
|
template_context = {
|
||||||
|
'is_initializing': is_initializing,
|
||||||
|
'settings': settings,
|
||||||
|
'request': request,
|
||||||
|
'folders': [],
|
||||||
|
't': server_i18n.get_translation,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not is_initializing:
|
||||||
try:
|
try:
|
||||||
cache = await self.service.scanner.get_cached_data(force_refresh=False)
|
cache = await self.service.scanner.get_cached_data(force_refresh=False)
|
||||||
rendered = self.template_env.get_template(template_name).render(
|
template_context['folders'] = getattr(cache, "folders", [])
|
||||||
folders=getattr(cache, "folders", []),
|
|
||||||
is_initializing=False,
|
|
||||||
settings=settings,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except Exception as cache_error:
|
except Exception as cache_error:
|
||||||
logger.error(f"Error loading cache data: {cache_error}")
|
logger.error(f"Error loading cache data: {cache_error}")
|
||||||
rendered = self.template_env.get_template(template_name).render(
|
template_context['is_initializing'] = True
|
||||||
folders=[],
|
|
||||||
is_initializing=True,
|
rendered = self.template_env.get_template(template_name).render(**template_context)
|
||||||
settings=settings,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
text=rendered,
|
text=rendered,
|
||||||
content_type='text/html'
|
content_type='text/html'
|
||||||
@@ -191,7 +202,7 @@ class BaseModelRoutes(ABC):
|
|||||||
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
||||||
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
||||||
'creator': request.query.get('search_creator', 'false').lower() == 'true',
|
'creator': request.query.get('search_creator', 'false').lower() == 'true',
|
||||||
'recursive': request.query.get('recursive', 'false').lower() == 'true',
|
'recursive': request.query.get('recursive', 'true').lower() == 'true',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse hash filters if provided
|
# Parse hash filters if provided
|
||||||
@@ -263,6 +274,10 @@ class BaseModelRoutes(ABC):
|
|||||||
"""Handle saving metadata updates"""
|
"""Handle saving metadata updates"""
|
||||||
return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner)
|
return await ModelRouteUtils.handle_save_metadata(request, self.service.scanner)
|
||||||
|
|
||||||
|
async def add_tags(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle adding tags to model metadata"""
|
||||||
|
return await ModelRouteUtils.handle_add_tags(request, self.service.scanner)
|
||||||
|
|
||||||
async def rename_model(self, request: web.Request) -> web.Response:
|
async def rename_model(self, request: web.Request) -> web.Response:
|
||||||
"""Handle renaming a model file and its associated files"""
|
"""Handle renaming a model file and its associated files"""
|
||||||
return await ModelRouteUtils.handle_rename_model(request, self.service.scanner)
|
return await ModelRouteUtils.handle_rename_model(request, self.service.scanner)
|
||||||
@@ -684,19 +699,27 @@ class BaseModelRoutes(ABC):
|
|||||||
source_dir = os.path.dirname(file_path)
|
source_dir = os.path.dirname(file_path)
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
|
||||||
if os.path.exists(target_file_path):
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': True,
|
||||||
'error': f"Target file already exists: {target_file_path}"
|
'message': 'Source and target directories are the same',
|
||||||
}, status=409)
|
'original_file_path': file_path,
|
||||||
success = await self.service.scanner.move_model(file_path, target_path)
|
'new_file_path': file_path
|
||||||
if success:
|
})
|
||||||
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
|
||||||
|
new_file_path = await self.service.scanner.move_model(file_path, target_path)
|
||||||
|
if new_file_path:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': new_file_path
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
return web.Response(text='Failed to move model', status=500)
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Failed to move model',
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': None
|
||||||
|
}, status=500)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
return web.Response(text=str(e), status=500)
|
return web.Response(text=str(e), status=500)
|
||||||
@@ -715,26 +738,28 @@ class BaseModelRoutes(ABC):
|
|||||||
source_dir = os.path.dirname(file_path)
|
source_dir = os.path.dirname(file_path)
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
results.append({
|
results.append({
|
||||||
"path": file_path,
|
"original_file_path": file_path,
|
||||||
|
"new_file_path": file_path,
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Source and target directories are the same"
|
"message": "Source and target directories are the same"
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
new_file_path = await self.service.scanner.move_model(file_path, target_path)
|
||||||
if os.path.exists(target_file_path):
|
if new_file_path:
|
||||||
results.append({
|
results.append({
|
||||||
"path": file_path,
|
"original_file_path": file_path,
|
||||||
"success": False,
|
"new_file_path": new_file_path,
|
||||||
"message": f"Target file already exists: {target_file_path}"
|
"success": True,
|
||||||
|
"message": "Success"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"original_file_path": file_path,
|
||||||
|
"new_file_path": None,
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to move model"
|
||||||
})
|
})
|
||||||
continue
|
|
||||||
success = await self.service.scanner.move_model(file_path, target_path)
|
|
||||||
results.append({
|
|
||||||
"path": file_path,
|
|
||||||
"success": success,
|
|
||||||
"message": "Success" if success else "Failed to move model"
|
|
||||||
})
|
|
||||||
success_count = sum(1 for r in results if r["success"])
|
success_count = sum(1 for r in results if r["success"])
|
||||||
failure_count = len(results) - success_count
|
failure_count = len(results) - success_count
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
@@ -749,7 +774,7 @@ class BaseModelRoutes(ABC):
|
|||||||
return web.Response(text=str(e), status=500)
|
return web.Response(text=str(e), status=500)
|
||||||
|
|
||||||
async def auto_organize_models(self, request: web.Request) -> web.Response:
|
async def auto_organize_models(self, request: web.Request) -> web.Response:
|
||||||
"""Auto-organize all models based on current settings"""
|
"""Auto-organize all models or a specific set of models based on current settings"""
|
||||||
try:
|
try:
|
||||||
# Check if auto-organize is already running
|
# Check if auto-organize is already running
|
||||||
if ws_manager.is_auto_organize_running():
|
if ws_manager.is_auto_organize_running():
|
||||||
@@ -767,8 +792,17 @@ class BaseModelRoutes(ABC):
|
|||||||
'error': 'Auto-organize is already running. Please wait for it to complete.'
|
'error': 'Auto-organize is already running. Please wait for it to complete.'
|
||||||
}, status=409)
|
}, status=409)
|
||||||
|
|
||||||
|
# Get specific file paths from request if this is a POST with selected models
|
||||||
|
file_paths = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_paths = data.get('file_paths')
|
||||||
|
except Exception:
|
||||||
|
pass # Continue with all models if no valid JSON
|
||||||
|
|
||||||
async with auto_organize_lock:
|
async with auto_organize_lock:
|
||||||
return await self._perform_auto_organize()
|
return await self._perform_auto_organize(file_paths)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
|
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
|
||||||
@@ -785,20 +819,33 @@ class BaseModelRoutes(ABC):
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
async def _perform_auto_organize(self) -> web.Response:
|
async def _perform_auto_organize(self, file_paths=None) -> web.Response:
|
||||||
"""Perform the actual auto-organize operation"""
|
"""Perform the actual auto-organize operation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: Optional list of specific file paths to organize.
|
||||||
|
If None, organizes all models.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all models from cache
|
# Get all models from cache
|
||||||
cache = await self.service.scanner.get_cached_data()
|
cache = await self.service.scanner.get_cached_data()
|
||||||
all_models = cache.raw_data
|
all_models = cache.raw_data
|
||||||
|
|
||||||
|
# Filter models if specific file paths are provided
|
||||||
|
if file_paths:
|
||||||
|
all_models = [model for model in all_models if model.get('file_path') in file_paths]
|
||||||
|
operation_type = 'bulk'
|
||||||
|
else:
|
||||||
|
operation_type = 'all'
|
||||||
|
|
||||||
# Get model roots for this scanner
|
# Get model roots for this scanner
|
||||||
model_roots = self.service.get_model_roots()
|
model_roots = self.service.get_model_roots()
|
||||||
if not model_roots:
|
if not model_roots:
|
||||||
await ws_manager.broadcast_auto_organize_progress({
|
await ws_manager.broadcast_auto_organize_progress({
|
||||||
'type': 'auto_organize_progress',
|
'type': 'auto_organize_progress',
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error': 'No model roots configured'
|
'error': 'No model roots configured',
|
||||||
|
'operation_type': operation_type
|
||||||
})
|
})
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
@@ -825,7 +872,8 @@ class BaseModelRoutes(ABC):
|
|||||||
'processed': 0,
|
'processed': 0,
|
||||||
'success': 0,
|
'success': 0,
|
||||||
'failures': 0,
|
'failures': 0,
|
||||||
'skipped': 0
|
'skipped': 0,
|
||||||
|
'operation_type': operation_type
|
||||||
})
|
})
|
||||||
|
|
||||||
# Process models in batches
|
# Process models in batches
|
||||||
@@ -956,7 +1004,8 @@ class BaseModelRoutes(ABC):
|
|||||||
'processed': processed,
|
'processed': processed,
|
||||||
'success': success_count,
|
'success': success_count,
|
||||||
'failures': failure_count,
|
'failures': failure_count,
|
||||||
'skipped': skipped_count
|
'skipped': skipped_count,
|
||||||
|
'operation_type': operation_type
|
||||||
})
|
})
|
||||||
|
|
||||||
# Small delay between batches to prevent overwhelming the system
|
# Small delay between batches to prevent overwhelming the system
|
||||||
@@ -971,7 +1020,8 @@ class BaseModelRoutes(ABC):
|
|||||||
'success': success_count,
|
'success': success_count,
|
||||||
'failures': failure_count,
|
'failures': failure_count,
|
||||||
'skipped': skipped_count,
|
'skipped': skipped_count,
|
||||||
'message': 'Cleaning up empty directories...'
|
'message': 'Cleaning up empty directories...',
|
||||||
|
'operation_type': operation_type
|
||||||
})
|
})
|
||||||
|
|
||||||
# Clean up empty directories after organizing
|
# Clean up empty directories after organizing
|
||||||
@@ -990,20 +1040,22 @@ class BaseModelRoutes(ABC):
|
|||||||
'success': success_count,
|
'success': success_count,
|
||||||
'failures': failure_count,
|
'failures': failure_count,
|
||||||
'skipped': skipped_count,
|
'skipped': skipped_count,
|
||||||
'cleanup': cleanup_counts
|
'cleanup': cleanup_counts,
|
||||||
|
'operation_type': operation_type
|
||||||
})
|
})
|
||||||
|
|
||||||
# Prepare response with limited details
|
# Prepare response with limited details
|
||||||
response_data = {
|
response_data = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': f'Auto-organize completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
|
'message': f'Auto-organize {operation_type} completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
|
||||||
'summary': {
|
'summary': {
|
||||||
'total': total_models,
|
'total': total_models,
|
||||||
'success': success_count,
|
'success': success_count,
|
||||||
'skipped': skipped_count,
|
'skipped': skipped_count,
|
||||||
'failures': failure_count,
|
'failures': failure_count,
|
||||||
'organization_type': 'flat' if is_flat_structure else 'structured',
|
'organization_type': 'flat' if is_flat_structure else 'structured',
|
||||||
'cleaned_dirs': cleanup_counts
|
'cleaned_dirs': cleanup_counts,
|
||||||
|
'operation_type': operation_type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,7 +1075,8 @@ class BaseModelRoutes(ABC):
|
|||||||
await ws_manager.broadcast_auto_organize_progress({
|
await ws_manager.broadcast_auto_organize_progress({
|
||||||
'type': 'auto_organize_progress',
|
'type': 'auto_organize_progress',
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error': str(e)
|
'error': str(e),
|
||||||
|
'operation_type': operation_type if 'operation_type' in locals() else 'unknown'
|
||||||
})
|
})
|
||||||
|
|
||||||
raise e
|
raise e
|
||||||
@@ -1128,6 +1181,58 @@ class BaseModelRoutes(ABC):
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_model_metadata(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get filtered CivitAI metadata for a model by file path"""
|
||||||
|
try:
|
||||||
|
file_path = request.query.get('file_path')
|
||||||
|
if not file_path:
|
||||||
|
return web.Response(text='File path is required', status=400)
|
||||||
|
|
||||||
|
metadata = await self.service.get_model_metadata(file_path)
|
||||||
|
if metadata is not None:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'metadata': metadata
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'{self.model_type.capitalize()} not found or no CivitAI metadata available'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting {self.model_type} metadata: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_model_description(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get model description by file path"""
|
||||||
|
try:
|
||||||
|
file_path = request.query.get('file_path')
|
||||||
|
if not file_path:
|
||||||
|
return web.Response(text='File path is required', status=400)
|
||||||
|
|
||||||
|
description = await self.service.get_model_description(file_path)
|
||||||
|
if description is not None:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'description': description
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'{self.model_type.capitalize()} not found or no description available'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting {self.model_type} description: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||||
"""Get model relative file paths for autocomplete functionality"""
|
"""Get model relative file paths for autocomplete functionality"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
# LoRA-specific query routes
|
# LoRA-specific query routes
|
||||||
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
|
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
|
||||||
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
|
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
|
||||||
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
|
|
||||||
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
|
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
|
||||||
|
|
||||||
# CivitAI integration with LoRA-specific validation
|
# CivitAI integration with LoRA-specific validation
|
||||||
@@ -298,74 +297,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
"error": str(e)
|
"error": str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get model description for a Lora model"""
|
|
||||||
try:
|
|
||||||
# Get parameters
|
|
||||||
model_id = request.query.get('model_id')
|
|
||||||
file_path = request.query.get('file_path')
|
|
||||||
|
|
||||||
if not model_id:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Model ID is required'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Check if we already have the description stored in metadata
|
|
||||||
description = None
|
|
||||||
tags = []
|
|
||||||
creator = {}
|
|
||||||
if file_path:
|
|
||||||
import os
|
|
||||||
from ..utils.metadata_manager import MetadataManager
|
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
|
||||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
|
||||||
description = metadata.get('modelDescription')
|
|
||||||
tags = metadata.get('tags', [])
|
|
||||||
creator = metadata.get('creator', {})
|
|
||||||
|
|
||||||
# If description is not in metadata, fetch from CivitAI
|
|
||||||
if not description:
|
|
||||||
logger.info(f"Fetching model metadata for model ID: {model_id}")
|
|
||||||
model_metadata, _ = await self.civitai_client.get_model_metadata(model_id)
|
|
||||||
|
|
||||||
if model_metadata:
|
|
||||||
description = model_metadata.get('description')
|
|
||||||
tags = model_metadata.get('tags', [])
|
|
||||||
creator = model_metadata.get('creator', {})
|
|
||||||
|
|
||||||
# Save the metadata to file if we have a file path and got metadata
|
|
||||||
if file_path:
|
|
||||||
try:
|
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
|
||||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
|
||||||
|
|
||||||
metadata['modelDescription'] = description
|
|
||||||
metadata['tags'] = tags
|
|
||||||
# Ensure the civitai dict exists
|
|
||||||
if 'civitai' not in metadata:
|
|
||||||
metadata['civitai'] = {}
|
|
||||||
# Store creator in the civitai nested structure
|
|
||||||
metadata['civitai']['creator'] = creator
|
|
||||||
|
|
||||||
await MetadataManager.save_metadata(file_path, metadata, True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving model metadata: {e}")
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'description': description or "<p>No model description available.</p>",
|
|
||||||
'tags': tags,
|
|
||||||
'creator': creator
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting model metadata: {e}")
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
||||||
"""Get trigger words for specified LoRA models"""
|
"""Get trigger words for specified LoRA models"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -183,16 +182,6 @@ class MiscRoutes:
|
|||||||
if old_path != value:
|
if old_path != value:
|
||||||
logger.info(f"Example images path changed to {value} - server restart required")
|
logger.info(f"Example images path changed to {value} - server restart required")
|
||||||
|
|
||||||
# Special handling for base_model_path_mappings - parse JSON string
|
|
||||||
if (key == 'base_model_path_mappings' or key == 'download_path_templates') and value:
|
|
||||||
try:
|
|
||||||
value = json.loads(value)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"Invalid JSON format for base_model_path_mappings: {value}"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Save to settings
|
# Save to settings
|
||||||
settings.set(key, value)
|
settings.set(key, value)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from ..recipes import RecipeParserFactory
|
|||||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||||
|
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
|
from ..services.server_i18n import server_i18n
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
@@ -127,6 +128,17 @@ class RecipeRoutes:
|
|||||||
# Ensure services are initialized
|
# Ensure services are initialized
|
||||||
await self.init_services()
|
await self.init_services()
|
||||||
|
|
||||||
|
# 获取用户语言设置
|
||||||
|
user_language = settings.get('language', 'en')
|
||||||
|
|
||||||
|
# 设置服务端i18n语言
|
||||||
|
server_i18n.set_locale(user_language)
|
||||||
|
|
||||||
|
# 为模板环境添加i18n过滤器
|
||||||
|
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||||
|
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||||
|
self.template_env._i18n_filter_added = True
|
||||||
|
|
||||||
# Skip initialization check and directly try to get cached data
|
# Skip initialization check and directly try to get cached data
|
||||||
try:
|
try:
|
||||||
# Recipe scanner will initialize cache if needed
|
# Recipe scanner will initialize cache if needed
|
||||||
@@ -136,7 +148,9 @@ class RecipeRoutes:
|
|||||||
recipes=[], # Frontend will load recipes via API
|
recipes=[], # Frontend will load recipes via API
|
||||||
is_initializing=False,
|
is_initializing=False,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
request=request
|
request=request,
|
||||||
|
# 添加服务端翻译函数
|
||||||
|
t=server_i18n.get_translation,
|
||||||
)
|
)
|
||||||
except Exception as cache_error:
|
except Exception as cache_error:
|
||||||
logger.error(f"Error loading recipe cache data: {cache_error}")
|
logger.error(f"Error loading recipe cache data: {cache_error}")
|
||||||
@@ -145,7 +159,9 @@ class RecipeRoutes:
|
|||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
is_initializing=True,
|
is_initializing=True,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
request=request
|
request=request,
|
||||||
|
# 添加服务端翻译函数
|
||||||
|
t=server_i18n.get_translation,
|
||||||
)
|
)
|
||||||
logger.info("Recipe cache error, returning initialization page")
|
logger.info("Recipe cache error, returning initialization page")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Dict, List, Any
|
|||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
|
from ..services.server_i18n import server_i18n
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
|
|
||||||
@@ -58,11 +59,23 @@ class StatsRoutes:
|
|||||||
|
|
||||||
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
||||||
|
|
||||||
|
# 获取用户语言设置
|
||||||
|
user_language = settings.get('language', 'en')
|
||||||
|
|
||||||
|
# 设置服务端i18n语言
|
||||||
|
server_i18n.set_locale(user_language)
|
||||||
|
|
||||||
|
# 为模板环境添加i18n过滤器
|
||||||
|
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||||
|
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||||
|
self.template_env._i18n_filter_added = True
|
||||||
|
|
||||||
template = self.template_env.get_template('statistics.html')
|
template = self.template_env.get_template('statistics.html')
|
||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
is_initializing=is_initializing,
|
is_initializing=is_initializing,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
request=request
|
request=request,
|
||||||
|
t=server_i18n.get_translation,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
|
from ..utils.routes_common import ModelRouteUtils
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from ..utils.constants import NSFW_LEVELS
|
||||||
from .settings_manager import settings
|
from .settings_manager import settings
|
||||||
from ..utils.utils import fuzzy_match
|
from ..utils.utils import fuzzy_match
|
||||||
@@ -68,7 +69,7 @@ class BaseModelService(ABC):
|
|||||||
'filename': True,
|
'filename': True,
|
||||||
'modelname': True,
|
'modelname': True,
|
||||||
'tags': False,
|
'tags': False,
|
||||||
'recursive': False,
|
'recursive': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the base data set using new sort logic
|
# Get the base data set using new sort logic
|
||||||
@@ -139,12 +140,20 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
# Apply folder filtering
|
# Apply folder filtering
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
if search_options and search_options.get('recursive', False):
|
if search_options and search_options.get('recursive', True):
|
||||||
# Recursive folder filtering - include all subfolders
|
# Recursive folder filtering - include all subfolders
|
||||||
data = [
|
# Ensure we match exact folder or its subfolders by checking path boundaries
|
||||||
item for item in data
|
if folder == "":
|
||||||
if item['folder'].startswith(folder)
|
# Empty folder means root - include all items
|
||||||
]
|
pass # Don't filter anything
|
||||||
|
else:
|
||||||
|
# Add trailing slash to ensure we match folder boundaries correctly
|
||||||
|
folder_with_separator = folder + "/"
|
||||||
|
data = [
|
||||||
|
item for item in data
|
||||||
|
if (item['folder'] == folder or
|
||||||
|
item['folder'].startswith(folder_with_separator))
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
# Exact folder filtering
|
# Exact folder filtering
|
||||||
data = [
|
data = [
|
||||||
@@ -379,6 +388,26 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
||||||
|
|
||||||
|
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||||
|
"""Get filtered CivitAI metadata for a model by file path"""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for model in cache.raw_data:
|
||||||
|
if model.get('file_path') == file_path:
|
||||||
|
return ModelRouteUtils.filter_civitai_data(model.get("civitai", {}))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
"""Get model description by file path"""
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for model in cache.raw_data:
|
||||||
|
if model.get('file_path') == file_path:
|
||||||
|
return model.get('modelDescription', '')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
||||||
"""Search model relative file paths for autocomplete functionality"""
|
"""Search model relative file paths for autocomplete functionality"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
@@ -398,12 +427,12 @@ class BaseModelService(ABC):
|
|||||||
relative_path = None
|
relative_path = None
|
||||||
for root in model_roots:
|
for root in model_roots:
|
||||||
# Normalize paths for comparison
|
# Normalize paths for comparison
|
||||||
normalized_root = os.path.normpath(root).replace(os.sep, '/')
|
normalized_root = os.path.normpath(root)
|
||||||
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
|
normalized_file = os.path.normpath(file_path)
|
||||||
|
|
||||||
if normalized_file.startswith(normalized_root):
|
if normalized_file.startswith(normalized_root):
|
||||||
# Remove root and leading slash to get relative path
|
# Remove root and leading separator to get relative path
|
||||||
relative_path = normalized_file[len(normalized_root):].lstrip('/')
|
relative_path = normalized_file[len(normalized_root):].lstrip(os.sep)
|
||||||
break
|
break
|
||||||
|
|
||||||
if relative_path and search_lower in relative_path.lower():
|
if relative_path and search_lower in relative_path.lower():
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ class CheckpointService(BaseModelService):
|
|||||||
"file_size": checkpoint_data.get("size", 0),
|
"file_size": checkpoint_data.get("size", 0),
|
||||||
"modified": checkpoint_data.get("modified", ""),
|
"modified": checkpoint_data.get("modified", ""),
|
||||||
"tags": checkpoint_data.get("tags", []),
|
"tags": checkpoint_data.get("tags", []),
|
||||||
"modelDescription": checkpoint_data.get("modelDescription", ""),
|
|
||||||
"from_civitai": checkpoint_data.get("from_civitai", True),
|
"from_civitai": checkpoint_data.get("from_civitai", True),
|
||||||
"notes": checkpoint_data.get("notes", ""),
|
"notes": checkpoint_data.get("notes", ""),
|
||||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||||
"favorite": checkpoint_data.get("favorite", False),
|
"favorite": checkpoint_data.get("favorite", False),
|
||||||
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}))
|
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class CivitaiClient:
|
|||||||
}
|
}
|
||||||
self._session = None
|
self._session = None
|
||||||
self._session_created_at = None
|
self._session_created_at = None
|
||||||
# Set default buffer size to 1MB for higher throughput
|
# Adjust chunk size based on storage type - consider making this configurable
|
||||||
self.chunk_size = 1024 * 1024
|
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better HDD throughput
|
||||||
|
|
||||||
@property
|
@property
|
||||||
async def session(self) -> aiohttp.ClientSession:
|
async def session(self) -> aiohttp.ClientSession:
|
||||||
@@ -49,8 +49,8 @@ class CivitaiClient:
|
|||||||
enable_cleanup_closed=True
|
enable_cleanup_closed=True
|
||||||
)
|
)
|
||||||
trust_env = True # Allow using system environment proxy settings
|
trust_env = True # Allow using system environment proxy settings
|
||||||
# Configure timeout parameters - increase read timeout for large files
|
# Configure timeout parameters - increase read timeout for large files and remove sock_read timeout
|
||||||
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=120)
|
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=None)
|
||||||
self._session = aiohttp.ClientSession(
|
self._session = aiohttp.ClientSession(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
trust_env=trust_env,
|
trust_env=trust_env,
|
||||||
@@ -102,7 +102,7 @@ class CivitaiClient:
|
|||||||
return headers
|
return headers
|
||||||
|
|
||||||
async def _download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
|
async def _download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
|
||||||
"""Download file with content-disposition support and progress tracking
|
"""Download file with resumable downloads and retry mechanism
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Download URL
|
url: Download URL
|
||||||
@@ -113,73 +113,176 @@ class CivitaiClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple[bool, str]: (success, save_path or error message)
|
Tuple[bool, str]: (success, save_path or error message)
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Resolving DNS for: {url}")
|
max_retries = 5
|
||||||
|
retry_count = 0
|
||||||
|
base_delay = 2.0 # Base delay for exponential backoff
|
||||||
|
|
||||||
|
# Initial setup
|
||||||
session = await self._ensure_fresh_session()
|
session = await self._ensure_fresh_session()
|
||||||
try:
|
save_path = os.path.join(save_dir, default_filename)
|
||||||
headers = self._get_request_headers()
|
part_path = save_path + '.part'
|
||||||
|
|
||||||
# Add Range header to allow resumable downloads
|
# Get existing file size for resume
|
||||||
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads
|
resume_offset = 0
|
||||||
|
if os.path.exists(part_path):
|
||||||
logger.debug(f"Starting download from: {url}")
|
resume_offset = os.path.getsize(part_path)
|
||||||
async with session.get(url, headers=headers, allow_redirects=True) as response:
|
logger.info(f"Resuming download from offset {resume_offset} bytes")
|
||||||
if response.status != 200:
|
|
||||||
# Handle 401 unauthorized responses
|
total_size = 0
|
||||||
if response.status == 401:
|
filename = default_filename
|
||||||
|
|
||||||
|
while retry_count <= max_retries:
|
||||||
|
try:
|
||||||
|
headers = self._get_request_headers()
|
||||||
|
|
||||||
|
# Add Range header for resume if we have partial data
|
||||||
|
if resume_offset > 0:
|
||||||
|
headers['Range'] = f'bytes={resume_offset}-'
|
||||||
|
|
||||||
|
# Add Range header to allow resumable downloads
|
||||||
|
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads
|
||||||
|
|
||||||
|
logger.debug(f"Download attempt {retry_count + 1}/{max_retries + 1} from: {url}")
|
||||||
|
if resume_offset > 0:
|
||||||
|
logger.debug(f"Requesting range from byte {resume_offset}")
|
||||||
|
|
||||||
|
async with session.get(url, headers=headers, allow_redirects=True) as response:
|
||||||
|
# Handle different response codes
|
||||||
|
if response.status == 200:
|
||||||
|
# Full content response
|
||||||
|
if resume_offset > 0:
|
||||||
|
# Server doesn't support ranges, restart from beginning
|
||||||
|
logger.warning("Server doesn't support range requests, restarting download")
|
||||||
|
resume_offset = 0
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
os.remove(part_path)
|
||||||
|
elif response.status == 206:
|
||||||
|
# Partial content response (resume successful)
|
||||||
|
content_range = response.headers.get('Content-Range')
|
||||||
|
if content_range:
|
||||||
|
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
|
||||||
|
range_parts = content_range.split('/')
|
||||||
|
if len(range_parts) == 2:
|
||||||
|
total_size = int(range_parts[1])
|
||||||
|
logger.info(f"Successfully resumed download from byte {resume_offset}")
|
||||||
|
elif response.status == 416:
|
||||||
|
# Range not satisfiable - file might be complete or corrupted
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
part_size = os.path.getsize(part_path)
|
||||||
|
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
|
||||||
|
# Try to get actual file size
|
||||||
|
head_response = await session.head(url, headers=self._get_request_headers())
|
||||||
|
if head_response.status == 200:
|
||||||
|
actual_size = int(head_response.headers.get('content-length', 0))
|
||||||
|
if part_size == actual_size:
|
||||||
|
# File is complete, just rename it
|
||||||
|
os.rename(part_path, save_path)
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback(100)
|
||||||
|
return True, save_path
|
||||||
|
# Remove corrupted part file and restart
|
||||||
|
os.remove(part_path)
|
||||||
|
resume_offset = 0
|
||||||
|
continue
|
||||||
|
elif response.status == 401:
|
||||||
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
||||||
|
|
||||||
return False, "Invalid or missing CivitAI API key, or early access restriction."
|
return False, "Invalid or missing CivitAI API key, or early access restriction."
|
||||||
|
elif response.status == 403:
|
||||||
# Handle other client errors that might be permission-related
|
|
||||||
if response.status == 403:
|
|
||||||
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
|
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
|
||||||
return False, "Access forbidden: You don't have permission to download this file."
|
return False, "Access forbidden: You don't have permission to download this file."
|
||||||
|
else:
|
||||||
|
logger.error(f"Download failed for {url} with status {response.status}")
|
||||||
|
return False, f"Download failed with status {response.status}"
|
||||||
|
|
||||||
# Generic error response for other status codes
|
# Get total file size for progress calculation (if not set from Content-Range)
|
||||||
logger.error(f"Download failed for {url} with status {response.status}")
|
if total_size == 0:
|
||||||
return False, f"Download failed with status {response.status}"
|
total_size = int(response.headers.get('content-length', 0))
|
||||||
|
if response.status == 206:
|
||||||
|
# For partial content, add the offset to get total file size
|
||||||
|
total_size += resume_offset
|
||||||
|
|
||||||
# Get filename from content-disposition header
|
current_size = resume_offset
|
||||||
content_disposition = response.headers.get('Content-Disposition')
|
last_progress_report_time = datetime.now()
|
||||||
filename = self._parse_content_disposition(content_disposition)
|
|
||||||
if not filename:
|
|
||||||
filename = default_filename
|
|
||||||
|
|
||||||
save_path = os.path.join(save_dir, filename)
|
|
||||||
|
|
||||||
# Get total file size for progress calculation
|
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
|
||||||
current_size = 0
|
|
||||||
last_progress_report_time = datetime.now()
|
|
||||||
|
|
||||||
# Stream download to file with progress updates using larger buffer
|
# Stream download to file with progress updates using larger buffer
|
||||||
with open(save_path, 'wb') as f:
|
loop = asyncio.get_running_loop()
|
||||||
async for chunk in response.content.iter_chunked(self.chunk_size):
|
mode = 'ab' if resume_offset > 0 else 'wb'
|
||||||
if chunk:
|
with open(part_path, mode) as f:
|
||||||
f.write(chunk)
|
async for chunk in response.content.iter_chunked(self.chunk_size):
|
||||||
current_size += len(chunk)
|
if chunk:
|
||||||
|
# Run blocking file write in executor
|
||||||
# Limit progress update frequency to reduce overhead
|
await loop.run_in_executor(None, f.write, chunk)
|
||||||
now = datetime.now()
|
current_size += len(chunk)
|
||||||
time_diff = (now - last_progress_report_time).total_seconds()
|
|
||||||
|
# Limit progress update frequency to reduce overhead
|
||||||
if progress_callback and total_size and time_diff >= 1.0:
|
now = datetime.now()
|
||||||
progress = (current_size / total_size) * 100
|
time_diff = (now - last_progress_report_time).total_seconds()
|
||||||
await progress_callback(progress)
|
|
||||||
last_progress_report_time = now
|
if progress_callback and total_size and time_diff >= 1.0:
|
||||||
|
progress = (current_size / total_size) * 100
|
||||||
# Ensure 100% progress is reported
|
await progress_callback(progress)
|
||||||
if progress_callback:
|
last_progress_report_time = now
|
||||||
await progress_callback(100)
|
|
||||||
|
# Download completed successfully
|
||||||
|
# Verify file size if total_size was provided
|
||||||
|
final_size = os.path.getsize(part_path)
|
||||||
|
if total_size > 0 and final_size != total_size:
|
||||||
|
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
|
||||||
|
# Don't treat this as fatal error, rename anyway
|
||||||
|
|
||||||
|
# Atomically rename .part to final file with retries
|
||||||
|
max_rename_attempts = 5
|
||||||
|
rename_attempt = 0
|
||||||
|
rename_success = False
|
||||||
|
|
||||||
|
while rename_attempt < max_rename_attempts and not rename_success:
|
||||||
|
try:
|
||||||
|
os.rename(part_path, save_path)
|
||||||
|
rename_success = True
|
||||||
|
except PermissionError as e:
|
||||||
|
rename_attempt += 1
|
||||||
|
if rename_attempt < max_rename_attempts:
|
||||||
|
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})")
|
||||||
|
await asyncio.sleep(2) # Wait before retrying
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
||||||
|
return False, f"Failed to finalize download: {str(e)}"
|
||||||
|
|
||||||
|
# Ensure 100% progress is reported
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback(100)
|
||||||
|
|
||||||
return True, save_path
|
return True, save_path
|
||||||
|
|
||||||
|
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
|
||||||
|
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
|
||||||
|
retry_count += 1
|
||||||
|
logger.warning(f"Network error during download (attempt {retry_count}/{max_retries + 1}): {e}")
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
if retry_count <= max_retries:
|
||||||
logger.error(f"Network error during download: {e}")
|
# Calculate delay with exponential backoff
|
||||||
return False, f"Network error: {str(e)}"
|
delay = base_delay * (2 ** (retry_count - 1))
|
||||||
except Exception as e:
|
logger.info(f"Retrying in {delay} seconds...")
|
||||||
logger.error(f"Download error: {e}")
|
await asyncio.sleep(delay)
|
||||||
return False, str(e)
|
|
||||||
|
# Update resume offset for next attempt
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
resume_offset = os.path.getsize(part_path)
|
||||||
|
logger.info(f"Will resume from byte {resume_offset}")
|
||||||
|
|
||||||
|
# Refresh session to get new connection
|
||||||
|
await self.close()
|
||||||
|
session = await self._ensure_fresh_session()
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error(f"Max retries exceeded for download: {e}")
|
||||||
|
return False, f"Network error after {max_retries + 1} attempts: {str(e)}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected download error: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
return False, f"Download failed after {max_retries + 1} attempts"
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
|
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -274,9 +274,9 @@ class DownloadManager:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00'))
|
date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00'))
|
||||||
formatted_date = date_obj.strftime('%Y-%m-%d')
|
formatted_date = date_obj.strftime('%Y-%m-%d')
|
||||||
early_access_msg = f"This model requires early access payment (until {formatted_date}). "
|
early_access_msg = f"This model requires payment (until {formatted_date}). "
|
||||||
except:
|
except:
|
||||||
early_access_msg = "This model requires early access payment. "
|
early_access_msg = "This model requires payment. "
|
||||||
|
|
||||||
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
|
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
|
||||||
logger.warning(f"Early access model detected: {version_info.get('name', 'Unknown')}")
|
logger.warning(f"Early access model detected: {version_info.get('name', 'Unknown')}")
|
||||||
@@ -321,6 +321,10 @@ class DownloadManager:
|
|||||||
download_id=download_id
|
download_id=download_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If early_access_msg exists and download failed, replace error message
|
||||||
|
if 'early_access_msg' in locals() and not result.get('success', False):
|
||||||
|
result['error'] = early_access_msg
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -385,18 +389,46 @@ class DownloadManager:
|
|||||||
return formatted_path
|
return formatted_path
|
||||||
|
|
||||||
async def _execute_download(self, download_url: str, save_dir: str,
|
async def _execute_download(self, download_url: str, save_dir: str,
|
||||||
metadata, version_info: Dict,
|
metadata, version_info: Dict,
|
||||||
relative_path: str, progress_callback=None,
|
relative_path: str, progress_callback=None,
|
||||||
model_type: str = "lora", download_id: str = None) -> Dict:
|
model_type: str = "lora", download_id: str = None) -> Dict:
|
||||||
"""Execute the actual download process including preview images and model files"""
|
"""Execute the actual download process including preview images and model files"""
|
||||||
try:
|
try:
|
||||||
civitai_client = await self._get_civitai_client()
|
civitai_client = await self._get_civitai_client()
|
||||||
save_path = metadata.file_path
|
|
||||||
|
# Extract original filename details
|
||||||
|
original_filename = os.path.basename(metadata.file_path)
|
||||||
|
base_name, extension = os.path.splitext(original_filename)
|
||||||
|
|
||||||
|
# Check for filename conflicts and generate unique filename if needed
|
||||||
|
# Use the hash from metadata for conflict resolution
|
||||||
|
def hash_provider():
|
||||||
|
return metadata.sha256
|
||||||
|
|
||||||
|
unique_filename = metadata.generate_unique_filename(
|
||||||
|
save_dir,
|
||||||
|
base_name,
|
||||||
|
extension,
|
||||||
|
hash_provider=hash_provider
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update paths if filename changed
|
||||||
|
if unique_filename != original_filename:
|
||||||
|
logger.info(f"Filename conflict detected. Changing '{original_filename}' to '{unique_filename}'")
|
||||||
|
save_path = os.path.join(save_dir, unique_filename)
|
||||||
|
# Update metadata with new file path and name
|
||||||
|
metadata.file_path = save_path.replace(os.sep, '/')
|
||||||
|
metadata.file_name = os.path.splitext(unique_filename)[0]
|
||||||
|
else:
|
||||||
|
save_path = metadata.file_path
|
||||||
|
|
||||||
|
part_path = save_path + '.part'
|
||||||
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
|
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
|
||||||
|
|
||||||
# Store file path in active_downloads for potential cleanup
|
# Store file paths in active_downloads for potential cleanup
|
||||||
if download_id and download_id in self._active_downloads:
|
if download_id and download_id in self._active_downloads:
|
||||||
self._active_downloads[download_id]['file_path'] = save_path
|
self._active_downloads[download_id]['file_path'] = save_path
|
||||||
|
self._active_downloads[download_id]['part_path'] = part_path
|
||||||
|
|
||||||
# Download preview image if available
|
# Download preview image if available
|
||||||
images = version_info.get('images', [])
|
images = version_info.get('images', [])
|
||||||
@@ -463,17 +495,29 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
# Clean up files on failure
|
# Clean up files on failure, but preserve .part file for resume
|
||||||
for path in [save_path, metadata_path, metadata.preview_url]:
|
cleanup_files = [metadata_path]
|
||||||
|
if metadata.preview_url and os.path.exists(metadata.preview_url):
|
||||||
|
cleanup_files.append(metadata.preview_url)
|
||||||
|
|
||||||
|
for path in cleanup_files:
|
||||||
if path and os.path.exists(path):
|
if path and os.path.exists(path):
|
||||||
os.remove(path)
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cleanup file {path}: {e}")
|
||||||
|
|
||||||
|
# Log but don't remove .part file to allow resume
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
logger.info(f"Preserving partial download for resume: {part_path}")
|
||||||
|
|
||||||
return {'success': False, 'error': result}
|
return {'success': False, 'error': result}
|
||||||
|
|
||||||
# 4. Update file information (size and modified time)
|
# 4. Update file information (size and modified time)
|
||||||
metadata.update_file_info(save_path)
|
metadata.update_file_info(save_path)
|
||||||
|
|
||||||
# 5. Final metadata update
|
# 5. Final metadata update
|
||||||
await MetadataManager.save_metadata(save_path, metadata, True)
|
await MetadataManager.save_metadata(save_path, metadata)
|
||||||
|
|
||||||
# 6. Update cache based on model type
|
# 6. Update cache based on model type
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
@@ -502,10 +546,18 @@ class DownloadManager:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in _execute_download: {e}", exc_info=True)
|
logger.error(f"Error in _execute_download: {e}", exc_info=True)
|
||||||
# Clean up partial downloads
|
# Clean up partial downloads except .part file
|
||||||
for path in [save_path, metadata_path]:
|
cleanup_files = [metadata_path]
|
||||||
|
if hasattr(metadata, 'preview_url') and metadata.preview_url and os.path.exists(metadata.preview_url):
|
||||||
|
cleanup_files.append(metadata.preview_url)
|
||||||
|
|
||||||
|
for path in cleanup_files:
|
||||||
if path and os.path.exists(path):
|
if path and os.path.exists(path):
|
||||||
os.remove(path)
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cleanup file {path}: {e}")
|
||||||
|
|
||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
async def _handle_download_progress(self, file_progress: float, progress_callback):
|
async def _handle_download_progress(self, file_progress: float, progress_callback):
|
||||||
@@ -547,35 +599,48 @@ class DownloadManager:
|
|||||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Clean up partial downloads
|
# Clean up ALL files including .part when user cancels
|
||||||
download_info = self._active_downloads.get(download_id)
|
download_info = self._active_downloads.get(download_id)
|
||||||
if download_info and 'file_path' in download_info:
|
if download_info:
|
||||||
# Delete the partial file
|
# Delete the main file
|
||||||
file_path = download_info['file_path']
|
if 'file_path' in download_info:
|
||||||
if os.path.exists(file_path):
|
file_path = download_info['file_path']
|
||||||
try:
|
if os.path.exists(file_path):
|
||||||
os.unlink(file_path)
|
try:
|
||||||
logger.debug(f"Deleted partial download: {file_path}")
|
os.unlink(file_path)
|
||||||
except Exception as e:
|
logger.debug(f"Deleted cancelled download: {file_path}")
|
||||||
logger.error(f"Error deleting partial file: {e}")
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting file: {e}")
|
||||||
|
|
||||||
|
# Delete the .part file (only on user cancellation)
|
||||||
|
if 'part_path' in download_info:
|
||||||
|
part_path = download_info['part_path']
|
||||||
|
if os.path.exists(part_path):
|
||||||
|
try:
|
||||||
|
os.unlink(part_path)
|
||||||
|
logger.debug(f"Deleted partial download: {part_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting part file: {e}")
|
||||||
|
|
||||||
# Delete metadata file if exists
|
# Delete metadata file if exists
|
||||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
if 'file_path' in download_info:
|
||||||
if os.path.exists(metadata_path):
|
file_path = download_info['file_path']
|
||||||
try:
|
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||||
os.unlink(metadata_path)
|
if os.path.exists(metadata_path):
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting metadata file: {e}")
|
|
||||||
|
|
||||||
# Delete preview file if exists (.webp or .mp4)
|
|
||||||
for preview_ext in ['.webp', '.mp4']:
|
|
||||||
preview_path = os.path.splitext(file_path)[0] + preview_ext
|
|
||||||
if os.path.exists(preview_path):
|
|
||||||
try:
|
try:
|
||||||
os.unlink(preview_path)
|
os.unlink(metadata_path)
|
||||||
logger.debug(f"Deleted preview file: {preview_path}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting preview file: {e}")
|
logger.error(f"Error deleting metadata file: {e}")
|
||||||
|
|
||||||
|
# Delete preview file if exists (.webp or .mp4)
|
||||||
|
for preview_ext in ['.webp', '.mp4']:
|
||||||
|
preview_path = os.path.splitext(file_path)[0] + preview_ext
|
||||||
|
if os.path.exists(preview_path):
|
||||||
|
try:
|
||||||
|
os.unlink(preview_path)
|
||||||
|
logger.debug(f"Deleted preview file: {preview_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting preview file: {e}")
|
||||||
|
|
||||||
return {'success': True, 'message': 'Download cancelled successfully'}
|
return {'success': True, 'message': 'Download cancelled successfully'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ class EmbeddingService(BaseModelService):
|
|||||||
"file_size": embedding_data.get("size", 0),
|
"file_size": embedding_data.get("size", 0),
|
||||||
"modified": embedding_data.get("modified", ""),
|
"modified": embedding_data.get("modified", ""),
|
||||||
"tags": embedding_data.get("tags", []),
|
"tags": embedding_data.get("tags", []),
|
||||||
"modelDescription": embedding_data.get("modelDescription", ""),
|
|
||||||
"from_civitai": embedding_data.get("from_civitai", True),
|
"from_civitai": embedding_data.get("from_civitai", True),
|
||||||
"notes": embedding_data.get("notes", ""),
|
"notes": embedding_data.get("notes", ""),
|
||||||
"model_type": embedding_data.get("model_type", "embedding"),
|
"model_type": embedding_data.get("model_type", "embedding"),
|
||||||
"favorite": embedding_data.get("favorite", False),
|
"favorite": embedding_data.get("favorite", False),
|
||||||
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}))
|
"civitai": ModelRouteUtils.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ class LoraService(BaseModelService):
|
|||||||
"file_size": lora_data.get("size", 0),
|
"file_size": lora_data.get("size", 0),
|
||||||
"modified": lora_data.get("modified", ""),
|
"modified": lora_data.get("modified", ""),
|
||||||
"tags": lora_data.get("tags", []),
|
"tags": lora_data.get("tags", []),
|
||||||
"modelDescription": lora_data.get("modelDescription", ""),
|
|
||||||
"from_civitai": lora_data.get("from_civitai", True),
|
"from_civitai": lora_data.get("from_civitai", True),
|
||||||
"usage_tips": lora_data.get("usage_tips", ""),
|
"usage_tips": lora_data.get("usage_tips", ""),
|
||||||
"notes": lora_data.get("notes", ""),
|
"notes": lora_data.get("notes", ""),
|
||||||
"favorite": lora_data.get("favorite", False),
|
"favorite": lora_data.get("favorite", False),
|
||||||
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}))
|
"civitai": ModelRouteUtils.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
@@ -167,6 +166,7 @@ class LoraService(BaseModelService):
|
|||||||
if file_path:
|
if file_path:
|
||||||
# Convert to forward slashes and extract relative path
|
# Convert to forward slashes and extract relative path
|
||||||
file_path_normalized = file_path.replace('\\', '/')
|
file_path_normalized = file_path.replace('\\', '/')
|
||||||
|
relative_path = relative_path.replace('\\', '/')
|
||||||
# Find the relative path part by looking for the relative_path in the full path
|
# Find the relative path part by looking for the relative_path in the full path
|
||||||
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
|
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
|
||||||
return lora.get('usage_tips', '')
|
return lora.get('usage_tips', '')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type, Set
|
|||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.file_utils import find_preview_file
|
from ..utils.file_utils import find_preview_file, get_preview_extension
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
from .model_hash_index import ModelHashIndex
|
from .model_hash_index import ModelHashIndex
|
||||||
@@ -303,11 +303,11 @@ class ModelScanner:
|
|||||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Log duplicate filename warnings after building the index
|
# Log duplicate filename warnings after building the index
|
||||||
duplicate_filenames = self._hash_index.get_duplicate_filenames()
|
# duplicate_filenames = self._hash_index.get_duplicate_filenames()
|
||||||
if duplicate_filenames:
|
# if duplicate_filenames:
|
||||||
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
|
# logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
|
||||||
for filename, paths in duplicate_filenames.items():
|
# for filename, paths in duplicate_filenames.items():
|
||||||
logger.warning(f" Duplicate filename '{filename}': {paths}")
|
# logger.warning(f" Duplicate filename '{filename}': {paths}")
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
self._cache.raw_data = raw_data
|
self._cache.raw_data = raw_data
|
||||||
@@ -375,11 +375,11 @@ class ModelScanner:
|
|||||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Log duplicate filename warnings after building the index
|
# Log duplicate filename warnings after building the index
|
||||||
duplicate_filenames = self._hash_index.get_duplicate_filenames()
|
# duplicate_filenames = self._hash_index.get_duplicate_filenames()
|
||||||
if duplicate_filenames:
|
# if duplicate_filenames:
|
||||||
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
|
# logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
|
||||||
for filename, paths in duplicate_filenames.items():
|
# for filename, paths in duplicate_filenames.items():
|
||||||
logger.warning(f" Duplicate filename '{filename}': {paths}")
|
# logger.warning(f" Duplicate filename '{filename}': {paths}")
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
self._cache = ModelCache(
|
self._cache = ModelCache(
|
||||||
@@ -585,6 +585,7 @@ class ModelScanner:
|
|||||||
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
||||||
file_path = entry.path.replace(os.sep, "/")
|
file_path = entry.path.replace(os.sep, "/")
|
||||||
result = await self._process_model_file(file_path, original_root)
|
result = await self._process_model_file(file_path, original_root)
|
||||||
|
# Only add to models if result is not None (skip corrupted metadata)
|
||||||
if result:
|
if result:
|
||||||
models.append(result)
|
models.append(result)
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
@@ -624,7 +625,12 @@ class ModelScanner:
|
|||||||
|
|
||||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||||
"""Process a single model file and return its metadata"""
|
"""Process a single model file and return its metadata"""
|
||||||
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||||
|
|
||||||
|
if should_skip:
|
||||||
|
# Metadata file exists but cannot be parsed - skip this model
|
||||||
|
logger.warning(f"Skipping model {file_path} due to corrupted metadata file")
|
||||||
|
return None
|
||||||
|
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||||
@@ -640,7 +646,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
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))
|
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||||
await MetadataManager.save_metadata(file_path, metadata, True)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||||
@@ -667,7 +673,7 @@ class ModelScanner:
|
|||||||
metadata.modelDescription = version_info['model']['description']
|
metadata.modelDescription = version_info['model']['description']
|
||||||
|
|
||||||
# Save the updated metadata
|
# Save the updated metadata
|
||||||
await MetadataManager.save_metadata(file_path, metadata, True)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
logger.debug(f"Updated metadata with civitai info for {file_path}")
|
logger.debug(f"Updated metadata with civitai info for {file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
|
||||||
@@ -747,7 +753,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
model_data['civitai']['creator'] = model_metadata['creator']
|
model_data['civitai']['creator'] = model_metadata['creator']
|
||||||
|
|
||||||
await MetadataManager.save_metadata(file_path, model_data, True)
|
await MetadataManager.save_metadata(file_path, model_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||||
|
|
||||||
@@ -786,8 +792,16 @@ class ModelScanner:
|
|||||||
logger.error(f"Error adding model to cache: {e}")
|
logger.error(f"Error adding model to cache: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def move_model(self, source_path: str, target_path: str) -> bool:
|
async def move_model(self, source_path: str, target_path: str) -> Optional[str]:
|
||||||
"""Move a model and its associated files to a new location"""
|
"""Move a model and its associated files to a new location
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Original file path
|
||||||
|
target_path: Target directory path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: New file path if successful, None if failed
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
source_path = source_path.replace(os.sep, '/')
|
source_path = source_path.replace(os.sep, '/')
|
||||||
target_path = target_path.replace(os.sep, '/')
|
target_path = target_path.replace(os.sep, '/')
|
||||||
@@ -796,14 +810,28 @@ class ModelScanner:
|
|||||||
|
|
||||||
if not file_ext or file_ext.lower() not in self.file_extensions:
|
if not file_ext or file_ext.lower() not in self.file_extensions:
|
||||||
logger.error(f"Invalid file extension for model: {file_ext}")
|
logger.error(f"Invalid file extension for model: {file_ext}")
|
||||||
return False
|
return None
|
||||||
|
|
||||||
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||||
source_dir = os.path.dirname(source_path)
|
source_dir = os.path.dirname(source_path)
|
||||||
|
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
target_file = os.path.join(target_path, f"{base_name}{file_ext}").replace(os.sep, '/')
|
def get_source_hash():
|
||||||
|
return self.get_hash_by_path(source_path)
|
||||||
|
|
||||||
|
# Check for filename conflicts and auto-rename if necessary
|
||||||
|
from ..utils.models import BaseModelMetadata
|
||||||
|
final_filename = BaseModelMetadata.generate_unique_filename(
|
||||||
|
target_path, base_name, file_ext, get_source_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
target_file = os.path.join(target_path, final_filename).replace(os.sep, '/')
|
||||||
|
final_base_name = os.path.splitext(final_filename)[0]
|
||||||
|
|
||||||
|
# Log if filename was changed due to conflict
|
||||||
|
if final_filename != f"{base_name}{file_ext}":
|
||||||
|
logger.info(f"Renamed {base_name}{file_ext} to {final_filename} to avoid filename conflict")
|
||||||
|
|
||||||
real_source = os.path.realpath(source_path)
|
real_source = os.path.realpath(source_path)
|
||||||
real_target = os.path.realpath(target_file)
|
real_target = os.path.realpath(target_file)
|
||||||
@@ -820,12 +848,17 @@ class ModelScanner:
|
|||||||
for file in os.listdir(source_dir):
|
for file in os.listdir(source_dir):
|
||||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||||
source_file_path = os.path.join(source_dir, file)
|
source_file_path = os.path.join(source_dir, file)
|
||||||
|
# Generate new filename with the same base name as the model file
|
||||||
|
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||||
|
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||||
|
target_associated_path = os.path.join(target_path, new_associated_filename)
|
||||||
|
|
||||||
# Store metadata file path for special handling
|
# Store metadata file path for special handling
|
||||||
if file == f"{base_name}.metadata.json":
|
if file == f"{base_name}.metadata.json":
|
||||||
source_metadata = source_file_path
|
source_metadata = source_file_path
|
||||||
moved_metadata_path = os.path.join(target_path, file)
|
moved_metadata_path = target_associated_path
|
||||||
else:
|
else:
|
||||||
files_to_move.append((source_file_path, os.path.join(target_path, file)))
|
files_to_move.append((source_file_path, target_associated_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing files in {source_dir}: {e}")
|
logger.error(f"Error listing files in {source_dir}: {e}")
|
||||||
|
|
||||||
@@ -847,11 +880,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self.update_single_model_cache(source_path, target_file, metadata)
|
await self.update_single_model_cache(source_path, target_file, metadata)
|
||||||
|
|
||||||
return True
|
return target_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
return False
|
return None
|
||||||
|
|
||||||
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
|
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
|
||||||
"""Update file paths in metadata file"""
|
"""Update file paths in metadata file"""
|
||||||
@@ -860,12 +893,15 @@ class ModelScanner:
|
|||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
|
|
||||||
metadata['file_path'] = model_path.replace(os.sep, '/')
|
metadata['file_path'] = model_path.replace(os.sep, '/')
|
||||||
|
# Update file_name to match the new filename
|
||||||
|
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
if 'preview_url' in metadata and metadata['preview_url']:
|
if 'preview_url' in metadata and metadata['preview_url']:
|
||||||
preview_dir = os.path.dirname(model_path)
|
preview_dir = os.path.dirname(model_path)
|
||||||
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
|
# Update preview filename to match the new base name
|
||||||
preview_ext = os.path.splitext(metadata['preview_url'])[1]
|
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
|
preview_ext = get_preview_extension(metadata['preview_url'])
|
||||||
|
new_preview_path = os.path.join(preview_dir, f"{new_base_name}{preview_ext}")
|
||||||
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
|
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
|
||||||
|
|
||||||
await MetadataManager.save_metadata(metadata_path, metadata)
|
await MetadataManager.save_metadata(metadata_path, metadata)
|
||||||
@@ -932,8 +968,16 @@ class ModelScanner:
|
|||||||
|
|
||||||
def get_hash_by_path(self, file_path: str) -> Optional[str]:
|
def get_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a model by its file path"""
|
"""Get hash for a model by its file path"""
|
||||||
return self._hash_index.get_hash(file_path)
|
if self._cache is None or not self._cache.raw_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Iterate through cache data to find matching file path
|
||||||
|
for model_data in self._cache.raw_data:
|
||||||
|
if model_data.get('file_path') == file_path:
|
||||||
|
return model_data.get('sha256')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||||
"""Get hash for a model by its filename without path"""
|
"""Get hash for a model by its filename without path"""
|
||||||
return self._hash_index.get_hash_by_filename(filename)
|
return self._hash_index.get_hash_by_filename(filename)
|
||||||
|
|||||||
114
py/services/server_i18n.py
Normal file
114
py/services/server_i18n.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ServerI18nManager:
|
||||||
|
"""Server-side internationalization manager for template rendering"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.translations = {}
|
||||||
|
self.current_locale = 'en'
|
||||||
|
self._load_translations()
|
||||||
|
|
||||||
|
def _load_translations(self):
|
||||||
|
"""Load all translation files from the locales directory"""
|
||||||
|
i18n_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
|
'locales'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(i18n_path):
|
||||||
|
logger.warning(f"I18n directory not found: {i18n_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load all available locale files
|
||||||
|
for filename in os.listdir(i18n_path):
|
||||||
|
if filename.endswith('.json'):
|
||||||
|
locale_code = filename[:-5] # Remove .json extension
|
||||||
|
try:
|
||||||
|
self._load_locale_file(i18n_path, filename, locale_code)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading locale file {filename}: {e}")
|
||||||
|
|
||||||
|
def _load_locale_file(self, path: str, filename: str, locale_code: str):
|
||||||
|
"""Load a single locale JSON file"""
|
||||||
|
file_path = os.path.join(path, filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
translations = json.load(f)
|
||||||
|
|
||||||
|
self.translations[locale_code] = translations
|
||||||
|
logger.debug(f"Loaded translations for {locale_code} from {filename}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing locale file {filename}: {e}")
|
||||||
|
|
||||||
|
def set_locale(self, locale: str):
|
||||||
|
"""Set the current locale"""
|
||||||
|
if locale in self.translations:
|
||||||
|
self.current_locale = locale
|
||||||
|
else:
|
||||||
|
logger.warning(f"Locale {locale} not found, using 'en'")
|
||||||
|
self.current_locale = 'en'
|
||||||
|
|
||||||
|
def get_translation(self, key: str, params: Dict[str, Any] = None, **kwargs) -> str:
|
||||||
|
"""Get translation for a key with optional parameters (supports both dict and keyword args)"""
|
||||||
|
# Merge kwargs into params for convenience
|
||||||
|
if params is None:
|
||||||
|
params = {}
|
||||||
|
if kwargs:
|
||||||
|
params = {**params, **kwargs}
|
||||||
|
|
||||||
|
if self.current_locale not in self.translations:
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Navigate through nested object using dot notation
|
||||||
|
keys = key.split('.')
|
||||||
|
value = self.translations[self.current_locale]
|
||||||
|
|
||||||
|
for k in keys:
|
||||||
|
if isinstance(value, dict) and k in value:
|
||||||
|
value = value[k]
|
||||||
|
else:
|
||||||
|
# Fallback to English if current locale doesn't have the key
|
||||||
|
if self.current_locale != 'en' and 'en' in self.translations:
|
||||||
|
en_value = self.translations['en']
|
||||||
|
for k in keys:
|
||||||
|
if isinstance(en_value, dict) and k in en_value:
|
||||||
|
en_value = en_value[k]
|
||||||
|
else:
|
||||||
|
return key
|
||||||
|
value = en_value
|
||||||
|
else:
|
||||||
|
return key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Replace parameters if provided
|
||||||
|
if params:
|
||||||
|
for param_key, param_value in params.items():
|
||||||
|
placeholder = f"{{{param_key}}}"
|
||||||
|
double_placeholder = f"{{{{{param_key}}}}}"
|
||||||
|
value = value.replace(placeholder, str(param_value))
|
||||||
|
value = value.replace(double_placeholder, str(param_value))
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_available_locales(self) -> list:
|
||||||
|
"""Get list of available locales"""
|
||||||
|
return list(self.translations.keys())
|
||||||
|
|
||||||
|
def create_template_filter(self):
|
||||||
|
"""Create a Jinja2 filter function for templates"""
|
||||||
|
def t_filter(key: str, **params) -> str:
|
||||||
|
return self.get_translation(key, params)
|
||||||
|
return t_filter
|
||||||
|
|
||||||
|
# Create global instance
|
||||||
|
server_i18n = ServerI18nManager()
|
||||||
@@ -80,7 +80,8 @@ class SettingsManager:
|
|||||||
"""Return default settings"""
|
"""Return default settings"""
|
||||||
return {
|
return {
|
||||||
"civitai_api_key": "",
|
"civitai_api_key": "",
|
||||||
"show_only_sfw": False
|
"show_only_sfw": False,
|
||||||
|
"language": "en" # 添加默认语言设置
|
||||||
}
|
}
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
@@ -110,6 +111,43 @@ class SettingsManager:
|
|||||||
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
||||||
"""
|
"""
|
||||||
templates = self.settings.get('download_path_templates', {})
|
templates = self.settings.get('download_path_templates', {})
|
||||||
|
|
||||||
|
# Handle edge case where templates might be stored as JSON string
|
||||||
|
if isinstance(templates, str):
|
||||||
|
try:
|
||||||
|
# Try to parse JSON string
|
||||||
|
parsed_templates = json.loads(templates)
|
||||||
|
if isinstance(parsed_templates, dict):
|
||||||
|
# Update settings with parsed dictionary
|
||||||
|
self.settings['download_path_templates'] = parsed_templates
|
||||||
|
self._save_settings()
|
||||||
|
templates = parsed_templates
|
||||||
|
logger.info("Successfully parsed download_path_templates from JSON string")
|
||||||
|
else:
|
||||||
|
raise ValueError("Parsed JSON is not a dictionary")
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
# If parsing fails, set default values
|
||||||
|
logger.warning(f"Failed to parse download_path_templates JSON string: {e}. Setting default values.")
|
||||||
|
default_template = '{base_model}/{first_tag}'
|
||||||
|
templates = {
|
||||||
|
'lora': default_template,
|
||||||
|
'checkpoint': default_template,
|
||||||
|
'embedding': default_template
|
||||||
|
}
|
||||||
|
self.settings['download_path_templates'] = templates
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
# Ensure templates is a dictionary
|
||||||
|
if not isinstance(templates, dict):
|
||||||
|
default_template = '{base_model}/{first_tag}'
|
||||||
|
templates = {
|
||||||
|
'lora': default_template,
|
||||||
|
'checkpoint': default_template,
|
||||||
|
'embedding': default_template
|
||||||
|
}
|
||||||
|
self.settings['download_path_templates'] = templates
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
return templates.get(model_type, '{base_model}/{first_tag}')
|
return templates.get(model_type, '{base_model}/{first_tag}')
|
||||||
|
|
||||||
settings = SettingsManager()
|
settings = SettingsManager()
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming
|
|||||||
# Civitai model tags in priority order for subfolder organization
|
# Civitai model tags in priority order for subfolder organization
|
||||||
CIVITAI_MODEL_TAGS = [
|
CIVITAI_MODEL_TAGS = [
|
||||||
'character', 'style', 'concept', 'clothing',
|
'character', 'style', 'concept', 'clothing',
|
||||||
# 'base model', # exclude 'base model'
|
'realistic', 'anime', 'toon', 'furry',
|
||||||
'poses', 'background', 'tool', 'vehicle', 'buildings',
|
'poses', 'background', 'tool', 'vehicle', 'buildings',
|
||||||
'objects', 'assets', 'animal', 'action'
|
'objects', 'assets', 'animal', 'action'
|
||||||
]
|
]
|
||||||
@@ -68,6 +68,7 @@ class DownloadManager:
|
|||||||
optimize = data.get('optimize', True)
|
optimize = data.get('optimize', True)
|
||||||
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
||||||
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
|
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
|
||||||
|
delay = 0 # Temporary: Disable delay to speed up downloads
|
||||||
|
|
||||||
if not output_dir:
|
if not output_dir:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|||||||
@@ -27,39 +27,58 @@ def find_preview_file(base_name: str, dir_path: str) -> str:
|
|||||||
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
|
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
|
||||||
if os.path.exists(full_pattern):
|
if os.path.exists(full_pattern):
|
||||||
# Check if this is an image and not already webp
|
# Check if this is an image and not already webp
|
||||||
if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
|
# TODO: disable the optimization for now, maybe add a config option later
|
||||||
try:
|
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
|
||||||
# Optimize the image to webp format
|
# try:
|
||||||
webp_path = os.path.join(dir_path, f"{base_name}.webp")
|
# # Optimize the image to webp format
|
||||||
|
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
|
||||||
|
|
||||||
# Use ExifUtils to optimize the image
|
# # Use ExifUtils to optimize the image
|
||||||
with open(full_pattern, 'rb') as f:
|
# with open(full_pattern, 'rb') as f:
|
||||||
image_data = f.read()
|
# image_data = f.read()
|
||||||
|
|
||||||
optimized_data, _ = ExifUtils.optimize_image(
|
# optimized_data, _ = ExifUtils.optimize_image(
|
||||||
image_data=image_data,
|
# image_data=image_data,
|
||||||
target_width=CARD_PREVIEW_WIDTH,
|
# target_width=CARD_PREVIEW_WIDTH,
|
||||||
format='webp',
|
# format='webp',
|
||||||
quality=85,
|
# quality=85,
|
||||||
preserve_metadata=False
|
# preserve_metadata=False
|
||||||
)
|
# )
|
||||||
|
|
||||||
# Save the optimized webp file
|
# # Save the optimized webp file
|
||||||
with open(webp_path, 'wb') as f:
|
# with open(webp_path, 'wb') as f:
|
||||||
f.write(optimized_data)
|
# f.write(optimized_data)
|
||||||
|
|
||||||
logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
|
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
|
||||||
return webp_path.replace(os.sep, "/")
|
# return webp_path.replace(os.sep, "/")
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
logger.error(f"Error optimizing preview image {full_pattern}: {e}")
|
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
|
||||||
# Fall back to original file if optimization fails
|
# # Fall back to original file if optimization fails
|
||||||
return full_pattern.replace(os.sep, "/")
|
# return full_pattern.replace(os.sep, "/")
|
||||||
|
|
||||||
# Return the original path for webp images or non-image files
|
# Return the original path for webp images or non-image files
|
||||||
return full_pattern.replace(os.sep, "/")
|
return full_pattern.replace(os.sep, "/")
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def get_preview_extension(preview_path: str) -> str:
|
||||||
|
"""Get the complete preview extension from a preview file path
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preview_path: Path to the preview file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The complete extension (e.g., '.preview.png', '.png', '.webp')
|
||||||
|
"""
|
||||||
|
preview_path_lower = preview_path.lower()
|
||||||
|
|
||||||
|
# Check for compound extensions first (longer matches first)
|
||||||
|
for ext in sorted(PREVIEW_EXTENSIONS, key=len, reverse=True):
|
||||||
|
if preview_path_lower.endswith(ext.lower()):
|
||||||
|
return ext
|
||||||
|
|
||||||
|
return os.path.splitext(preview_path)[1]
|
||||||
|
|
||||||
def normalize_path(path: str) -> str:
|
def normalize_path(path: str) -> str:
|
||||||
"""Normalize file path to use forward slashes"""
|
"""Normalize file path to use forward slashes"""
|
||||||
return path.replace(os.sep, "/") if path else path
|
return path.replace(os.sep, "/") if path else path
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shutil
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional, Type, Union
|
from typing import Dict, Optional, Type, Union
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ class MetadataManager:
|
|||||||
|
|
||||||
This class is responsible for:
|
This class is responsible for:
|
||||||
1. Loading metadata safely with fallback mechanisms
|
1. Loading metadata safely with fallback mechanisms
|
||||||
2. Saving metadata with atomic operations and backups
|
2. Saving metadata with atomic operations
|
||||||
3. Creating default metadata for models
|
3. Creating default metadata for models
|
||||||
4. Handling unknown fields gracefully
|
4. Handling unknown fields gracefully
|
||||||
"""
|
"""
|
||||||
@@ -25,81 +24,44 @@ class MetadataManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
|
async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = LoraMetadata) -> Optional[BaseModelMetadata]:
|
||||||
"""
|
"""
|
||||||
Load metadata with robust error handling and data preservation.
|
Load metadata safely.
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to the model file
|
|
||||||
model_class: Class to instantiate (LoraMetadata, CheckpointMetadata, etc.)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BaseModelMetadata instance or None if file doesn't exist
|
tuple: (metadata, should_skip)
|
||||||
|
- metadata: BaseModelMetadata instance or None
|
||||||
|
- should_skip: True if corrupted metadata file exists and model should be skipped
|
||||||
"""
|
"""
|
||||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||||
backup_path = f"{metadata_path}.bak"
|
|
||||||
|
|
||||||
# Try loading the main metadata file
|
# Check if metadata file exists
|
||||||
if os.path.exists(metadata_path):
|
if not os.path.exists(metadata_path):
|
||||||
try:
|
return None, False
|
||||||
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
|
try:
|
||||||
|
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||||
@staticmethod
|
data = json.load(f)
|
||||||
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:
|
# Create model instance
|
||||||
BaseModelMetadata instance or None if restoration fails
|
metadata = model_class.from_dict(data)
|
||||||
"""
|
|
||||||
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
|
# Normalize paths
|
||||||
metadata = model_class.from_dict(data)
|
await MetadataManager._normalize_metadata_paths(metadata, file_path)
|
||||||
await MetadataManager._normalize_metadata_paths(metadata, file_path)
|
|
||||||
return metadata
|
return metadata, False
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to restore from backup: {str(e)}")
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
|
error_type = "Invalid JSON" if isinstance(e, json.JSONDecodeError) else "Parse error"
|
||||||
return None
|
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
|
||||||
|
return None, True # should_skip = True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict], create_backup: bool = False) -> bool:
|
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict]) -> bool:
|
||||||
"""
|
"""
|
||||||
Save metadata with atomic write operations and backup creation.
|
Save metadata with atomic write operations.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to the model file or directly to the metadata file
|
path: Path to the model file or directly to the metadata file
|
||||||
metadata: Metadata to save (either BaseModelMetadata object or dict)
|
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:
|
Returns:
|
||||||
bool: Success or failure
|
bool: Success or failure
|
||||||
@@ -112,19 +74,8 @@ class MetadataManager:
|
|||||||
file_path = path
|
file_path = path
|
||||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||||
temp_path = f"{metadata_path}.tmp"
|
temp_path = f"{metadata_path}.tmp"
|
||||||
backup_path = f"{metadata_path}.bak"
|
|
||||||
|
|
||||||
try:
|
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
|
# Convert to dict if needed
|
||||||
if isinstance(metadata, BaseModelMetadata):
|
if isinstance(metadata, BaseModelMetadata):
|
||||||
metadata_dict = metadata.to_dict()
|
metadata_dict = metadata.to_dict()
|
||||||
@@ -240,7 +191,7 @@ class MetadataManager:
|
|||||||
# await MetadataManager._enrich_metadata(metadata, real_path)
|
# await MetadataManager._enrich_metadata(metadata, real_path)
|
||||||
|
|
||||||
# Save the created metadata
|
# Save the created metadata
|
||||||
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
@@ -310,4 +261,4 @@ class MetadataManager:
|
|||||||
|
|
||||||
# If path attributes were changed, save the metadata back to disk
|
# If path attributes were changed, save the metadata back to disk
|
||||||
if need_update:
|
if need_update:
|
||||||
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|||||||
@@ -83,6 +83,50 @@ class BaseModelMetadata:
|
|||||||
self.size = os.path.getsize(file_path)
|
self.size = os.path.getsize(file_path)
|
||||||
self.modified = os.path.getmtime(file_path)
|
self.modified = os.path.getmtime(file_path)
|
||||||
self.file_path = file_path.replace(os.sep, '/')
|
self.file_path = file_path.replace(os.sep, '/')
|
||||||
|
# Update file_name when file_path changes
|
||||||
|
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str:
|
||||||
|
"""Generate a unique filename to avoid conflicts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_dir: Target directory path
|
||||||
|
base_name: Base filename without extension
|
||||||
|
extension: File extension including the dot
|
||||||
|
hash_provider: A callable that returns the SHA256 hash when needed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Unique filename that doesn't conflict with existing files
|
||||||
|
"""
|
||||||
|
original_filename = f"{base_name}{extension}"
|
||||||
|
target_path = os.path.join(target_dir, original_filename)
|
||||||
|
|
||||||
|
# If no conflict, return original filename
|
||||||
|
if not os.path.exists(target_path):
|
||||||
|
return original_filename
|
||||||
|
|
||||||
|
# Only compute hash when needed
|
||||||
|
if hash_provider:
|
||||||
|
sha256_hash = hash_provider()
|
||||||
|
else:
|
||||||
|
sha256_hash = "0000"
|
||||||
|
|
||||||
|
# Generate short hash (first 4 characters of SHA256)
|
||||||
|
short_hash = sha256_hash[:4] if sha256_hash else "0000"
|
||||||
|
|
||||||
|
# Try with short hash suffix
|
||||||
|
unique_filename = f"{base_name}-{short_hash}{extension}"
|
||||||
|
unique_path = os.path.join(target_dir, unique_filename)
|
||||||
|
|
||||||
|
# If still conflicts, add incremental number
|
||||||
|
counter = 1
|
||||||
|
while os.path.exists(unique_path):
|
||||||
|
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
|
||||||
|
unique_path = os.path.join(target_dir, unique_filename)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
return unique_filename
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LoraMetadata(BaseModelMetadata):
|
class LoraMetadata(BaseModelMetadata):
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class ModelRouteUtils:
|
|||||||
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
|
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
|
||||||
|
|
||||||
# Save updated metadata
|
# Save updated metadata
|
||||||
await MetadataManager.save_metadata(metadata_path, local_metadata, True)
|
await MetadataManager.save_metadata(metadata_path, local_metadata)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def fetch_and_update_model(
|
async def fetch_and_update_model(
|
||||||
@@ -229,13 +229,13 @@ class ModelRouteUtils:
|
|||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filter_civitai_data(data: Dict) -> Dict:
|
def filter_civitai_data(data: Dict, minimal: bool = False) -> Dict:
|
||||||
"""Filter relevant fields from CivitAI data"""
|
"""Filter relevant fields from CivitAI data"""
|
||||||
if not data:
|
if not data:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
fields = [
|
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
|
||||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||||
"publishedAt", "trainedWords", "baseModel", "description",
|
"publishedAt", "trainedWords", "baseModel", "description",
|
||||||
"model", "images", "customImages", "creator"
|
"model", "images", "customImages", "creator"
|
||||||
]
|
]
|
||||||
@@ -628,15 +628,6 @@ class ModelRouteUtils:
|
|||||||
if not result.get('success', False):
|
if not result.get('success', False):
|
||||||
error_message = result.get('error', 'Unknown error')
|
error_message = result.get('error', 'Unknown error')
|
||||||
|
|
||||||
# Return 401 for early access errors
|
|
||||||
if 'early access' in error_message.lower():
|
|
||||||
logger.warning(f"Early access download failed: {error_message}")
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"Early Access Restriction: {error_message}",
|
|
||||||
'download_id': download_id
|
|
||||||
}, status=401)
|
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': error_message,
|
'error': error_message,
|
||||||
@@ -879,11 +870,11 @@ class ModelRouteUtils:
|
|||||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||||
|
|
||||||
# Compare hashes
|
# Compare hashes
|
||||||
stored_hash = metadata.get('sha256', '').lower()
|
stored_hash = metadata.get('sha256', '').lower();
|
||||||
|
|
||||||
# Set expected hash from first file if not yet set
|
# Set expected hash from first file if not yet set
|
||||||
if not expected_hash:
|
if not expected_hash:
|
||||||
expected_hash = stored_hash
|
expected_hash = stored_hash;
|
||||||
|
|
||||||
# Check if hash matches expected hash
|
# Check if hash matches expected hash
|
||||||
if actual_hash != expected_hash:
|
if actual_hash != expected_hash:
|
||||||
@@ -987,10 +978,11 @@ class ModelRouteUtils:
|
|||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||||
hash_value = metadata.get('sha256')
|
hash_value = metadata.get('sha256')
|
||||||
|
logger.info(f"hash_value: {hash_value}, metadata_path: {metadata_path}, metadata: {metadata}")
|
||||||
# Rename all files
|
# Rename all files
|
||||||
renamed_files = []
|
renamed_files = []
|
||||||
new_metadata_path = None
|
new_metadata_path = None
|
||||||
|
new_preview = None
|
||||||
|
|
||||||
for old_path, pattern in existing_files:
|
for old_path, pattern in existing_files:
|
||||||
# Get the file extension like .safetensors or .metadata.json
|
# Get the file extension like .safetensors or .metadata.json
|
||||||
@@ -1101,3 +1093,63 @@ class ModelRouteUtils:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving metadata: {e}", exc_info=True)
|
logger.error(f"Error saving metadata: {e}", exc_info=True)
|
||||||
return web.Response(text=str(e), status=500)
|
return web.Response(text=str(e), status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_add_tags(request: web.Request, scanner) -> web.Response:
|
||||||
|
"""Handle adding tags to model metadata
|
||||||
|
|
||||||
|
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_tags = data.get('tags', [])
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.Response(text='File path is required', status=400)
|
||||||
|
|
||||||
|
if not isinstance(new_tags, list):
|
||||||
|
return web.Response(text='Tags must be a list', status=400)
|
||||||
|
|
||||||
|
# Get metadata file path
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||||
|
|
||||||
|
# Load existing metadata
|
||||||
|
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||||
|
|
||||||
|
# Get existing tags (case insensitive)
|
||||||
|
existing_tags = metadata.get('tags', [])
|
||||||
|
existing_tags_lower = [tag.lower() for tag in existing_tags]
|
||||||
|
|
||||||
|
# Add new tags that don't already exist (case insensitive check)
|
||||||
|
tags_added = []
|
||||||
|
for tag in new_tags:
|
||||||
|
if isinstance(tag, str) and tag.strip():
|
||||||
|
tag_stripped = tag.strip()
|
||||||
|
if tag_stripped.lower() not in existing_tags_lower:
|
||||||
|
existing_tags.append(tag_stripped)
|
||||||
|
existing_tags_lower.append(tag_stripped.lower())
|
||||||
|
tags_added.append(tag_stripped)
|
||||||
|
|
||||||
|
# Update metadata with combined tags
|
||||||
|
metadata['tags'] = existing_tags
|
||||||
|
|
||||||
|
# Save updated metadata
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
await scanner.update_single_model_cache(file_path, file_path, metadata)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'tags': existing_tags
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding tags: {e}", exc_info=True)
|
||||||
|
return web.Response(text=str(e), status=500)
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
|
|||||||
if civitai_data and civitai_data.get('id') is not None:
|
if civitai_data and civitai_data.get('id') is not None:
|
||||||
base_model = civitai_data.get('baseModel', '')
|
base_model = civitai_data.get('baseModel', '')
|
||||||
# Get author from civitai creator data
|
# Get author from civitai creator data
|
||||||
author = civitai_data.get('creator', {}).get('username') or 'Anonymous'
|
creator_info = civitai_data.get('creator') or {}
|
||||||
|
author = creator_info.get('username') or 'Anonymous'
|
||||||
else:
|
else:
|
||||||
# Fallback to model_data fields for non-CivitAI models
|
# Fallback to model_data fields for non-CivitAI models
|
||||||
base_model = model_data.get('base_model', '')
|
base_model = model_data.get('base_model', '')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "0.8.28"
|
version = "0.9.2"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
305
scripts/sync_translation_keys.py
Normal file
305
scripts/sync_translation_keys.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Translation Key Synchronization Script
|
||||||
|
|
||||||
|
This script synchronizes new translation keys from en.json to all other locale files
|
||||||
|
while maintaining exact formatting consistency to pass test_i18n.py validation.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Preserves exact line-by-line formatting
|
||||||
|
- Maintains proper indentation and structure
|
||||||
|
- Adds missing keys with placeholder translations
|
||||||
|
- Handles nested objects correctly
|
||||||
|
- Ensures all locale files have identical structure
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/sync_translation_keys.py [--dry-run] [--verbose]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import argparse
|
||||||
|
from typing import Dict, List, Set, Tuple, Any, Optional
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
# Add the parent directory to the path so we can import modules if needed
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
class TranslationKeySynchronizer:
|
||||||
|
"""Synchronizes translation keys across locale files while maintaining formatting."""
|
||||||
|
|
||||||
|
def __init__(self, locales_dir: str, verbose: bool = False):
|
||||||
|
self.locales_dir = locales_dir
|
||||||
|
self.verbose = verbose
|
||||||
|
self.reference_locale = 'en'
|
||||||
|
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
|
||||||
|
|
||||||
|
def log(self, message: str, level: str = 'INFO'):
|
||||||
|
"""Log a message if verbose mode is enabled."""
|
||||||
|
if self.verbose or level == 'ERROR':
|
||||||
|
print(f"[{level}] {message}")
|
||||||
|
|
||||||
|
def load_json_preserve_order(self, file_path: str) -> Tuple[Dict[str, Any], List[str]]:
|
||||||
|
"""
|
||||||
|
Load a JSON file preserving the exact order and formatting.
|
||||||
|
Returns both the parsed data and the original lines.
|
||||||
|
"""
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
content = ''.join(lines)
|
||||||
|
|
||||||
|
# Parse JSON while preserving order
|
||||||
|
data = json.loads(content, object_pairs_hook=OrderedDict)
|
||||||
|
return data, lines
|
||||||
|
|
||||||
|
def get_all_leaf_keys(self, data: Any, prefix: str = '') -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract all leaf keys (non-object values) with their full paths.
|
||||||
|
Returns a dictionary mapping full key paths to their values.
|
||||||
|
"""
|
||||||
|
keys = {}
|
||||||
|
|
||||||
|
if isinstance(data, (dict, OrderedDict)):
|
||||||
|
for key, value in data.items():
|
||||||
|
full_key = f"{prefix}.{key}" if prefix else key
|
||||||
|
|
||||||
|
if isinstance(value, (dict, OrderedDict)):
|
||||||
|
# Recursively get nested keys
|
||||||
|
keys.update(self.get_all_leaf_keys(value, full_key))
|
||||||
|
else:
|
||||||
|
# Leaf node - actual translatable value
|
||||||
|
keys[full_key] = value
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def merge_json_structures(self, reference_data: Dict[str, Any], target_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Merge the reference JSON structure with existing target translations.
|
||||||
|
This creates a new structure that matches the reference exactly but preserves
|
||||||
|
existing translations where available. Keys not in reference are removed.
|
||||||
|
"""
|
||||||
|
def merge_recursive(ref_obj, target_obj):
|
||||||
|
if isinstance(ref_obj, (dict, OrderedDict)):
|
||||||
|
result = OrderedDict()
|
||||||
|
# Only include keys that exist in the reference
|
||||||
|
for key, ref_value in ref_obj.items():
|
||||||
|
if key in target_obj and isinstance(target_obj[key], type(ref_value)):
|
||||||
|
# Key exists in target with same type
|
||||||
|
if isinstance(ref_value, (dict, OrderedDict)):
|
||||||
|
# Recursively merge nested objects
|
||||||
|
result[key] = merge_recursive(ref_value, target_obj[key])
|
||||||
|
else:
|
||||||
|
# Use existing translation
|
||||||
|
result[key] = target_obj[key]
|
||||||
|
else:
|
||||||
|
# Key missing in target or type mismatch
|
||||||
|
if isinstance(ref_value, (dict, OrderedDict)):
|
||||||
|
# Recursively handle nested objects
|
||||||
|
result[key] = merge_recursive(ref_value, {})
|
||||||
|
else:
|
||||||
|
# Create placeholder translation
|
||||||
|
result[key] = f"[TODO: Translate] {ref_value}"
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# For non-dict values, use reference (this shouldn't happen at root level)
|
||||||
|
return ref_obj
|
||||||
|
|
||||||
|
return merge_recursive(reference_data, target_data)
|
||||||
|
|
||||||
|
def format_json_like_reference(self, data: Dict[str, Any], reference_lines: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Format the merged JSON data to match the reference file's formatting exactly.
|
||||||
|
"""
|
||||||
|
# Use json.dumps with proper formatting to match the reference style
|
||||||
|
formatted_json = json.dumps(data, indent=4, ensure_ascii=False, separators=(',', ': '))
|
||||||
|
|
||||||
|
# Split into lines and ensure consistent line endings
|
||||||
|
formatted_lines = [line + '\n' for line in formatted_json.split('\n')]
|
||||||
|
|
||||||
|
# Make sure the last line doesn't have extra newlines
|
||||||
|
if formatted_lines and formatted_lines[-1].strip() == '':
|
||||||
|
formatted_lines = formatted_lines[:-1]
|
||||||
|
|
||||||
|
# Ensure the last line ends with just a newline
|
||||||
|
if formatted_lines and not formatted_lines[-1].endswith('\n'):
|
||||||
|
formatted_lines[-1] += '\n'
|
||||||
|
|
||||||
|
return formatted_lines
|
||||||
|
|
||||||
|
def synchronize_locale_simple(self, locale: str, reference_data: Dict[str, Any],
|
||||||
|
reference_lines: List[str], dry_run: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Synchronize a locale file using JSON structure merging.
|
||||||
|
Handles both addition of missing keys and removal of obsolete keys.
|
||||||
|
"""
|
||||||
|
locale_file = os.path.join(self.locales_dir, f'{locale}.json')
|
||||||
|
|
||||||
|
if not os.path.exists(locale_file):
|
||||||
|
self.log(f"Locale file {locale_file} does not exist!", 'ERROR')
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_data, _ = self.load_json_preserve_order(locale_file)
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error loading {locale_file}: {e}", 'ERROR')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get keys to check for differences
|
||||||
|
ref_keys = self.get_all_leaf_keys(reference_data)
|
||||||
|
target_keys = self.get_all_leaf_keys(target_data)
|
||||||
|
missing_keys = set(ref_keys.keys()) - set(target_keys.keys())
|
||||||
|
obsolete_keys = set(target_keys.keys()) - set(ref_keys.keys())
|
||||||
|
|
||||||
|
if not missing_keys and not obsolete_keys:
|
||||||
|
self.log(f"Locale {locale} is already up to date")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Report changes
|
||||||
|
if missing_keys:
|
||||||
|
self.log(f"Found {len(missing_keys)} missing keys in {locale}:")
|
||||||
|
for key in sorted(missing_keys):
|
||||||
|
self.log(f" + {key}")
|
||||||
|
|
||||||
|
if obsolete_keys:
|
||||||
|
self.log(f"Found {len(obsolete_keys)} obsolete keys in {locale}:")
|
||||||
|
for key in sorted(obsolete_keys):
|
||||||
|
self.log(f" - {key}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
total_changes = len(missing_keys) + len(obsolete_keys)
|
||||||
|
self.log(f"DRY RUN: Would update {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Merge the structures (this will both add missing keys and remove obsolete ones)
|
||||||
|
try:
|
||||||
|
merged_data = self.merge_json_structures(reference_data, target_data)
|
||||||
|
|
||||||
|
# Format to match reference style
|
||||||
|
new_lines = self.format_json_like_reference(merged_data, reference_lines)
|
||||||
|
|
||||||
|
# Validate that the result is valid JSON
|
||||||
|
reconstructed_content = ''.join(new_lines)
|
||||||
|
json.loads(reconstructed_content) # This will raise an exception if invalid
|
||||||
|
|
||||||
|
# Write the updated file
|
||||||
|
with open(locale_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
|
||||||
|
total_changes = len(missing_keys) + len(obsolete_keys)
|
||||||
|
self.log(f"Successfully updated {locale} with {len(missing_keys)} additions and {len(obsolete_keys)} deletions ({total_changes} total changes)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
self.log(f"Generated invalid JSON for {locale}: {e}", 'ERROR')
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error updating {locale_file}: {e}", 'ERROR')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def synchronize_all(self, dry_run: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Synchronize all locale files with the reference.
|
||||||
|
Returns True if all operations were successful.
|
||||||
|
"""
|
||||||
|
# Load reference file
|
||||||
|
reference_file = os.path.join(self.locales_dir, f'{self.reference_locale}.json')
|
||||||
|
|
||||||
|
if not os.path.exists(reference_file):
|
||||||
|
self.log(f"Reference file {reference_file} does not exist!", 'ERROR')
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
reference_data, reference_lines = self.load_json_preserve_order(reference_file)
|
||||||
|
reference_keys = self.get_all_leaf_keys(reference_data)
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error loading reference file: {e}", 'ERROR')
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.log(f"Loaded reference file with {len(reference_keys)} keys")
|
||||||
|
|
||||||
|
success = True
|
||||||
|
changes_made = False
|
||||||
|
|
||||||
|
# Synchronize each target locale
|
||||||
|
for locale in self.target_locales:
|
||||||
|
try:
|
||||||
|
if self.synchronize_locale_simple(locale, reference_data, reference_lines, dry_run):
|
||||||
|
changes_made = True
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error synchronizing {locale}: {e}", 'ERROR')
|
||||||
|
success = False
|
||||||
|
|
||||||
|
if changes_made:
|
||||||
|
self.log("Synchronization completed with changes")
|
||||||
|
else:
|
||||||
|
self.log("All locale files are already up to date")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the script."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Synchronize translation keys from en.json to all other locale files'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Show what would be changed without making actual changes'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable verbose output'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--locales-dir',
|
||||||
|
default=None,
|
||||||
|
help='Path to locales directory (default: auto-detect from script location)'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Determine locales directory
|
||||||
|
if args.locales_dir:
|
||||||
|
locales_dir = args.locales_dir
|
||||||
|
else:
|
||||||
|
# Auto-detect based on script location
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
locales_dir = os.path.join(os.path.dirname(script_dir), 'locales')
|
||||||
|
|
||||||
|
if not os.path.exists(locales_dir):
|
||||||
|
print(f"ERROR: Locales directory not found: {locales_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Translation Key Synchronization")
|
||||||
|
print(f"Locales directory: {locales_dir}")
|
||||||
|
print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE UPDATE'}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Create synchronizer and run
|
||||||
|
synchronizer = TranslationKeySynchronizer(locales_dir, args.verbose)
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = synchronizer.synchronize_all(args.dry_run)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n✅ Synchronization completed successfully!")
|
||||||
|
if not args.dry_run:
|
||||||
|
print("💡 Run 'python test_i18n.py' to verify formatting consistency")
|
||||||
|
else:
|
||||||
|
print("\n❌ Synchronization completed with errors!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n⚠️ Operation cancelled by user")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Unexpected error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -339,6 +339,11 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Add static route for locales JSON files
|
||||||
|
if os.path.exists(config.i18n_path):
|
||||||
|
app.router.add_static('/locales', config.i18n_path)
|
||||||
|
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
|
||||||
|
|
||||||
# Add static route for plugin assets
|
# Add static route for plugin assets
|
||||||
app.router.add_static('/loras_static', config.static_path)
|
app.router.add_static('/loras_static', config.static_path)
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ html, body {
|
|||||||
|
|
||||||
/* Composed Colors */
|
/* Composed Colors */
|
||||||
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
--lora-surface: oklch(100% 0 0 / 0.98);
|
--lora-surface: oklch(97% 0 0 / 0.95);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(95% 0.02 256);
|
--lora-text: oklch(95% 0.02 256);
|
||||||
--lora-error: oklch(75% 0.32 29);
|
--lora-error: oklch(75% 0.32 29);
|
||||||
|
|||||||
@@ -1,81 +1,3 @@
|
|||||||
/* Bulk Operations Styles */
|
|
||||||
.bulk-operations-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateY(100px) translateX(-50%);
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
padding: 12px 16px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: var(--z-overlay);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 420px;
|
|
||||||
max-width: 900px;
|
|
||||||
width: auto;
|
|
||||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
gap: 20px; /* Increase space between count and buttons */
|
|
||||||
}
|
|
||||||
|
|
||||||
#selectedCount {
|
|
||||||
font-weight: 500;
|
|
||||||
background: var(--bg-color);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
min-width: 80px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-actions button {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
background: var(--bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-actions button:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Danger button style - updated to use proper theme variables */
|
|
||||||
.bulk-operations-actions button.danger-btn {
|
|
||||||
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
|
|
||||||
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
|
|
||||||
border-color: var(--lora-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-actions button.danger-btn:hover {
|
|
||||||
background: var(--lora-error);
|
|
||||||
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style for selected cards */
|
/* Style for selected cards */
|
||||||
.model-card.selected {
|
.model-card.selected {
|
||||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
box-shadow: 0 0 0 2px var(--lora-accent);
|
||||||
@@ -99,203 +21,61 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update bulk operations button to match others when active */
|
/* Marquee selection styles */
|
||||||
#bulkOperationsBtn.active {
|
.marquee-selection {
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.bulk-operations-panel {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
min-width: unset;
|
|
||||||
max-width: unset;
|
|
||||||
left: 20px;
|
|
||||||
transform: none;
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-operations-panel.visible {
|
|
||||||
transform: translateY(0) translateX(-50%);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thumbnail Strip Styles */
|
|
||||||
.selected-thumbnails-strip {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 80px; /* Position above the bulk operations panel */
|
border: 2px dashed var(--lora-accent, #007bff);
|
||||||
left: 50%;
|
background: rgba(0, 123, 255, 0.1);
|
||||||
transform: translateX(-50%) translateY(20px);
|
pointer-events: none;
|
||||||
background: var(--card-bg);
|
z-index: 9999;
|
||||||
border: 1px solid var(--border-color);
|
border-radius: 2px;
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
||||||
z-index: calc(var(--z-overlay) - 1); /* Just below the bulk panel z-index */
|
|
||||||
padding: 16px;
|
|
||||||
max-width: 80%;
|
|
||||||
width: auto;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-thumbnails-strip.visible {
|
/* Visual feedback when marquee selecting */
|
||||||
opacity: 1;
|
.marquee-selecting {
|
||||||
transform: translateX(-50%) translateY(0);
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnails-container {
|
/* Prevent text selection during marquee */
|
||||||
display: flex;
|
.marquee-selecting * {
|
||||||
gap: 12px;
|
user-select: none;
|
||||||
overflow-x: auto;
|
-webkit-user-select: none;
|
||||||
padding-bottom: 8px; /* Space for scrollbar */
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove bulk base model modal specific styles - now using shared components */
|
||||||
|
/* Use shared metadata editing styles instead */
|
||||||
|
|
||||||
|
/* Override for bulk base model select to ensure proper width */
|
||||||
|
.bulk-base-model-select {
|
||||||
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
align-items: flex-start;
|
padding: 6px 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.selected-thumbnail {
|
|
||||||
position: relative;
|
|
||||||
width: 80px;
|
|
||||||
min-width: 80px; /* Prevent shrinking */
|
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
overflow: hidden;
|
background-color: var(--lora-surface);
|
||||||
cursor: pointer;
|
|
||||||
background: var(--bg-color);
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-thumbnail:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-thumbnail img,
|
|
||||||
.selected-thumbnail video {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-name {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
color: white;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 5px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
right: 3px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 10px;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-remove:hover {
|
|
||||||
opacity: 1;
|
|
||||||
background: var(--lora-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.strip-close-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 5px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
font-size: 0.95em;
|
||||||
display: flex;
|
height: 32px;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip-close-btn:hover {
|
.bulk-base-model-select:focus {
|
||||||
opacity: 1;
|
border-color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style the selectedCount to indicate it's clickable */
|
/* Dark theme support for bulk base model select */
|
||||||
.selectable-count {
|
[data-theme="dark"] .bulk-base-model-select {
|
||||||
display: flex;
|
background-color: rgba(30, 30, 30, 0.9);
|
||||||
align-items: center;
|
color: var(--text-color);
|
||||||
gap: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectable-count:hover {
|
[data-theme="dark"] .bulk-base-model-select option {
|
||||||
background: var(--lora-border);
|
background-color: #2d2d2d;
|
||||||
}
|
color: var(--text-color);
|
||||||
|
|
||||||
.dropdown-caret {
|
|
||||||
font-size: 12px;
|
|
||||||
visibility: hidden; /* Will be shown via JS when items are selected */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar styling for the thumbnails container */
|
|
||||||
.thumbnails-container::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnails-container::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnails-container::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnails-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile optimizations */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.selected-thumbnails-strip {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
max-width: none;
|
|
||||||
left: 20px;
|
|
||||||
transform: translateY(20px);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-thumbnails-strip.visible {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-thumbnail {
|
|
||||||
width: 70px;
|
|
||||||
min-width: 70px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments for 1440p screens (2K) */
|
/* Responsive adjustments for 1440p screens (2K) */
|
||||||
@media (min-width: 2000px) {
|
@media (min-width: 2150px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
max-width: 1800px; /* Increased for 2K screens */
|
max-width: 1800px; /* Increased for 2K screens */
|
||||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||||
@@ -525,7 +525,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* For larger screens, allow more space for the cards */
|
/* For larger screens, allow more space for the cards */
|
||||||
@media (min-width: 2000px) {
|
@media (min-width: 2150px) {
|
||||||
.card-grid.virtual-scroll {
|
.card-grid.virtual-scroll {
|
||||||
max-width: 1800px;
|
max-width: 1800px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive container for larger screens - match container in layout.css */
|
/* Responsive container for larger screens - match container in layout.css */
|
||||||
@media (min-width: 2000px) {
|
@media (min-width: 2150px) {
|
||||||
.duplicates-banner .banner-content {
|
.duplicates-banner .banner-content {
|
||||||
max-width: 1800px;
|
max-width: 1800px;
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Add responsive container adjustments for duplicate groups - match container in banner */
|
/* Add responsive container adjustments for duplicate groups - match container in banner */
|
||||||
@media (min-width: 2000px) {
|
@media (min-width: 2150px) {
|
||||||
.duplicate-group {
|
.duplicate-group {
|
||||||
max-width: 1800px;
|
max-width: 1800px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
z-index: var(--z-header);
|
z-index: var(--z-header);
|
||||||
height: 48px; /* Reduced height */
|
height: 48px; /* Reduced height */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* Slightly stronger shadow */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive header container for larger screens */
|
/* Responsive header container for larger screens */
|
||||||
@media (min-width: 2000px) {
|
@media (min-width: 2150px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
max-width: 1800px;
|
max-width: 1800px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,72 +337,7 @@
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Location Selection Styles */
|
|
||||||
.location-selection {
|
|
||||||
margin: var(--space-2) 0;
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: var(--lora-surface);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reuse folder browser and path preview styles from download-modal.css */
|
|
||||||
.folder-browser {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: var(--space-1);
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-item {
|
|
||||||
padding: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-item:hover {
|
|
||||||
background: var(--lora-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-item.selected {
|
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
|
||||||
border: 1px solid var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-preview {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: var(--bg-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
border: 1px dashed var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-preview label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 0.9em;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-display {
|
|
||||||
padding: var(--space-1);
|
|
||||||
color: var(--text-color);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
opacity: 0.85;
|
|
||||||
background: var(--lora-surface);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input Group Styles */
|
/* Input Group Styles */
|
||||||
.input-group {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-button {
|
.input-with-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -430,22 +365,6 @@
|
|||||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group input,
|
|
||||||
.input-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
background: var(--bg-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme adjustments */
|
/* Dark theme adjustments */
|
||||||
[data-theme="dark"] .lora-item {
|
[data-theme="dark"] .lora-item {
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
|
|||||||
@@ -40,10 +40,10 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9999; /* 确保在卡片上方显示 */
|
z-index: 9999; /* Ensure tooltip appears above cards */
|
||||||
left: 120%; /* 将tooltip显示在图标右侧 */
|
left: 120%; /* Position tooltip to the right of the icon */
|
||||||
top: 50%; /* 垂直居中 */
|
top: 50%; /* Vertically center */
|
||||||
transform: translateY(-50%); /* 垂直居中 */
|
transform: translateY(-15%); /* Vertically center */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||||
@@ -55,12 +55,12 @@
|
|||||||
.tooltip .tooltiptext::after {
|
.tooltip .tooltiptext::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%; /* 箭头垂直居中 */
|
top: 50%; /* Vertically center arrow */
|
||||||
right: 100%; /* 箭头在左侧 */
|
right: 100%; /* Arrow on the left side */
|
||||||
margin-top: -5px;
|
margin-top: -5px;
|
||||||
border-width: 5px;
|
border-width: 5px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */
|
border-color: transparent var(--lora-border) transparent transparent; /* Arrow points left */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip:hover .tooltiptext {
|
.tooltip:hover .tooltiptext {
|
||||||
|
|||||||
@@ -176,11 +176,6 @@
|
|||||||
background: linear-gradient(45deg, #4a90e2, #357abd);
|
background: linear-gradient(45deg, #4a90e2, #357abd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove old node-color-indicator styles */
|
|
||||||
.node-color-indicator {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-all-item {
|
.send-all-item {
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -217,4 +212,24 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bulk Context Menu Header */
|
||||||
|
.bulk-context-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--card-bg); /* Use card background for subtlety */
|
||||||
|
color: var(--text-color); /* Use standard text color */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: var(--border-radius-xs) var(--border-radius-xs) 0 0;
|
||||||
|
border-bottom: 1px solid var(--border-color); /* Add subtle separator */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-context-header i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--lora-accent); /* Accent only the icon for a hint of color */
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ body.modal-open {
|
|||||||
position: relative;
|
position: relative;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: calc(90vh - 48px); /* Adjust to account for header height */
|
max-height: calc(90vh);
|
||||||
margin: 1rem auto; /* Keep reduced top margin */
|
margin: 1rem auto; /* Keep reduced top margin */
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
@@ -37,6 +37,10 @@ body.modal-open {
|
|||||||
overflow-x: hidden; /* 防止水平滚动条 */
|
overflow-x: hidden; /* 防止水平滚动条 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content-large {
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 当 modal 打开时锁定 body */
|
/* 当 modal 打开时锁定 body */
|
||||||
body.modal-open {
|
body.modal-open {
|
||||||
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */
|
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */
|
||||||
|
|||||||
@@ -121,15 +121,6 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Folder Browser Styles */
|
|
||||||
.folder-browser {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: var(--space-1);
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-item {
|
.folder-item {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -445,69 +445,6 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Switch styles */
|
|
||||||
.search-option-switch {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 46px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: #ccc;
|
|
||||||
transition: .4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider:before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
background-color: white;
|
|
||||||
transition: .4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + .slider {
|
|
||||||
background-color: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus + .slider {
|
|
||||||
box-shadow: 0 0 1px var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + .slider:before {
|
|
||||||
transform: translateX(22px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider.round {
|
|
||||||
border-radius: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider.round:before {
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.search-options-panel,
|
.search-options-panel,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 30px; /* Ensure some height even if empty to prevent layout shifts */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Individual Item */
|
/* Individual Item */
|
||||||
@@ -153,17 +154,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metadata-save-btn,
|
.metadata-save-btn,
|
||||||
.save-tags-btn {
|
.save-tags-btn,
|
||||||
|
.append-tags-btn,
|
||||||
|
.replace-tags-btn {
|
||||||
background: var(--lora-accent) !important;
|
background: var(--lora-accent) !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
border-color: var(--lora-accent) !important;
|
border-color: var(--lora-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-save-btn:hover,
|
.metadata-save-btn:hover,
|
||||||
.save-tags-btn:hover {
|
.save-tags-btn:hover,
|
||||||
|
.append-tags-btn:hover,
|
||||||
|
.replace-tags-btn:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Specific styling for bulk tag action buttons */
|
||||||
|
.bulk-append-tags-btn {
|
||||||
|
background: var(--lora-accent) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: var(--lora-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-replace-tags-btn {
|
||||||
|
background: var(--lora-warning, #f59e0b) !important;
|
||||||
|
color: white !important;
|
||||||
|
border-color: var(--lora-warning, #f59e0b) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-append-tags-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-replace-tags-btn:hover {
|
||||||
|
background: var(--lora-warning-dark, #d97706) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add Form */
|
/* Add Form */
|
||||||
.metadata-add-form {
|
.metadata-add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
554
static/css/components/sidebar.css
Normal file
554
static/css/components/sidebar.css
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
.folder-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 68px; /* Below header */
|
||||||
|
left: 0px;
|
||||||
|
width: 230px;
|
||||||
|
height: calc(100vh - 88px);
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: var(--z-overlay);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
/* Default state: hidden off-screen */
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visible state */
|
||||||
|
.folder-sidebar.visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-hide states */
|
||||||
|
.folder-sidebar.auto-hide {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-sidebar.auto-hide.hover-active {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-sidebar.collapsed {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover detection area for auto-hide */
|
||||||
|
.sidebar-hover-area {
|
||||||
|
position: fixed;
|
||||||
|
top: 68px;
|
||||||
|
left: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: calc(100vh - 88px);
|
||||||
|
z-index: calc(var(--z-overlay) - 1);
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-hover-area.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header.root-selected {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-btn.active {
|
||||||
|
opacity: 1;
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action-btn.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove old close button styles */
|
||||||
|
.sidebar-toggle-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Tree Node Styles */
|
||||||
|
.sidebar-tree-node {
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-node-content:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-node-content.selected {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border-left-color: var(--lora-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-expand-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-expand-icon i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-folder-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-node-content.selected .sidebar-tree-folder-icon {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-node-content:hover .sidebar-tree-folder-icon {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-folder-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children.expanded {
|
||||||
|
max-height: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children .sidebar-tree-node-content {
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children .sidebar-tree-children .sidebar-tree-node-content {
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children .sidebar-tree-children .sidebar-tree-children .sidebar-tree-node-content {
|
||||||
|
padding-left: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Sidebar Breadcrumb Styles */
|
||||||
|
.sidebar-breadcrumb-container {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--text-muted);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item.active {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-separator {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New Breadcrumb Dropdown Styles */
|
||||||
|
.breadcrumb-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown-indicator {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item:hover .breadcrumb-dropdown-indicator {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item.placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item.placeholder:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown.open .breadcrumb-dropdown-indicator {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 240px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
|
||||||
|
z-index: calc(var(--z-overlay) + 20);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 450px;
|
||||||
|
display: none;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown.open .breadcrumb-dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown-item:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-dropdown-item.active {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder List Mode Styles */
|
||||||
|
.sidebar-folder-item {
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-node-content:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-folder-item.selected .sidebar-node-content {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border-left-color: var(--lora-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-folder-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-folder-item.selected .sidebar-folder-icon {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-node-content:hover .sidebar-folder-icon {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-folder-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (min-width: 2150px) {
|
||||||
|
.folder-sidebar {
|
||||||
|
width: 280px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
.folder-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.folder-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.sidebar-tree-placeholder {
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-placeholder i {
|
||||||
|
font-size: 2em;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for tree nodes */
|
||||||
|
.sidebar-tree-node {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children {
|
||||||
|
transition: max-height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-expand-icon {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual separator for nested levels */
|
||||||
|
.sidebar-tree-children .sidebar-tree-node-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-children .sidebar-tree-node-content::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.folder-sidebar {
|
||||||
|
top: 68px;
|
||||||
|
left: 0px;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
max-width: 320px;
|
||||||
|
height: calc(100vh - 88px);
|
||||||
|
z-index: calc(var(--z-overlay) + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-sidebar.collapsed {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile overlay */
|
||||||
|
.folder-sidebar:not(.collapsed)::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: -1;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.folder-sidebar {
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
max-width: 280px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-nav {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-breadcrumb-item {
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.sidebar-tree-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,24 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 20px auto;
|
margin: 0 auto;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--z-base);
|
z-index: var(--z-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sticky controls container */
|
||||||
|
.controls {
|
||||||
|
position: sticky;
|
||||||
|
top: -54px;
|
||||||
|
z-index: calc(var(--z-header) - 1);
|
||||||
|
background: var(--bg-color);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive container for larger screens */
|
/* Responsive container for larger screens */
|
||||||
@media (min-width: 2000px) {
|
@media (min-width: 2150px) {
|
||||||
.container {
|
.container {
|
||||||
max-width: 1800px;
|
max-width: 1800px;
|
||||||
}
|
}
|
||||||
@@ -28,13 +38,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-right {
|
.controls-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -225,63 +228,6 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-tags-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 8px; /* Add margin to ensure space for the button */
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
transition: max-height 0.3s ease, opacity 0.2s ease;
|
|
||||||
max-height: 150px; /* Limit height to prevent overflow */
|
|
||||||
opacity: 1;
|
|
||||||
overflow-y: auto; /* Enable vertical scrolling */
|
|
||||||
margin-bottom: 5px; /* Add margin below the tags */
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags.collapsed {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-folders-container {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Folders Button */
|
|
||||||
.toggle-folders-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-folders-btn:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-folders-btn i {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon-only button style */
|
/* Icon-only button style */
|
||||||
.icon-only {
|
.icon-only {
|
||||||
min-width: unset !important;
|
min-width: unset !important;
|
||||||
@@ -290,55 +236,6 @@
|
|||||||
height: 32px !important;
|
height: 32px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotate icon when folders are collapsed */
|
|
||||||
.folder-tags.collapsed ~ .actions .toggle-folders-btn i {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add custom scrollbar for better visibility */
|
|
||||||
.folder-tags::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags::-webkit-scrollbar-track {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 8px;
|
|
||||||
margin: 2px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
display: inline-block;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-size: 14px;
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag:hover {
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
background-color: oklch(var(--lora-accent) / 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.active {
|
|
||||||
background-color: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back to Top Button */
|
/* Back to Top Button */
|
||||||
.back-to-top {
|
.back-to-top {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -376,10 +273,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent text selection in control and header areas */
|
/* Prevent text selection in control and header areas */
|
||||||
.tag,
|
|
||||||
.control-group button,
|
.control-group button,
|
||||||
.control-group select,
|
.control-group select,
|
||||||
.toggle-folders-btn,
|
|
||||||
.bulk-operations-panel,
|
.bulk-operations-panel,
|
||||||
.app-header,
|
.app-header,
|
||||||
.header-branding,
|
.header-branding,
|
||||||
@@ -387,8 +282,7 @@
|
|||||||
.main-nav,
|
.main-nav,
|
||||||
.nav-item,
|
.nav-item,
|
||||||
.header-actions button,
|
.header-actions button,
|
||||||
.header-controls,
|
.header-controls {
|
||||||
.toggle-folders-container button {
|
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
@@ -472,18 +366,6 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-folders-container {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags-container {
|
|
||||||
order: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-folders-btn:hover {
|
|
||||||
transform: none; /* Disable hover effects on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group button:hover {
|
.control-group button:hover {
|
||||||
transform: none; /* Disable hover effects on mobile */
|
transform: none; /* Disable hover effects on mobile */
|
||||||
@@ -493,10 +375,6 @@
|
|||||||
transform: none; /* Disable hover effects on mobile */
|
transform: none; /* Disable hover effects on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag:hover {
|
|
||||||
transform: none; /* Disable hover effects on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top {
|
.back-to-top {
|
||||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||||
}
|
}
|
||||||
@@ -505,4 +383,9 @@
|
|||||||
left: auto;
|
left: auto;
|
||||||
right: 0; /* Align to right on mobile */
|
right: 0; /* Align to right on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Adjust controls padding on mobile */
|
||||||
|
.controls {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
252
static/css/onboarding.css
Normal file
252
static/css/onboarding.css
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/* Onboarding Tutorial Styles */
|
||||||
|
.onboarding-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: var(--z-overlay);
|
||||||
|
display: none;
|
||||||
|
/* Use mask to create cutout for highlighted element */
|
||||||
|
mask-composite: subtract;
|
||||||
|
-webkit-mask-composite: subtract;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-overlay.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-spotlight {
|
||||||
|
position: absolute;
|
||||||
|
background: transparent;
|
||||||
|
border: 3px solid var(--lora-accent);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
z-index: calc(var(--z-overlay) + 1);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
/* Add glow effect */
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(24, 144, 255, 0.3),
|
||||||
|
0 0 20px rgba(24, 144, 255, 0.2),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target element highlighting */
|
||||||
|
.onboarding-target-highlight {
|
||||||
|
position: relative;
|
||||||
|
z-index: calc(var(--z-overlay) + 2) !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure highlighted elements are interactive */
|
||||||
|
.onboarding-target-highlight * {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-popup {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
padding: var(--space-3);
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 400px;
|
||||||
|
z-index: calc(var(--z-overlay) + 3);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-popup h3 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-popup p {
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-btn {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: var(--lora-text);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-btn.primary {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: var(--lora-text);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-btn.primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language Selection Modal */
|
||||||
|
.language-selection-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: calc(var(--z-overlay) + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-selection-content {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
padding: var(--space-3);
|
||||||
|
min-width: 510px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-selection-content h2 {
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-selection-content p {
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-option {
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 2px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: var(--card-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-option:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-option.selected {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: var(--lora-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-flag {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shortcut Key Highlighting */
|
||||||
|
.onboarding-shortcut {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--shortcut-bg);
|
||||||
|
border: 1px solid var(--shortcut-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--shortcut-text);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for highlighting elements */
|
||||||
|
.onboarding-highlight {
|
||||||
|
animation: onboarding-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes onboarding-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(24, 144, 255, 0.4),
|
||||||
|
0 0 20px rgba(24, 144, 255, 0.3),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(24, 144, 255, 0.6),
|
||||||
|
0 0 30px rgba(24, 144, 255, 0.4),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.onboarding-popup {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-selection-content {
|
||||||
|
min-width: calc(100vw - 40px);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,10 +34,10 @@
|
|||||||
@import 'components/filter-indicator.css';
|
@import 'components/filter-indicator.css';
|
||||||
@import 'components/initialization.css';
|
@import 'components/initialization.css';
|
||||||
@import 'components/progress-panel.css';
|
@import 'components/progress-panel.css';
|
||||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
|
||||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -63,6 +63,9 @@ export function getApiEndpoints(modelType) {
|
|||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
bulkDelete: `/api/${modelType}/bulk-delete`,
|
bulkDelete: `/api/${modelType}/bulk-delete`,
|
||||||
|
|
||||||
|
// Tag operations
|
||||||
|
addTags: `/api/${modelType}/add-tags`,
|
||||||
|
|
||||||
// Move operations (now common for all model types that support move)
|
// Move operations (now common for all model types that support move)
|
||||||
moveModel: `/api/${modelType}/move_model`,
|
moveModel: `/api/${modelType}/move_model`,
|
||||||
@@ -88,6 +91,12 @@ export function getApiEndpoints(modelType) {
|
|||||||
duplicates: `/api/${modelType}/find-duplicates`,
|
duplicates: `/api/${modelType}/find-duplicates`,
|
||||||
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
||||||
verify: `/api/${modelType}/verify-duplicates`,
|
verify: `/api/${modelType}/verify-duplicates`,
|
||||||
|
metadata: `/api/${modelType}/metadata`,
|
||||||
|
modelDescription: `/api/${modelType}/model-description`,
|
||||||
|
|
||||||
|
// Auto-organize operations
|
||||||
|
autoOrganize: `/api/${modelType}/auto-organize`,
|
||||||
|
autoOrganizeProgress: `/api/${modelType}/auto-organize-progress`,
|
||||||
|
|
||||||
// Model-specific endpoints (will be merged with specific configs)
|
// Model-specific endpoints (will be merged with specific configs)
|
||||||
specific: {}
|
specific: {}
|
||||||
@@ -104,7 +113,7 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
|||||||
triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`,
|
triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`,
|
||||||
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
||||||
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
||||||
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
|
metadata: `/api/${MODEL_TYPES.LORA}/metadata`,
|
||||||
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
||||||
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
||||||
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
||||||
@@ -113,8 +122,10 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
|||||||
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
||||||
checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
|
checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
|
||||||
unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
|
unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
|
||||||
|
metadata: `/api/${MODEL_TYPES.CHECKPOINT}/metadata`,
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
|
metadata: `/api/${MODEL_TYPES.EMBEDDING}/metadata`,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||||
import {
|
import {
|
||||||
getCompleteApiConfig,
|
getCompleteApiConfig,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
import { resetAndReload } from './modelApiFactory.js';
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
|
import { sidebarManager } from '../components/SidebarManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for all model API clients
|
* Abstract base class for all model API clients
|
||||||
@@ -75,7 +77,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error);
|
console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error);
|
||||||
showToast(`Failed to fetch ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
|
showToast('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,21 +105,13 @@ export class BaseModelApiClient {
|
|||||||
pageState.currentPage = pageState.currentPage + 1;
|
pageState.currentPage = pageState.currentPage + 1;
|
||||||
|
|
||||||
if (updateFolders) {
|
if (updateFolders) {
|
||||||
const response = await fetch(this.apiConfig.endpoints.folders);
|
sidebarManager.refresh();
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
updateFolderTags(data.folders);
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
const errorMsg = errorData && errorData.error ? errorData.error : response.statusText;
|
|
||||||
console.error(`Error getting folders: ${errorMsg}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error);
|
console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error);
|
||||||
showToast(`Failed to reload ${this.apiConfig.config.displayName}s: ${error.message}`, 'error');
|
showToast('toast.api.reloadFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pageState.isLoading = false;
|
pageState.isLoading = false;
|
||||||
@@ -145,14 +139,14 @@ export class BaseModelApiClient {
|
|||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
state.virtualScroller.removeItemByFilePath(filePath);
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
}
|
}
|
||||||
showToast(`${this.apiConfig.config.displayName} deleted successfully`, 'success');
|
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error);
|
console.error(`Error deleting ${this.apiConfig.config.singularName}:`, error);
|
||||||
showToast(`Failed to delete ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
|
showToast('toast.api.deleteFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
@@ -179,14 +173,14 @@ export class BaseModelApiClient {
|
|||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
state.virtualScroller.removeItemByFilePath(filePath);
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
}
|
}
|
||||||
showToast(`${this.apiConfig.config.displayName} excluded successfully`, 'success');
|
showToast('toast.api.excludeSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || `Failed to exclude ${this.apiConfig.config.singularName}`);
|
throw new Error(data.error || `Failed to exclude ${this.apiConfig.config.singularName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error excluding ${this.apiConfig.config.singularName}:`, error);
|
console.error(`Error excluding ${this.apiConfig.config.singularName}:`, error);
|
||||||
showToast(`Failed to exclude ${this.apiConfig.config.singularName}: ${error.message}`, 'error');
|
showToast('toast.api.excludeFailed', { type: this.apiConfig.config.singularName, message: error.message }, 'error');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
@@ -215,9 +209,9 @@ export class BaseModelApiClient {
|
|||||||
preview_url: result.new_preview_path
|
preview_url: result.new_preview_path
|
||||||
});
|
});
|
||||||
|
|
||||||
showToast('File name updated successfully', 'success');
|
showToast('toast.api.fileNameUpdated', {}, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('Failed to rename file: ' + (result.error || 'Unknown error'), 'error');
|
showToast('toast.api.fileRenameFailed', { error: result.error || 'Unknown error' }, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -279,10 +273,10 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.virtualScroller.updateSingleItem(filePath, updateData);
|
state.virtualScroller.updateSingleItem(filePath, updateData);
|
||||||
showToast('Preview updated successfully', 'success');
|
showToast('toast.api.previewUpdated', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading preview:', error);
|
console.error('Error uploading preview:', error);
|
||||||
showToast('Failed to upload preview image', 'error');
|
showToast('toast.api.previewUploadFailed', {}, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
@@ -312,6 +306,34 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addTags(filePath, data) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.addTags, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_path: filePath,
|
||||||
|
...data
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to add tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.tags) {
|
||||||
|
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding tags:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(
|
state.loadingManager.showSimpleLoading(
|
||||||
@@ -329,10 +351,10 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
resetAndReload(true);
|
resetAndReload(true);
|
||||||
|
|
||||||
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
|
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.apiConfig.config.displayName}s`, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
@@ -360,14 +382,14 @@ export class BaseModelApiClient {
|
|||||||
state.virtualScroller.updateSingleItem(filePath, data.metadata);
|
state.virtualScroller.updateSingleItem(filePath, data.metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Metadata refreshed successfully', 'success');
|
showToast('toast.api.metadataRefreshed', {}, 'success');
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || 'Failed to refresh metadata');
|
throw new Error(data.error || 'Failed to refresh metadata');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing metadata:', error);
|
console.error('Error refreshing metadata:', error);
|
||||||
showToast(error.message, 'error');
|
showToast('toast.api.metadataRefreshFailed', { message: error.message }, 'error');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
@@ -419,6 +441,7 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for WebSocket connection to establish
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
ws.onopen = resolve;
|
ws.onopen = resolve;
|
||||||
ws.onerror = reject;
|
ws.onerror = reject;
|
||||||
@@ -434,13 +457,14 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('Failed to fetch metadata');
|
throw new Error('Failed to fetch metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for the operation to complete via WebSocket
|
||||||
await operationComplete;
|
await operationComplete;
|
||||||
|
|
||||||
resetAndReload(false);
|
resetAndReload(false);
|
||||||
showToast('Metadata update complete', 'success');
|
showToast('toast.api.metadataUpdateComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
showToast('Failed to fetch metadata: ' + error.message, 'error');
|
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -508,22 +532,22 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
let completionMessage;
|
let completionMessage;
|
||||||
if (successCount === totalItems) {
|
if (successCount === totalItems) {
|
||||||
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
|
||||||
showToast(completionMessage, 'success');
|
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
|
||||||
} else if (successCount > 0) {
|
} else if (successCount > 0) {
|
||||||
completionMessage = `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`;
|
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
|
||||||
showToast(completionMessage, 'warning');
|
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
|
||||||
|
|
||||||
if (failedItems.length > 0) {
|
if (failedItems.length > 0) {
|
||||||
const failureMessage = failedItems.length <= 3
|
const failureMessage = failedItems.length <= 3
|
||||||
? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
|
? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
|
||||||
: failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
|
: failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
|
||||||
`\n(and ${failedItems.length - 3} more)`;
|
`\n(and ${failedItems.length - 3} more)`;
|
||||||
showToast(`Failed refreshes:\n${failureMessage}`, 'warning', 6000);
|
showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
completionMessage = `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`;
|
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
|
||||||
showToast(completionMessage, 'error');
|
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
await progressController.complete(completionMessage);
|
await progressController.complete(completionMessage);
|
||||||
@@ -539,7 +563,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in bulk metadata refresh:', error);
|
console.error('Error in bulk metadata refresh:', error);
|
||||||
showToast(`Failed to refresh metadata: ${error.message}`, 'error');
|
showToast('toast.api.bulkMetadataFailed', { message: error.message }, 'error');
|
||||||
await progressController.complete('Operation failed');
|
await progressController.complete('Operation failed');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -669,9 +693,10 @@ export class BaseModelApiClient {
|
|||||||
if (pageState.searchOptions.creator !== undefined) {
|
if (pageState.searchOptions.creator !== undefined) {
|
||||||
params.append('search_creator', pageState.searchOptions.creator.toString());
|
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||||
}
|
}
|
||||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||||
|
|
||||||
if (pageState.filters) {
|
if (pageState.filters) {
|
||||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||||
@@ -714,11 +739,11 @@ export class BaseModelApiClient {
|
|||||||
async moveSingleModel(filePath, targetPath) {
|
async moveSingleModel(filePath, targetPath) {
|
||||||
// Only allow move if supported
|
// Only allow move if supported
|
||||||
if (!this.apiConfig.config.supportsMove) {
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
||||||
showToast(`${this.apiConfig.config.displayName} is already in the selected folder`, 'info');
|
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,20 +768,23 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result && result.message) {
|
if (result && result.message) {
|
||||||
showToast(result.message, 'info');
|
showToast('toast.api.moveInfo', { message: result.message }, 'info');
|
||||||
} else {
|
} else {
|
||||||
showToast(`${this.apiConfig.config.displayName} moved successfully`, 'success');
|
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return result.new_file_path;
|
return {
|
||||||
|
original_file_path: result.original_file_path || filePath,
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveBulkModels(filePaths, targetPath) {
|
async moveBulkModels(filePaths, targetPath) {
|
||||||
if (!this.apiConfig.config.supportsMove) {
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const movedPaths = filePaths.filter(path => {
|
const movedPaths = filePaths.filter(path => {
|
||||||
@@ -764,7 +792,7 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (movedPaths.length === 0) {
|
if (movedPaths.length === 0) {
|
||||||
showToast(`All selected ${this.apiConfig.config.displayName}s are already in the target folder`, 'info');
|
showToast('toast.api.allAlreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,33 +813,38 @@ export class BaseModelApiClient {
|
|||||||
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let successFilePaths = [];
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.failure_count > 0) {
|
if (result.failure_count > 0) {
|
||||||
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
|
showToast('toast.api.bulkMovePartial', {
|
||||||
|
successCount: result.success_count,
|
||||||
|
type: this.apiConfig.config.displayName,
|
||||||
|
failureCount: result.failure_count
|
||||||
|
}, 'warning');
|
||||||
console.log('Move operation results:', result.results);
|
console.log('Move operation results:', result.results);
|
||||||
const failedFiles = result.results
|
const failedFiles = result.results
|
||||||
.filter(r => !r.success)
|
.filter(r => !r.success)
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
const fileName = r.original_file_path.substring(r.original_file_path.lastIndexOf('/') + 1);
|
||||||
return `${fileName}: ${r.message}`;
|
return `${fileName}: ${r.message}`;
|
||||||
});
|
});
|
||||||
if (failedFiles.length > 0) {
|
if (failedFiles.length > 0) {
|
||||||
const failureMessage = failedFiles.length <= 3
|
const failureMessage = failedFiles.length <= 3
|
||||||
? failedFiles.join('\n')
|
? failedFiles.join('\n')
|
||||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
|
showToast('toast.api.bulkMoveSuccess', {
|
||||||
|
successCount: result.success_count,
|
||||||
|
type: this.apiConfig.config.displayName
|
||||||
|
}, 'success');
|
||||||
}
|
}
|
||||||
successFilePaths = result.results
|
|
||||||
.filter(r => r.success)
|
// Return the results array with original_file_path and new_file_path
|
||||||
.map(r => r.path);
|
return result.results || [];
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
}
|
}
|
||||||
return successFilePaths;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkDeleteModels(filePaths) {
|
async bulkDeleteModels(filePaths) {
|
||||||
@@ -936,12 +969,12 @@ export class BaseModelApiClient {
|
|||||||
// Wait for the operation to complete via WebSocket
|
// Wait for the operation to complete via WebSocket
|
||||||
await operationComplete;
|
await operationComplete;
|
||||||
|
|
||||||
showToast('Successfully downloaded example images!', 'success');
|
showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success');
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading example images:', error);
|
console.error('Error downloading example images:', error);
|
||||||
showToast(`Failed to download example images: ${error.message}`, 'error');
|
showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -953,4 +986,173 @@ export class BaseModelApiClient {
|
|||||||
completionMessage: 'Example images download complete'
|
completionMessage: 'Example images download complete'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchModelMetadata(filePath) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ file_path: filePath });
|
||||||
|
const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
return data.metadata;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || `No metadata found for ${this.apiConfig.config.singularName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${this.apiConfig.config.singularName} metadata:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchModelDescription(filePath) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ file_path: filePath });
|
||||||
|
const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
return data.description;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || `No description found for ${this.apiConfig.config.singularName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${this.apiConfig.config.singularName} description:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-organize models based on current path template settings
|
||||||
|
* @param {Array} filePaths - Optional array of file paths to organize. If not provided, organizes all models.
|
||||||
|
* @returns {Promise} - Promise that resolves when the operation is complete
|
||||||
|
*/
|
||||||
|
async autoOrganizeModels(filePaths = null) {
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
|
try {
|
||||||
|
// Connect to WebSocket for progress updates
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type !== 'auto_organize_progress') return;
|
||||||
|
|
||||||
|
switch(data.status) {
|
||||||
|
case 'started':
|
||||||
|
loading.setProgress(0);
|
||||||
|
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.starting', { type: operationType }, `Starting auto-organize for ${operationType}...`));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
|
||||||
|
loading.setProgress(percent);
|
||||||
|
loading.setStatus(
|
||||||
|
translate('loras.bulkOperations.autoOrganizeProgress.processing', {
|
||||||
|
processed: data.processed,
|
||||||
|
total: data.total,
|
||||||
|
success: data.success,
|
||||||
|
failures: data.failures,
|
||||||
|
skipped: data.skipped
|
||||||
|
}, `Processing (${data.processed}/${data.total}) - ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cleaning':
|
||||||
|
loading.setProgress(95);
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.cleaning', {}, 'Cleaning up empty directories...'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'completed':
|
||||||
|
loading.setProgress(100);
|
||||||
|
loading.setStatus(
|
||||||
|
translate('loras.bulkOperations.autoOrganizeProgress.completed', {
|
||||||
|
success: data.success,
|
||||||
|
skipped: data.skipped,
|
||||||
|
failures: data.failures,
|
||||||
|
total: data.total
|
||||||
|
}, `Completed: ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(data);
|
||||||
|
}, 1500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||||
|
reject(new Error(data.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error during auto-organize:', error);
|
||||||
|
reject(new Error('Connection error'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the auto-organize operation
|
||||||
|
const endpoint = this.apiConfig.endpoints.autoOrganize;
|
||||||
|
const requestOptions = {
|
||||||
|
method: filePaths ? 'POST' : 'GET',
|
||||||
|
headers: filePaths ? { 'Content-Type': 'application/json' } : {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filePaths) {
|
||||||
|
requestOptions.body = JSON.stringify({ file_paths: filePaths });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, requestOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to start auto-organize operation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the operation to complete via WebSocket
|
||||||
|
const result = await operationComplete;
|
||||||
|
|
||||||
|
// Show appropriate success message based on results
|
||||||
|
if (result.failures === 0) {
|
||||||
|
showToast('toast.loras.autoOrganizeSuccess', {
|
||||||
|
count: result.success,
|
||||||
|
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||||
|
}, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('toast.loras.autoOrganizePartialSuccess', {
|
||||||
|
success: result.success,
|
||||||
|
failures: result.failures,
|
||||||
|
total: result.total
|
||||||
|
}, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during auto-organize:', error);
|
||||||
|
showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
initialMessage: translate('loras.bulkOperations.autoOrganizeProgress.initializing', {}, 'Initializing auto-organize...'),
|
||||||
|
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BaseModelApiClient } from './baseModelApi.js';
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checkpoint-specific API client
|
* Checkpoint-specific API client
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BaseModelApiClient } from './baseModelApi.js';
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embedding-specific API client
|
* Embedding-specific API client
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BaseModelApiClient } from './baseModelApi.js';
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
|
||||||
import { getSessionItem } from '../utils/storageHelpers.js';
|
import { getSessionItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching recipes:', error);
|
console.error('Error fetching recipes:', error);
|
||||||
showToast(`Failed to fetch recipes: ${error.message}`, 'error');
|
showToast('toast.recipes.fetchFailed', { message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reloading ${modelType}s:`, error);
|
console.error(`Error reloading ${modelType}s:`, error);
|
||||||
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
|
showToast('toast.recipes.reloadFailed', { modelType: modelType, message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pageState.isLoading = false;
|
pageState.isLoading = false;
|
||||||
@@ -179,7 +179,7 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading ${modelType}s:`, error);
|
console.error(`Error loading ${modelType}s:`, error);
|
||||||
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
|
showToast('toast.recipes.loadFailed', { modelType: modelType, message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pageState.isLoading = false;
|
pageState.isLoading = false;
|
||||||
@@ -217,10 +217,10 @@ export async function refreshRecipes() {
|
|||||||
// After successful cache rebuild, reload the recipes
|
// After successful cache rebuild, reload the recipes
|
||||||
await resetAndReload();
|
await resetAndReload();
|
||||||
|
|
||||||
showToast('Refresh complete', 'success');
|
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
showToast(error.message || 'Failed to refresh recipes', 'error');
|
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
@@ -285,7 +285,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showToast(`Failed to update recipe: ${data.error}`, 'error');
|
showToast('toast.recipes.updateFailed', { error: data.error }, 'error');
|
||||||
throw new Error(data.error || 'Failed to update recipe');
|
throw new Error(data.error || 'Failed to update recipe');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating recipe:', error);
|
console.error('Error updating recipe:', error);
|
||||||
showToast(`Error updating recipe: ${error.message}`, 'error');
|
showToast('toast.recipes.updateError', { message: error.message }, 'error');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
|
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
|
||||||
@@ -30,14 +29,7 @@ class CheckpointsPageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Initialize page-specific components
|
// Initialize common page features (including context menus)
|
||||||
this.pageControls.restoreFolderFilter();
|
|
||||||
this.pageControls.initFolderTagsVisibility();
|
|
||||||
|
|
||||||
// Initialize context menu
|
|
||||||
new CheckpointContextMenu();
|
|
||||||
|
|
||||||
// Initialize common page features
|
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
console.log('Checkpoints Manager initialized');
|
console.log('Checkpoints Manager initialized');
|
||||||
@@ -52,4 +44,4 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Initialize checkpoints page
|
// Initialize checkpoints page
|
||||||
const checkpointsPage = new CheckpointsPageManager();
|
const checkpointsPage = new CheckpointsPageManager();
|
||||||
await checkpointsPage.initialize();
|
await checkpointsPage.initialize();
|
||||||
});
|
});
|
||||||
@@ -15,17 +15,6 @@ export class BaseContextMenu {
|
|||||||
init() {
|
init() {
|
||||||
// Hide menu on regular clicks
|
// Hide menu on regular clicks
|
||||||
document.addEventListener('click', () => this.hideMenu());
|
document.addEventListener('click', () => this.hideMenu());
|
||||||
|
|
||||||
// Show menu on right-click on cards
|
|
||||||
document.addEventListener('contextmenu', (e) => {
|
|
||||||
const card = e.target.closest(this.cardSelector);
|
|
||||||
if (!card) {
|
|
||||||
this.hideMenu();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
this.showMenu(e.clientX, e.clientY, card);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle menu item clicks
|
// Handle menu item clicks
|
||||||
this.menu.addEventListener('click', (e) => {
|
this.menu.addEventListener('click', (e) => {
|
||||||
|
|||||||
117
static/js/components/ContextMenu/BulkContextMenu.js
Normal file
117
static/js/components/ContextMenu/BulkContextMenu.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
|
import { updateElementText } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
export class BulkContextMenu extends BaseContextMenu {
|
||||||
|
constructor() {
|
||||||
|
super('bulkContextMenu', '.model-card.selected');
|
||||||
|
this.setupBulkMenuItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupBulkMenuItems() {
|
||||||
|
if (!this.menu) return;
|
||||||
|
|
||||||
|
// Update menu items visibility based on current model type
|
||||||
|
this.updateMenuItemsForModelType();
|
||||||
|
|
||||||
|
// Update selected count in header
|
||||||
|
this.updateSelectedCountHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMenuItemsForModelType() {
|
||||||
|
const currentModelType = state.currentPageType;
|
||||||
|
const config = bulkManager.actionConfig[currentModelType];
|
||||||
|
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
// Update button visibility based on model type
|
||||||
|
const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
|
||||||
|
const setBaseModelItem = this.menu.querySelector('[data-action="set-base-model"]');
|
||||||
|
const sendToWorkflowAppendItem = this.menu.querySelector('[data-action="send-to-workflow-append"]');
|
||||||
|
const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]');
|
||||||
|
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
|
||||||
|
const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
|
||||||
|
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
||||||
|
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||||
|
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||||
|
|
||||||
|
if (sendToWorkflowAppendItem) {
|
||||||
|
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (sendToWorkflowReplaceItem) {
|
||||||
|
sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (copyAllItem) {
|
||||||
|
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (refreshAllItem) {
|
||||||
|
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (moveAllItem) {
|
||||||
|
moveAllItem.style.display = config.moveAll ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (autoOrganizeItem) {
|
||||||
|
autoOrganizeItem.style.display = config.autoOrganize ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (deleteAllItem) {
|
||||||
|
deleteAllItem.style.display = config.deleteAll ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (addTagsItem) {
|
||||||
|
addTagsItem.style.display = config.addTags ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
if (setBaseModelItem) {
|
||||||
|
setBaseModelItem.style.display = 'flex'; // Base model editing is available for all model types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectedCountHeader() {
|
||||||
|
const headerElement = this.menu.querySelector('.bulk-context-header');
|
||||||
|
if (headerElement) {
|
||||||
|
updateElementText(headerElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showMenu(x, y, card) {
|
||||||
|
this.updateMenuItemsForModelType();
|
||||||
|
this.updateSelectedCountHeader();
|
||||||
|
super.showMenu(x, y, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenuAction(action, menuItem) {
|
||||||
|
switch (action) {
|
||||||
|
case 'add-tags':
|
||||||
|
bulkManager.showBulkAddTagsModal();
|
||||||
|
break;
|
||||||
|
case 'set-base-model':
|
||||||
|
bulkManager.showBulkBaseModelModal();
|
||||||
|
break;
|
||||||
|
case 'send-to-workflow-append':
|
||||||
|
bulkManager.sendAllModelsToWorkflow(false);
|
||||||
|
break;
|
||||||
|
case 'send-to-workflow-replace':
|
||||||
|
bulkManager.sendAllModelsToWorkflow(true);
|
||||||
|
break;
|
||||||
|
case 'copy-all':
|
||||||
|
bulkManager.copyAllModelsSyntax();
|
||||||
|
break;
|
||||||
|
case 'refresh-all':
|
||||||
|
bulkManager.refreshAllMetadata();
|
||||||
|
break;
|
||||||
|
case 'move-all':
|
||||||
|
window.moveManager.showMoveModal('bulk');
|
||||||
|
break;
|
||||||
|
case 'auto-organize':
|
||||||
|
bulkManager.autoOrganizeSelectedModels();
|
||||||
|
break;
|
||||||
|
case 'delete-all':
|
||||||
|
bulkManager.showBulkDeleteModal();
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
bulkManager.clearSelection();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown bulk action: ${action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,10 @@ export const ModelContextMenuMixin = {
|
|||||||
try {
|
try {
|
||||||
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
||||||
|
|
||||||
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
|
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
||||||
this.nsfwSelector.style.display = 'none';
|
this.nsfwSelector.style.display = 'none';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Failed to set content rating: ${error.message}`, 'error');
|
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -147,7 +147,7 @@ export const ModelContextMenuMixin = {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('Model successfully re-linked to Civitai', 'success');
|
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
|
||||||
// Reload the current view to show updated data
|
// Reload the current view to show updated data
|
||||||
await this.resetAndReload();
|
await this.resetAndReload();
|
||||||
} else {
|
} else {
|
||||||
@@ -155,7 +155,7 @@ export const ModelContextMenuMixin = {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error re-linking model:', error);
|
console.error('Error re-linking model:', error);
|
||||||
showToast(`Error: ${error.message}`, 'error');
|
showToast('toast.contextMenu.relinkFailed', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
@@ -211,10 +211,10 @@ export const ModelContextMenuMixin = {
|
|||||||
if (this.currentCard.querySelector('.fa-globe')) {
|
if (this.currentCard.querySelector('.fa-globe')) {
|
||||||
this.currentCard.querySelector('.fa-globe').click();
|
this.currentCard.querySelector('.fa-globe').click();
|
||||||
} else {
|
} else {
|
||||||
showToast('Please fetch metadata from CivitAI first', 'info');
|
showToast('toast.contextMenu.fetchMetadataFirst', {}, 'info');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('No CivitAI information available', 'info');
|
showToast('toast.contextMenu.noCivitaiInfo', {}, 'info');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
case 'relink-civitai':
|
case 'relink-civitai':
|
||||||
@@ -232,7 +232,7 @@ export const ModelContextMenuMixin = {
|
|||||||
async downloadExampleImages() {
|
async downloadExampleImages() {
|
||||||
const modelHash = this.currentCard.dataset.sha256;
|
const modelHash = this.currentCard.dataset.sha256;
|
||||||
if (!modelHash) {
|
if (!modelHash) {
|
||||||
showToast('Model hash not available', 'error');
|
showToast('toast.contextMenu.missingHash', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
copyRecipeSyntax() {
|
copyRecipeSyntax() {
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot copy recipe: Missing recipe ID', 'error');
|
showToast('recipes.contextMenu.copyRecipe.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to copy recipe syntax: ', err);
|
console.error('Failed to copy recipe syntax: ', err);
|
||||||
showToast('Failed to copy recipe syntax', 'error');
|
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
sendRecipeToWorkflow(replaceMode) {
|
sendRecipeToWorkflow(replaceMode) {
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
showToast('recipes.contextMenu.sendRecipe.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,14 +137,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to send recipe to workflow: ', err);
|
console.error('Failed to send recipe to workflow: ', err);
|
||||||
showToast('Failed to send recipe to workflow', 'error');
|
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// View all LoRAs in the recipe
|
// View all LoRAs in the recipe
|
||||||
viewRecipeLoRAs(recipeId) {
|
viewRecipeLoRAs(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot view LoRAs: Missing recipe ID', 'error');
|
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,19 +171,19 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Navigate to the LoRAs page
|
// Navigate to the LoRAs page
|
||||||
window.location.href = '/loras';
|
window.location.href = '/loras';
|
||||||
} else {
|
} else {
|
||||||
showToast('No LoRAs found in this recipe', 'info');
|
showToast('recipes.contextMenu.viewLoras.noLorasFound', {}, 'info');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error loading recipe LoRAs:', error);
|
console.error('Error loading recipe LoRAs:', error);
|
||||||
showToast('Error loading recipe LoRAs: ' + error.message, 'error');
|
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download missing LoRAs
|
// Download missing LoRAs
|
||||||
async downloadMissingLoRAs(recipeId) {
|
async downloadMissingLoRAs(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot download LoRAs: Missing recipe ID', 'error');
|
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||||
|
|
||||||
if (missingLoras.length === 0) {
|
if (missingLoras.length === 0) {
|
||||||
showToast('No missing LoRAs to download', 'info');
|
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||||
|
|
||||||
if (validLoras.length === 0) {
|
if (validLoras.length === 0) {
|
||||||
showToast('Failed to get information for missing LoRAs', 'error');
|
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading missing LoRAs:', error);
|
console.error('Error downloading missing LoRAs:', error);
|
||||||
showToast('Error preparing LoRAs for download: ' + error.message, 'error');
|
showToast('recipes.contextMenu.downloadMissing.prepareError', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (state.loadingManager) {
|
if (state.loadingManager) {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
|
|||||||
@@ -2,4 +2,25 @@ export { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
|
||||||
|
import { LoraContextMenu } from './LoraContextMenu.js';
|
||||||
|
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
|
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
|
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
|
|
||||||
|
// Factory method to create page-specific context menu instances
|
||||||
|
export function createPageContextMenu(pageType) {
|
||||||
|
switch (pageType) {
|
||||||
|
case 'loras':
|
||||||
|
return new LoraContextMenu();
|
||||||
|
case 'recipes':
|
||||||
|
return new RecipeContextMenu();
|
||||||
|
case 'checkpoints':
|
||||||
|
return new CheckpointContextMenu();
|
||||||
|
case 'embeddings':
|
||||||
|
return new EmbeddingContextMenu();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export class DuplicatesManager {
|
|||||||
this.duplicateGroups = data.duplicate_groups || [];
|
this.duplicateGroups = data.duplicate_groups || [];
|
||||||
|
|
||||||
if (this.duplicateGroups.length === 0) {
|
if (this.duplicateGroups.length === 0) {
|
||||||
showToast('No duplicate recipes found', 'info');
|
showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export class DuplicatesManager {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error finding duplicates:', error);
|
console.error('Error finding duplicates:', error);
|
||||||
showToast('Failed to find duplicates: ' + error.message, 'error');
|
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,7 +325,7 @@ export class DuplicatesManager {
|
|||||||
|
|
||||||
async deleteSelectedDuplicates() {
|
async deleteSelectedDuplicates() {
|
||||||
if (this.selectedForDeletion.size === 0) {
|
if (this.selectedForDeletion.size === 0) {
|
||||||
showToast('No recipes selected for deletion', 'info');
|
showToast('toast.duplicates.noItemsSelected', { type: 'recipes' }, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ export class DuplicatesManager {
|
|||||||
modalManager.showModal('duplicateDeleteModal');
|
modalManager.showModal('duplicateDeleteModal');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preparing delete:', error);
|
console.error('Error preparing delete:', error);
|
||||||
showToast('Error: ' + error.message, 'error');
|
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ export class DuplicatesManager {
|
|||||||
throw new Error(data.error || 'Unknown error deleting recipes');
|
throw new Error(data.error || 'Unknown error deleting recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`Successfully deleted ${data.total_deleted} recipes`, 'success');
|
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
|
||||||
|
|
||||||
// Exit duplicate mode if deletions were successful
|
// Exit duplicate mode if deletions were successful
|
||||||
if (data.total_deleted > 0) {
|
if (data.total_deleted > 0) {
|
||||||
@@ -380,7 +380,7 @@ export class DuplicatesManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting recipes:', error);
|
console.error('Error deleting recipes:', error);
|
||||||
showToast('Failed to delete recipes: ' + error.message, 'error');
|
showToast('toast.duplicates.deleteFailed', { type: 'recipes', message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { toggleTheme } from '../utils/uiHelpers.js';
|
|||||||
import { SearchManager } from '../managers/SearchManager.js';
|
import { SearchManager } from '../managers/SearchManager.js';
|
||||||
import { FilterManager } from '../managers/FilterManager.js';
|
import { FilterManager } from '../managers/FilterManager.js';
|
||||||
import { initPageState } from '../state/index.js';
|
import { initPageState } from '../state/index.js';
|
||||||
|
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
import { updateElementAttribute } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header.js - Manages the application header behavior across different pages
|
* Header.js - Manages the application header behavior across different pages
|
||||||
@@ -47,21 +49,17 @@ export class HeaderManager {
|
|||||||
// Handle theme toggle
|
// Handle theme toggle
|
||||||
const themeToggle = document.querySelector('.theme-toggle');
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
if (themeToggle) {
|
if (themeToggle) {
|
||||||
// Set initial state based on current theme
|
const currentTheme = getStorageItem('theme') || 'auto';
|
||||||
const currentTheme = localStorage.getItem('lm_theme') || 'auto';
|
|
||||||
themeToggle.classList.add(`theme-${currentTheme}`);
|
themeToggle.classList.add(`theme-${currentTheme}`);
|
||||||
|
|
||||||
themeToggle.addEventListener('click', () => {
|
// Use i18nHelpers to update themeToggle's title
|
||||||
|
this.updateThemeTooltip(themeToggle, currentTheme);
|
||||||
|
|
||||||
|
themeToggle.addEventListener('click', async () => {
|
||||||
if (typeof toggleTheme === 'function') {
|
if (typeof toggleTheme === 'function') {
|
||||||
const newTheme = toggleTheme();
|
const newTheme = toggleTheme();
|
||||||
// Update tooltip based on next toggle action
|
// Use i18nHelpers to update themeToggle's title
|
||||||
if (newTheme === 'light') {
|
this.updateThemeTooltip(themeToggle, newTheme);
|
||||||
themeToggle.title = "Switch to dark theme";
|
|
||||||
} else if (newTheme === 'dark') {
|
|
||||||
themeToggle.title = "Switch to auto theme";
|
|
||||||
} else {
|
|
||||||
themeToggle.title = "Switch to light theme";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,29 +123,43 @@ export class HeaderManager {
|
|||||||
// Hide search functionality on Statistics page
|
// Hide search functionality on Statistics page
|
||||||
this.updateHeaderForPage();
|
this.updateHeaderForPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHeaderForPage() {
|
updateHeaderForPage() {
|
||||||
const headerSearch = document.getElementById('headerSearch');
|
const headerSearch = document.getElementById('headerSearch');
|
||||||
|
const searchInput = headerSearch?.querySelector('#searchInput');
|
||||||
|
const searchButtons = headerSearch?.querySelectorAll('button');
|
||||||
|
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
|
||||||
|
|
||||||
if (this.currentPage === 'statistics' && headerSearch) {
|
if (this.currentPage === 'statistics' && headerSearch) {
|
||||||
headerSearch.classList.add('disabled');
|
headerSearch.classList.add('disabled');
|
||||||
// Disable search functionality
|
|
||||||
const searchInput = headerSearch.querySelector('#searchInput');
|
|
||||||
const searchButtons = headerSearch.querySelectorAll('button');
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.disabled = true;
|
searchInput.disabled = true;
|
||||||
searchInput.placeholder = 'Search not available on statistics page';
|
// Use i18nHelpers to update placeholder
|
||||||
|
updateElementAttribute(searchInput, 'placeholder', 'header.search.notAvailable', {}, 'Search not available on statistics page');
|
||||||
}
|
}
|
||||||
searchButtons.forEach(btn => btn.disabled = true);
|
searchButtons?.forEach(btn => btn.disabled = true);
|
||||||
} else if (headerSearch) {
|
} else if (headerSearch) {
|
||||||
headerSearch.classList.remove('disabled');
|
headerSearch.classList.remove('disabled');
|
||||||
// Re-enable search functionality
|
|
||||||
const searchInput = headerSearch.querySelector('#searchInput');
|
|
||||||
const searchButtons = headerSearch.querySelectorAll('button');
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.disabled = false;
|
searchInput.disabled = false;
|
||||||
|
// Use i18nHelpers to update placeholder
|
||||||
|
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
|
||||||
}
|
}
|
||||||
searchButtons.forEach(btn => btn.disabled = false);
|
searchButtons?.forEach(btn => btn.disabled = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateThemeTooltip(themeToggle, currentTheme) {
|
||||||
|
if (!themeToggle) return;
|
||||||
|
let key;
|
||||||
|
if (currentTheme === 'light') {
|
||||||
|
key = 'header.theme.switchToDark';
|
||||||
|
} else if (currentTheme === 'dark') {
|
||||||
|
key = 'header.theme.switchToLight';
|
||||||
|
} else {
|
||||||
|
key = 'header.theme.toggle';
|
||||||
|
}
|
||||||
|
// Use i18nHelpers to update title
|
||||||
|
updateElementAttribute(themeToggle, 'title', key, {}, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class ModelDuplicatesManager {
|
|||||||
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||||
|
|
||||||
if (this.duplicateGroups.length === 0) {
|
if (this.duplicateGroups.length === 0) {
|
||||||
showToast('No duplicate models found', 'info');
|
showToast('toast.duplicates.noDuplicatesFound', { type: this.modelType }, 'info');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ export class ModelDuplicatesManager {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error finding duplicates:', error);
|
console.error('Error finding duplicates:', error);
|
||||||
showToast('Failed to find duplicates: ' + error.message, 'error');
|
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,7 +594,7 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
async deleteSelectedDuplicates() {
|
async deleteSelectedDuplicates() {
|
||||||
if (this.selectedForDeletion.size === 0) {
|
if (this.selectedForDeletion.size === 0) {
|
||||||
showToast('No models selected for deletion', 'info');
|
showToast('toast.duplicates.noItemsSelected', { type: this.modelType }, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +609,7 @@ export class ModelDuplicatesManager {
|
|||||||
modalManager.showModal('modelDuplicateDeleteModal');
|
modalManager.showModal('modelDuplicateDeleteModal');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preparing delete:', error);
|
console.error('Error preparing delete:', error);
|
||||||
showToast('Error: ' + error.message, 'error');
|
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +640,7 @@ export class ModelDuplicatesManager {
|
|||||||
throw new Error(data.error || 'Unknown error deleting models');
|
throw new Error(data.error || 'Unknown error deleting models');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
|
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
|
||||||
|
|
||||||
// If models were successfully deleted
|
// If models were successfully deleted
|
||||||
if (data.total_deleted > 0) {
|
if (data.total_deleted > 0) {
|
||||||
@@ -678,7 +678,7 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting models:', error);
|
console.error('Error deleting models:', error);
|
||||||
showToast('Failed to delete models: ' + error.message, 'error');
|
showToast('toast.duplicates.deleteFailed', { type: this.modelType, message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,7 +745,7 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
// Check if already verified
|
// Check if already verified
|
||||||
if (this.verifiedGroups.has(groupHash)) {
|
if (this.verifiedGroups.has(groupHash)) {
|
||||||
showToast('This group has already been verified', 'info');
|
showToast('toast.models.verificationAlreadyDone', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,14 +793,14 @@ export class ModelDuplicatesManager {
|
|||||||
|
|
||||||
// Show appropriate toast message
|
// Show appropriate toast message
|
||||||
if (mismatchedFiles.length > 0) {
|
if (mismatchedFiles.length > 0) {
|
||||||
showToast(`Verification complete. ${mismatchedFiles.length} file(s) have different actual hashes.`, 'warning');
|
showToast('toast.models.verificationCompleteMismatch', { count: mismatchedFiles.length }, 'warning');
|
||||||
} else {
|
} else {
|
||||||
showToast('Verification complete. All files are confirmed duplicates.', 'success');
|
showToast('toast.models.verificationCompleteSuccess', {}, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying hashes:', error);
|
console.error('Error verifying hashes:', error);
|
||||||
showToast('Failed to verify hashes: ' + error.message, 'error');
|
showToast('toast.models.verificationFailed', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
// Hide loading state
|
// Hide loading state
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class RecipeCard {
|
|||||||
// Get recipe ID
|
// Get recipe ID
|
||||||
const recipeId = this.recipe.id;
|
const recipeId = this.recipe.id;
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
showToast('toast.recipes.cannotSend', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +214,11 @@ class RecipeCard {
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to send recipe to workflow: ', err);
|
console.error('Failed to send recipe to workflow: ', err);
|
||||||
showToast('Failed to send recipe to workflow', 'error');
|
showToast('toast.recipes.sendFailed', {}, 'error');
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending recipe to workflow:', error);
|
console.error('Error sending recipe to workflow:', error);
|
||||||
showToast('Error sending recipe to workflow', 'error');
|
showToast('toast.recipes.sendError', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ class RecipeCard {
|
|||||||
const recipeId = this.recipe.id;
|
const recipeId = this.recipe.id;
|
||||||
const filePath = this.recipe.file_path;
|
const filePath = this.recipe.file_path;
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ class RecipeCard {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error showing delete confirmation:', error);
|
console.error('Error showing delete confirmation:', error);
|
||||||
showToast('Error showing delete confirmation', 'error');
|
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ class RecipeCard {
|
|||||||
const recipeId = deleteModal.dataset.recipeId;
|
const recipeId = deleteModal.dataset.recipeId;
|
||||||
|
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||||
modalManager.closeModal('deleteModal');
|
modalManager.closeModal('deleteModal');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ class RecipeCard {
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
showToast('Recipe deleted successfully', 'success');
|
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||||
|
|
||||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ class RecipeCard {
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error deleting recipe:', error);
|
console.error('Error deleting recipe:', error);
|
||||||
showToast('Error deleting recipe: ' + error.message, 'error');
|
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||||
|
|
||||||
// Reset button state
|
// Reset button state
|
||||||
deleteBtn.textContent = originalText;
|
deleteBtn.textContent = originalText;
|
||||||
@@ -333,12 +333,12 @@ class RecipeCard {
|
|||||||
// Get recipe ID
|
// Get recipe ID
|
||||||
const recipeId = this.recipe.id;
|
const recipeId = this.recipe.id;
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot share recipe: Missing recipe ID', 'error');
|
showToast('toast.recipes.cannotShare', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
showToast('Preparing recipe for sharing...', 'info');
|
showToast('toast.recipes.preparingForSharing', {}, 'info');
|
||||||
|
|
||||||
// Call the API to process the image with metadata
|
// Call the API to process the image with metadata
|
||||||
fetch(`/api/recipe/${recipeId}/share`)
|
fetch(`/api/recipe/${recipeId}/share`)
|
||||||
@@ -363,15 +363,15 @@ class RecipeCard {
|
|||||||
downloadLink.click();
|
downloadLink.click();
|
||||||
document.body.removeChild(downloadLink);
|
document.body.removeChild(downloadLink);
|
||||||
|
|
||||||
showToast('Recipe download started', 'success');
|
showToast('toast.recipes.downloadStarted', {}, 'success');
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error sharing recipe:', error);
|
console.error('Error sharing recipe:', error);
|
||||||
showToast('Error sharing recipe: ' + error.message, 'error');
|
showToast('toast.recipes.shareError', { message: error.message }, 'error');
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sharing recipe:', error);
|
console.error('Error sharing recipe:', error);
|
||||||
showToast('Error preparing recipe for sharing', 'error');
|
showToast('toast.recipes.sharePreparationError', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ class RecipeModal {
|
|||||||
updateRecipeMetadata(this.filePath, { title: newTitle })
|
updateRecipeMetadata(this.filePath, { title: newTitle })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('Recipe name updated successfully', 'success');
|
showToast('toast.recipes.nameUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.title = newTitle;
|
this.currentRecipe.title = newTitle;
|
||||||
@@ -596,7 +596,7 @@ class RecipeModal {
|
|||||||
updateRecipeMetadata(this.filePath, { tags: newTags })
|
updateRecipeMetadata(this.filePath, { tags: newTags })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('Recipe tags updated successfully', 'success');
|
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.tags = newTags;
|
this.currentRecipe.tags = newTags;
|
||||||
@@ -717,7 +717,7 @@ class RecipeModal {
|
|||||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('Source URL updated successfully', 'success');
|
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update source URL in the UI
|
// Update source URL in the UI
|
||||||
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
||||||
@@ -778,7 +778,7 @@ class RecipeModal {
|
|||||||
// Fetch recipe syntax from backend and copy to clipboard
|
// Fetch recipe syntax from backend and copy to clipboard
|
||||||
async fetchAndCopyRecipeSyntax() {
|
async fetchAndCopyRecipeSyntax() {
|
||||||
if (!this.recipeId) {
|
if (!this.recipeId) {
|
||||||
showToast('No recipe ID available', 'error');
|
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +800,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching recipe syntax:', error);
|
console.error('Error fetching recipe syntax:', error);
|
||||||
showToast(`Error copying recipe syntax: ${error.message}`, 'error');
|
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,7 +817,7 @@ class RecipeModal {
|
|||||||
console.log("missingLoras", missingLoras);
|
console.log("missingLoras", missingLoras);
|
||||||
|
|
||||||
if (missingLoras.length === 0) {
|
if (missingLoras.length === 0) {
|
||||||
showToast('No missing LoRAs to download', 'info');
|
showToast('toast.recipes.noMissingLoras', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +856,7 @@ class RecipeModal {
|
|||||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||||
|
|
||||||
if (validLoras.length === 0) {
|
if (validLoras.length === 0) {
|
||||||
showToast('Failed to get information for missing LoRAs', 'error');
|
showToast('toast.recipes.missingLorasInfoFailed', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,7 +902,7 @@ class RecipeModal {
|
|||||||
window.importManager.downloadMissingLoras(recipeData, this.currentRecipe.id);
|
window.importManager.downloadMissingLoras(recipeData, this.currentRecipe.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error downloading missing LoRAs:", error);
|
console.error("Error downloading missing LoRAs:", error);
|
||||||
showToast('Error preparing LoRAs for download', 'error');
|
showToast('toast.recipes.preparingForDownloadFailed', {}, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
@@ -988,7 +988,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
async reconnectLora(loraIndex, inputValue) {
|
async reconnectLora(loraIndex, inputValue) {
|
||||||
if (!inputValue || !inputValue.trim()) {
|
if (!inputValue || !inputValue.trim()) {
|
||||||
showToast('Please enter a LoRA name or syntax', 'error');
|
showToast('toast.recipes.enterLoraName', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,7 +1026,7 @@ class RecipeModal {
|
|||||||
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
showToast('LoRA reconnected successfully', 'success');
|
showToast('toast.recipes.reconnectedSuccessfully', {}, 'success');
|
||||||
|
|
||||||
// Refresh modal to show updated content
|
// Refresh modal to show updated content
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1037,11 +1037,11 @@ class RecipeModal {
|
|||||||
loras: this.currentRecipe.loras
|
loras: this.currentRecipe.loras
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
showToast(`Error: ${result.error}`, 'error');
|
showToast('toast.recipes.reconnectFailed', { message: result.error }, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reconnecting LoRA:', error);
|
console.error('Error reconnecting LoRA:', error);
|
||||||
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
|
showToast('toast.recipes.reconnectFailed', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
977
static/js/components/SidebarManager.js
Normal file
977
static/js/components/SidebarManager.js
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
/**
|
||||||
|
* SidebarManager - Manages hierarchical folder navigation sidebar
|
||||||
|
*/
|
||||||
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
export class SidebarManager {
|
||||||
|
constructor() {
|
||||||
|
this.pageControls = null;
|
||||||
|
this.pageType = null;
|
||||||
|
this.treeData = {};
|
||||||
|
this.selectedPath = '';
|
||||||
|
this.expandedNodes = new Set();
|
||||||
|
this.isVisible = true;
|
||||||
|
this.isPinned = false;
|
||||||
|
this.apiClient = null;
|
||||||
|
this.openDropdown = null;
|
||||||
|
this.hoverTimeout = null;
|
||||||
|
this.isHovering = false;
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.displayMode = 'tree'; // 'tree' or 'list'
|
||||||
|
this.foldersList = [];
|
||||||
|
|
||||||
|
// Bind methods
|
||||||
|
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||||
|
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
||||||
|
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||||
|
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
||||||
|
this.handlePinToggle = this.handlePinToggle.bind(this);
|
||||||
|
this.handleCollapseAll = this.handleCollapseAll.bind(this);
|
||||||
|
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||||
|
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||||
|
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
|
||||||
|
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
|
||||||
|
this.updateContainerMargin = this.updateContainerMargin.bind(this);
|
||||||
|
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
|
||||||
|
this.handleFolderListClick = this.handleFolderListClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(pageControls) {
|
||||||
|
// Clean up previous initialization if exists
|
||||||
|
if (this.isInitialized) {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageControls = pageControls;
|
||||||
|
this.pageType = pageControls.pageType;
|
||||||
|
this.apiClient = getModelApiClient();
|
||||||
|
|
||||||
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
|
this.setInitialSidebarState();
|
||||||
|
|
||||||
|
this.setupEventHandlers();
|
||||||
|
this.updateSidebarTitle();
|
||||||
|
this.restoreSidebarState();
|
||||||
|
await this.loadFolderTree();
|
||||||
|
this.restoreSelectedFolder();
|
||||||
|
|
||||||
|
// Apply final state with animation after everything is loaded
|
||||||
|
this.applyFinalSidebarState();
|
||||||
|
|
||||||
|
// Update container margin based on initial sidebar state
|
||||||
|
this.updateContainerMargin();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log(`SidebarManager initialized for ${this.pageType} page`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
|
// Clear any pending timeouts
|
||||||
|
if (this.hoverTimeout) {
|
||||||
|
clearTimeout(this.hoverTimeout);
|
||||||
|
this.hoverTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up event handlers
|
||||||
|
this.removeEventHandlers();
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.pageControls = null;
|
||||||
|
this.pageType = null;
|
||||||
|
this.treeData = {};
|
||||||
|
this.selectedPath = '';
|
||||||
|
this.expandedNodes = new Set();
|
||||||
|
this.openDropdown = null;
|
||||||
|
this.isHovering = false;
|
||||||
|
this.apiClient = null;
|
||||||
|
this.isInitialized = false;
|
||||||
|
|
||||||
|
// Reset container margin
|
||||||
|
const container = document.querySelector('.container');
|
||||||
|
if (container) {
|
||||||
|
container.style.marginLeft = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove resize event listener
|
||||||
|
window.removeEventListener('resize', this.updateContainerMargin);
|
||||||
|
|
||||||
|
console.log('SidebarManager cleaned up');
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventHandlers() {
|
||||||
|
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
||||||
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||||
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||||
|
|
||||||
|
if (pinToggleBtn) {
|
||||||
|
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
|
||||||
|
}
|
||||||
|
if (collapseAllBtn) {
|
||||||
|
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
|
||||||
|
}
|
||||||
|
if (folderTree) {
|
||||||
|
folderTree.removeEventListener('click', this.handleTreeClick);
|
||||||
|
}
|
||||||
|
if (sidebarBreadcrumbNav) {
|
||||||
|
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
||||||
|
}
|
||||||
|
if (sidebarHeader) {
|
||||||
|
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
|
||||||
|
}
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||||
|
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
|
||||||
|
}
|
||||||
|
if (hoverArea) {
|
||||||
|
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
|
||||||
|
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove document click handler
|
||||||
|
document.removeEventListener('click', this.handleDocumentClick);
|
||||||
|
|
||||||
|
// Remove resize event handler
|
||||||
|
window.removeEventListener('resize', this.updateContainerMargin);
|
||||||
|
|
||||||
|
if (displayModeToggleBtn) {
|
||||||
|
displayModeToggleBtn.removeEventListener('click', this.handleDisplayModeToggle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.apiClient = getModelApiClient();
|
||||||
|
|
||||||
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
|
this.setInitialSidebarState();
|
||||||
|
|
||||||
|
this.setupEventHandlers();
|
||||||
|
this.updateSidebarTitle();
|
||||||
|
this.restoreSidebarState();
|
||||||
|
await this.loadFolderTree();
|
||||||
|
this.restoreSelectedFolder();
|
||||||
|
|
||||||
|
// Apply final state with animation after everything is loaded
|
||||||
|
this.applyFinalSidebarState();
|
||||||
|
|
||||||
|
// Update container margin based on initial sidebar state
|
||||||
|
this.updateContainerMargin();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialSidebarState() {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
|
||||||
|
if (!sidebar || !hoverArea) return;
|
||||||
|
|
||||||
|
// Get stored pin state
|
||||||
|
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
||||||
|
this.isPinned = isPinned;
|
||||||
|
|
||||||
|
// Sidebar starts hidden by default (CSS handles this)
|
||||||
|
// Just set up the hover area state
|
||||||
|
if (window.innerWidth <= 1024) {
|
||||||
|
hoverArea.classList.add('disabled');
|
||||||
|
} else if (this.isPinned) {
|
||||||
|
hoverArea.classList.add('disabled');
|
||||||
|
} else {
|
||||||
|
hoverArea.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFinalSidebarState() {
|
||||||
|
// Use requestAnimationFrame to ensure DOM is ready
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.updateAutoHideState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSidebarTitle() {
|
||||||
|
const sidebarTitle = document.getElementById('sidebarTitle');
|
||||||
|
if (sidebarTitle) {
|
||||||
|
sidebarTitle.textContent = `${this.apiClient.apiConfig.config.displayName} Root`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventHandlers() {
|
||||||
|
// Sidebar header (root selection) - only trigger on title area
|
||||||
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||||
|
if (sidebarHeader) {
|
||||||
|
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin toggle button
|
||||||
|
const pinToggleBtn = document.getElementById('sidebarPinToggle');
|
||||||
|
if (pinToggleBtn) {
|
||||||
|
pinToggleBtn.addEventListener('click', this.handlePinToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse all button
|
||||||
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
|
if (collapseAllBtn) {
|
||||||
|
collapseAllBtn.addEventListener('click', this.handleCollapseAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree click handler
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
if (folderTree) {
|
||||||
|
folderTree.addEventListener('click', this.handleTreeClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumb click handler
|
||||||
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||||
|
if (sidebarBreadcrumbNav) {
|
||||||
|
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover detection for auto-hide
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
|
||||||
|
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverArea) {
|
||||||
|
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
|
||||||
|
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar when clicking outside on mobile
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (window.innerWidth <= 1024 && this.isVisible) {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
|
||||||
|
if (sidebar && !sidebar.contains(e.target)) {
|
||||||
|
this.hideSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.updateAutoHideState();
|
||||||
|
this.updateContainerMargin();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add document click handler for closing dropdowns
|
||||||
|
document.addEventListener('click', this.handleDocumentClick);
|
||||||
|
|
||||||
|
// Add dedicated resize listener for container margin updates
|
||||||
|
window.addEventListener('resize', this.updateContainerMargin);
|
||||||
|
|
||||||
|
// Display mode toggle button
|
||||||
|
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||||
|
if (displayModeToggleBtn) {
|
||||||
|
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDocumentClick(event) {
|
||||||
|
// Close open dropdown when clicking outside
|
||||||
|
if (this.openDropdown && !event.target.closest('.breadcrumb-dropdown')) {
|
||||||
|
this.closeDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSidebarHeaderClick(event) {
|
||||||
|
// Only trigger root selection if clicking on the title area, not the buttons
|
||||||
|
if (!event.target.closest('.sidebar-header-actions')) {
|
||||||
|
this.selectFolder(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePinToggle(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.isPinned = !this.isPinned;
|
||||||
|
this.updateAutoHideState();
|
||||||
|
this.updatePinButton();
|
||||||
|
this.saveSidebarState();
|
||||||
|
this.updateContainerMargin();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCollapseAll(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.expandedNodes.clear();
|
||||||
|
this.renderFolderDisplay();
|
||||||
|
this.saveExpandedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseEnter() {
|
||||||
|
this.isHovering = true;
|
||||||
|
if (this.hoverTimeout) {
|
||||||
|
clearTimeout(this.hoverTimeout);
|
||||||
|
this.hoverTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isPinned) {
|
||||||
|
this.showSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave() {
|
||||||
|
this.isHovering = false;
|
||||||
|
if (!this.isPinned) {
|
||||||
|
this.hoverTimeout = setTimeout(() => {
|
||||||
|
if (!this.isHovering) {
|
||||||
|
this.hideSidebar();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHoverAreaEnter() {
|
||||||
|
if (!this.isPinned) {
|
||||||
|
this.showSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHoverAreaLeave() {
|
||||||
|
// Let the sidebar's mouse leave handler deal with hiding
|
||||||
|
}
|
||||||
|
|
||||||
|
showSidebar() {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
if (sidebar && !this.isPinned) {
|
||||||
|
sidebar.classList.add('hover-active');
|
||||||
|
this.isVisible = true;
|
||||||
|
this.updateContainerMargin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSidebar() {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
if (sidebar && !this.isPinned) {
|
||||||
|
sidebar.classList.remove('hover-active');
|
||||||
|
this.isVisible = false;
|
||||||
|
this.updateContainerMargin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAutoHideState() {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
|
||||||
|
if (!sidebar || !hoverArea) return;
|
||||||
|
|
||||||
|
if (window.innerWidth <= 1024) {
|
||||||
|
// Mobile: always use collapsed state
|
||||||
|
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
hoverArea.classList.add('disabled');
|
||||||
|
this.isVisible = false;
|
||||||
|
} else if (this.isPinned) {
|
||||||
|
// Desktop pinned: always visible
|
||||||
|
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
|
||||||
|
sidebar.classList.add('visible');
|
||||||
|
hoverArea.classList.add('disabled');
|
||||||
|
this.isVisible = true;
|
||||||
|
} else {
|
||||||
|
// Desktop auto-hide: use hover detection
|
||||||
|
sidebar.classList.remove('collapsed', 'visible');
|
||||||
|
sidebar.classList.add('auto-hide');
|
||||||
|
hoverArea.classList.remove('disabled');
|
||||||
|
|
||||||
|
if (this.isHovering) {
|
||||||
|
sidebar.classList.add('hover-active');
|
||||||
|
this.isVisible = true;
|
||||||
|
} else {
|
||||||
|
sidebar.classList.remove('hover-active');
|
||||||
|
this.isVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update container margin when sidebar state changes
|
||||||
|
this.updateContainerMargin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// New method to update container margin based on sidebar state
|
||||||
|
updateContainerMargin() {
|
||||||
|
const container = document.querySelector('.container');
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
|
||||||
|
if (!container || !sidebar) return;
|
||||||
|
|
||||||
|
// Reset margin to default
|
||||||
|
container.style.marginLeft = '';
|
||||||
|
|
||||||
|
// Only adjust margin if sidebar is visible and pinned
|
||||||
|
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
||||||
|
const sidebarWidth = sidebar.offsetWidth;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const containerWidth = container.offsetWidth;
|
||||||
|
|
||||||
|
// Check if there's enough space for both sidebar and container
|
||||||
|
// We need: sidebar width + container width + some padding < viewport width
|
||||||
|
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
||||||
|
// Not enough space, push container to the right
|
||||||
|
container.style.marginLeft = `${sidebarWidth + 10}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePinButton() {
|
||||||
|
const pinBtn = document.getElementById('sidebarPinToggle');
|
||||||
|
if (pinBtn) {
|
||||||
|
pinBtn.classList.toggle('active', this.isPinned);
|
||||||
|
pinBtn.title = this.isPinned
|
||||||
|
? translate('sidebar.unpinSidebar')
|
||||||
|
: translate('sidebar.pinSidebar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFolderTree() {
|
||||||
|
try {
|
||||||
|
if (this.displayMode === 'tree') {
|
||||||
|
const response = await this.apiClient.fetchUnifiedFolderTree();
|
||||||
|
this.treeData = response.tree || {};
|
||||||
|
} else {
|
||||||
|
const response = await this.apiClient.fetchModelFolders();
|
||||||
|
this.foldersList = response.folders || [];
|
||||||
|
}
|
||||||
|
this.renderFolderDisplay();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folder data:', error);
|
||||||
|
this.renderEmptyState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFolderDisplay() {
|
||||||
|
if (this.displayMode === 'tree') {
|
||||||
|
this.renderTree();
|
||||||
|
} else {
|
||||||
|
this.renderFolderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTree() {
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
if (!folderTree) return;
|
||||||
|
|
||||||
|
if (!this.treeData || Object.keys(this.treeData).length === 0) {
|
||||||
|
this.renderEmptyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTreeNode(nodeData, basePath) {
|
||||||
|
const entries = Object.entries(nodeData);
|
||||||
|
if (entries.length === 0) return '';
|
||||||
|
|
||||||
|
return entries.map(([folderName, children]) => {
|
||||||
|
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
|
||||||
|
const hasChildren = Object.keys(children).length > 0;
|
||||||
|
const isExpanded = this.expandedNodes.has(currentPath);
|
||||||
|
const isSelected = this.selectedPath === currentPath;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="sidebar-tree-node" data-path="${currentPath}">
|
||||||
|
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}">
|
||||||
|
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
|
||||||
|
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
|
||||||
|
<div class="sidebar-tree-folder-name" title="${folderName}">${folderName}</div>
|
||||||
|
</div>
|
||||||
|
${hasChildren ? `
|
||||||
|
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
|
||||||
|
${this.renderTreeNode(children, currentPath)}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEmptyState() {
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
if (!folderTree) return;
|
||||||
|
|
||||||
|
folderTree.innerHTML = `
|
||||||
|
<div class="sidebar-tree-placeholder">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<div>No folders found</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFolderList() {
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
if (!folderTree) return;
|
||||||
|
|
||||||
|
if (!this.foldersList || this.foldersList.length === 0) {
|
||||||
|
this.renderEmptyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foldersHtml = this.foldersList.map(folder => {
|
||||||
|
const displayName = folder === '' ? '/' : folder;
|
||||||
|
const isSelected = this.selectedPath === folder;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
||||||
|
<div class="sidebar-node-content">
|
||||||
|
<i class="fas fa-folder sidebar-folder-icon"></i>
|
||||||
|
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
folderTree.innerHTML = foldersHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTreeClick(event) {
|
||||||
|
if (this.displayMode === 'list') {
|
||||||
|
this.handleFolderListClick(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
|
||||||
|
const nodeContent = event.target.closest('.sidebar-tree-node-content');
|
||||||
|
|
||||||
|
if (expandIcon) {
|
||||||
|
// Toggle expand/collapse
|
||||||
|
const treeNode = expandIcon.closest('.sidebar-tree-node');
|
||||||
|
const path = treeNode.dataset.path;
|
||||||
|
const children = treeNode.querySelector('.sidebar-tree-children');
|
||||||
|
|
||||||
|
if (this.expandedNodes.has(path)) {
|
||||||
|
this.expandedNodes.delete(path);
|
||||||
|
expandIcon.classList.remove('expanded');
|
||||||
|
if (children) children.classList.remove('expanded');
|
||||||
|
} else {
|
||||||
|
this.expandedNodes.add(path);
|
||||||
|
expandIcon.classList.add('expanded');
|
||||||
|
if (children) children.classList.add('expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveExpandedState();
|
||||||
|
} else if (nodeContent) {
|
||||||
|
// Select folder
|
||||||
|
const treeNode = nodeContent.closest('.sidebar-tree-node');
|
||||||
|
const path = treeNode.dataset.path;
|
||||||
|
this.selectFolder(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBreadcrumbClick(event) {
|
||||||
|
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
||||||
|
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
||||||
|
|
||||||
|
if (dropdownItem) {
|
||||||
|
// Handle dropdown item selection
|
||||||
|
const path = dropdownItem.dataset.path || '';
|
||||||
|
this.selectFolder(path);
|
||||||
|
this.closeDropdown();
|
||||||
|
} else if (breadcrumbItem) {
|
||||||
|
// Handle breadcrumb item click
|
||||||
|
const path = breadcrumbItem.dataset.path || null; // null for showing all models
|
||||||
|
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
|
||||||
|
const isActive = breadcrumbItem.classList.contains('active');
|
||||||
|
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
|
||||||
|
|
||||||
|
if (isPlaceholder || (isActive && path === this.selectedPath)) {
|
||||||
|
// Open dropdown for placeholders or active items
|
||||||
|
// Close any open dropdown first
|
||||||
|
if (this.openDropdown && this.openDropdown !== dropdown) {
|
||||||
|
this.openDropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle current dropdown
|
||||||
|
dropdown.classList.toggle('open');
|
||||||
|
|
||||||
|
// Update open dropdown reference
|
||||||
|
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
|
||||||
|
} else {
|
||||||
|
// Navigate to the selected path
|
||||||
|
this.selectFolder(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDropdown() {
|
||||||
|
if (this.openDropdown) {
|
||||||
|
this.openDropdown.classList.remove('open');
|
||||||
|
this.openDropdown = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFolder(path) {
|
||||||
|
// Update selected path
|
||||||
|
this.selectedPath = path;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
this.updateTreeSelection();
|
||||||
|
this.updateBreadcrumbs();
|
||||||
|
this.updateSidebarHeader();
|
||||||
|
|
||||||
|
// Update page state
|
||||||
|
this.pageControls.pageState.activeFolder = path;
|
||||||
|
setStorageItem(`${this.pageType}_activeFolder`, path);
|
||||||
|
|
||||||
|
// Reload models with new filter
|
||||||
|
await this.pageControls.resetAndReload();
|
||||||
|
|
||||||
|
// Auto-hide sidebar on mobile after selection
|
||||||
|
if (window.innerWidth <= 1024) {
|
||||||
|
this.hideSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFolderListClick(event) {
|
||||||
|
const folderItem = event.target.closest('.sidebar-folder-item');
|
||||||
|
|
||||||
|
if (folderItem) {
|
||||||
|
const path = folderItem.dataset.path;
|
||||||
|
this.selectFolder(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisplayModeToggle(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.displayMode = this.displayMode === 'tree' ? 'list' : 'tree';
|
||||||
|
this.updateDisplayModeButton();
|
||||||
|
this.updateCollapseAllButton();
|
||||||
|
this.updateSearchRecursiveOption();
|
||||||
|
this.saveDisplayMode();
|
||||||
|
this.loadFolderTree(); // Reload with new display mode
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDisplayModeButton() {
|
||||||
|
const displayModeBtn = document.getElementById('sidebarDisplayModeToggle');
|
||||||
|
if (displayModeBtn) {
|
||||||
|
const icon = displayModeBtn.querySelector('i');
|
||||||
|
if (this.displayMode === 'tree') {
|
||||||
|
icon.className = 'fas fa-sitemap';
|
||||||
|
displayModeBtn.title = translate('sidebar.switchToListView');
|
||||||
|
} else {
|
||||||
|
icon.className = 'fas fa-list';
|
||||||
|
displayModeBtn.title = translate('sidebar.switchToTreeView');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCollapseAllButton() {
|
||||||
|
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
|
||||||
|
if (collapseAllBtn) {
|
||||||
|
if (this.displayMode === 'list') {
|
||||||
|
collapseAllBtn.disabled = true;
|
||||||
|
collapseAllBtn.classList.add('disabled');
|
||||||
|
collapseAllBtn.title = translate('sidebar.collapseAllDisabled');
|
||||||
|
} else {
|
||||||
|
collapseAllBtn.disabled = false;
|
||||||
|
collapseAllBtn.classList.remove('disabled');
|
||||||
|
collapseAllBtn.title = translate('sidebar.collapseAll');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSearchRecursiveOption() {
|
||||||
|
this.pageControls.pageState.searchOptions.recursive = this.displayMode === 'tree';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTreeSelection() {
|
||||||
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
|
if (!folderTree) return;
|
||||||
|
|
||||||
|
if (this.displayMode === 'list') {
|
||||||
|
// Remove all selections in list mode
|
||||||
|
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add selection to current path
|
||||||
|
if (this.selectedPath !== null) {
|
||||||
|
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||||
|
if (selectedItem) {
|
||||||
|
selectedItem.classList.add('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
|
||||||
|
node.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.selectedPath) {
|
||||||
|
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
||||||
|
if (selectedNode) {
|
||||||
|
selectedNode.classList.add('selected');
|
||||||
|
this.expandPathParents(this.selectedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expandPathParents(path) {
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
const parts = path.split('/');
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||||
|
this.expandedNodes.add(currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sibling folders for a given path level
|
||||||
|
getSiblingFolders(pathParts, level) {
|
||||||
|
if (level === 0) {
|
||||||
|
// Root level siblings are top-level folders
|
||||||
|
return Object.keys(this.treeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the parent folder to get siblings
|
||||||
|
let currentNode = this.treeData;
|
||||||
|
for (let i = 0; i < level; i++) {
|
||||||
|
if (!currentNode[pathParts[i]]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
currentNode = currentNode[pathParts[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(currentNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get child folders for a given path
|
||||||
|
getChildFolders(path) {
|
||||||
|
if (!path) {
|
||||||
|
return Object.keys(this.treeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = path.split('/');
|
||||||
|
let currentNode = this.treeData;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!currentNode[part]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
currentNode = currentNode[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(currentNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBreadcrumbs() {
|
||||||
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||||
|
if (!sidebarBreadcrumbNav) return;
|
||||||
|
|
||||||
|
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
// Start with root breadcrumb
|
||||||
|
const rootSiblings = Object.keys(this.treeData);
|
||||||
|
const breadcrumbs = [`
|
||||||
|
<div class="breadcrumb-dropdown">
|
||||||
|
<span class="sidebar-breadcrumb-item ${this.selectedPath == null ? 'active' : ''}" data-path="">
|
||||||
|
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`];
|
||||||
|
|
||||||
|
// Add separator and placeholder for next level if we're at root
|
||||||
|
if (!this.selectedPath) {
|
||||||
|
const nextLevelFolders = rootSiblings;
|
||||||
|
if (nextLevelFolders.length > 0) {
|
||||||
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||||
|
breadcrumbs.push(`
|
||||||
|
<div class="breadcrumb-dropdown">
|
||||||
|
<span class="sidebar-breadcrumb-item placeholder">
|
||||||
|
--
|
||||||
|
<span class="breadcrumb-dropdown-indicator">
|
||||||
|
<i class="fas fa-caret-down"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div class="breadcrumb-dropdown-menu">
|
||||||
|
${nextLevelFolders.map(folder => `
|
||||||
|
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
||||||
|
${folder}
|
||||||
|
</div>`).join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add breadcrumb items for each path segment
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||||
|
const isLast = index === parts.length - 1;
|
||||||
|
|
||||||
|
// Get siblings for this level
|
||||||
|
const siblings = this.getSiblingFolders(parts, index);
|
||||||
|
|
||||||
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||||
|
breadcrumbs.push(`
|
||||||
|
<div class="breadcrumb-dropdown">
|
||||||
|
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
|
||||||
|
${part}
|
||||||
|
${siblings.length > 1 ? `
|
||||||
|
<span class="breadcrumb-dropdown-indicator">
|
||||||
|
<i class="fas fa-caret-down"></i>
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
</span>
|
||||||
|
${siblings.length > 1 ? `
|
||||||
|
<div class="breadcrumb-dropdown-menu">
|
||||||
|
${siblings.map(folder => `
|
||||||
|
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||||
|
data-path="${currentPath.replace(part, folder)}">
|
||||||
|
${folder}
|
||||||
|
</div>`).join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add separator and placeholder for next level if not the last item
|
||||||
|
if (isLast) {
|
||||||
|
const childFolders = this.getChildFolders(currentPath);
|
||||||
|
if (childFolders.length > 0) {
|
||||||
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||||
|
breadcrumbs.push(`
|
||||||
|
<div class="breadcrumb-dropdown">
|
||||||
|
<span class="sidebar-breadcrumb-item placeholder">
|
||||||
|
--
|
||||||
|
<span class="breadcrumb-dropdown-indicator">
|
||||||
|
<i class="fas fa-caret-down"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div class="breadcrumb-dropdown-menu">
|
||||||
|
${childFolders.map(folder => `
|
||||||
|
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
||||||
|
${folder}
|
||||||
|
</div>`).join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSidebarHeader() {
|
||||||
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||||
|
if (!sidebarHeader) return;
|
||||||
|
|
||||||
|
if (this.selectedPath == null) {
|
||||||
|
sidebarHeader.classList.add('root-selected');
|
||||||
|
} else {
|
||||||
|
sidebarHeader.classList.remove('root-selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
||||||
|
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
this.isVisible = !this.isVisible;
|
||||||
|
|
||||||
|
if (this.isVisible) {
|
||||||
|
sidebar.classList.remove('collapsed');
|
||||||
|
sidebar.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
sidebar.classList.remove('visible');
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.classList.toggle('active', this.isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveSidebarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSidebar() {
|
||||||
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
||||||
|
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
this.isVisible = false;
|
||||||
|
sidebar.classList.remove('visible');
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveSidebarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSidebarState() {
|
||||||
|
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
||||||
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||||
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||||
|
|
||||||
|
this.isPinned = isPinned;
|
||||||
|
this.expandedNodes = new Set(expandedPaths);
|
||||||
|
this.displayMode = displayMode;
|
||||||
|
|
||||||
|
this.updatePinButton();
|
||||||
|
this.updateDisplayModeButton();
|
||||||
|
this.updateCollapseAllButton();
|
||||||
|
this.updateSearchRecursiveOption();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSelectedFolder() {
|
||||||
|
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||||
|
if (activeFolder && typeof activeFolder === 'string') {
|
||||||
|
this.selectedPath = activeFolder;
|
||||||
|
this.updateTreeSelection();
|
||||||
|
this.updateBreadcrumbs();
|
||||||
|
this.updateSidebarHeader();
|
||||||
|
} else {
|
||||||
|
this.selectedPath = '';
|
||||||
|
this.updateSidebarHeader();
|
||||||
|
this.updateBreadcrumbs(); // Always update breadcrumbs
|
||||||
|
}
|
||||||
|
// Removed hidden class toggle since breadcrumbs are always visible now
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSidebarState() {
|
||||||
|
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveExpandedState() {
|
||||||
|
setStorageItem(`${this.pageType}_expandedNodes`, Array.from(this.expandedNodes));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDisplayMode() {
|
||||||
|
setStorageItem(`${this.pageType}_displayMode`, this.displayMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
await this.loadFolderTree();
|
||||||
|
this.restoreSelectedFolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export global instance
|
||||||
|
export const sidebarManager = new SidebarManager();
|
||||||
@@ -43,11 +43,19 @@ export class CheckpointsControls extends PageControls {
|
|||||||
showDownloadModal: () => {
|
showDownloadModal: () => {
|
||||||
downloadManager.showDownloadModal();
|
downloadManager.showDownloadModal();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleBulkMode: () => {
|
||||||
|
if (window.bulkManager) {
|
||||||
|
window.bulkManager.toggleBulkMode();
|
||||||
|
} else {
|
||||||
|
console.error('Bulk manager not available');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// No clearCustomFilter implementation is needed for checkpoints
|
// No clearCustomFilter implementation is needed for checkpoints
|
||||||
// as custom filters are currently only used for LoRAs
|
// as custom filters are currently only used for LoRAs
|
||||||
clearCustomFilter: async () => {
|
clearCustomFilter: async () => {
|
||||||
showToast('No custom filter to clear', 'info');
|
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,19 @@ export class EmbeddingsControls extends PageControls {
|
|||||||
showDownloadModal: () => {
|
showDownloadModal: () => {
|
||||||
downloadManager.showDownloadModal();
|
downloadManager.showDownloadModal();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleBulkMode: () => {
|
||||||
|
if (window.bulkManager) {
|
||||||
|
window.bulkManager.toggleBulkMode();
|
||||||
|
} else {
|
||||||
|
console.error('Bulk manager not available');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// No clearCustomFilter implementation is needed for embeddings
|
// No clearCustomFilter implementation is needed for embeddings
|
||||||
// as custom filters are currently only used for LoRAs
|
// as custom filters are currently only used for LoRAs
|
||||||
clearCustomFilter: async () => {
|
clearCustomFilter: async () => {
|
||||||
showToast('No custom filter to clear', 'info');
|
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { sidebarManager } from '../SidebarManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageControls class - Unified control management for model pages
|
* PageControls class - Unified control management for model pages
|
||||||
@@ -23,6 +24,9 @@ export class PageControls {
|
|||||||
// Store API methods
|
// Store API methods
|
||||||
this.api = null;
|
this.api = null;
|
||||||
|
|
||||||
|
// Use global sidebar manager
|
||||||
|
this.sidebarManager = sidebarManager;
|
||||||
|
|
||||||
// Initialize event listeners
|
// Initialize event listeners
|
||||||
this.initEventListeners();
|
this.initEventListeners();
|
||||||
|
|
||||||
@@ -55,6 +59,20 @@ export class PageControls {
|
|||||||
registerAPI(api) {
|
registerAPI(api) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
console.log(`API methods registered for ${this.pageType} page`);
|
console.log(`API methods registered for ${this.pageType} page`);
|
||||||
|
|
||||||
|
// Initialize sidebar manager after API is registered
|
||||||
|
this.initSidebarManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize sidebar manager
|
||||||
|
*/
|
||||||
|
async initSidebarManager() {
|
||||||
|
try {
|
||||||
|
await this.sidebarManager.initialize(this);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize SidebarManager:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,17 +90,6 @@ export class PageControls {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use event delegation for folder tags - this is the key fix
|
|
||||||
const folderTagsContainer = document.querySelector('.folder-tags-container');
|
|
||||||
if (folderTagsContainer) {
|
|
||||||
folderTagsContainer.addEventListener('click', (e) => {
|
|
||||||
const tag = e.target.closest('.tag');
|
|
||||||
if (tag) {
|
|
||||||
this.handleFolderClick(tag);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh button handler
|
// Refresh button handler
|
||||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
@@ -92,12 +99,6 @@ export class PageControls {
|
|||||||
// Initialize dropdown functionality
|
// Initialize dropdown functionality
|
||||||
this.initDropdowns();
|
this.initDropdowns();
|
||||||
|
|
||||||
// Toggle folders button
|
|
||||||
const toggleFoldersBtn = document.querySelector('.toggle-folders-btn');
|
|
||||||
if (toggleFoldersBtn) {
|
|
||||||
toggleFoldersBtn.addEventListener('click', () => this.toggleFolderTags());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear custom filter handler
|
// Clear custom filter handler
|
||||||
const clearFilterBtn = document.querySelector('.clear-filter');
|
const clearFilterBtn = document.querySelector('.clear-filter');
|
||||||
if (clearFilterBtn) {
|
if (clearFilterBtn) {
|
||||||
@@ -184,12 +185,9 @@ export class PageControls {
|
|||||||
duplicatesButton.addEventListener('click', () => this.findDuplicates());
|
duplicatesButton.addEventListener('click', () => this.findDuplicates());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pageType === 'loras') {
|
const bulkButton = document.querySelector('[data-action="bulk"]');
|
||||||
// Bulk operations button - LoRAs only
|
if (bulkButton) {
|
||||||
const bulkButton = document.querySelector('[data-action="bulk"]');
|
bulkButton.addEventListener('click', () => this.toggleBulkMode());
|
||||||
if (bulkButton) {
|
|
||||||
bulkButton.addEventListener('click', () => this.toggleBulkMode());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorites filter button handler
|
// Favorites filter button handler
|
||||||
@@ -199,130 +197,6 @@ export class PageControls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle folder selection
|
|
||||||
* @param {HTMLElement} tagElement - The folder tag element that was clicked
|
|
||||||
*/
|
|
||||||
handleFolderClick(tagElement) {
|
|
||||||
const folder = tagElement.dataset.folder;
|
|
||||||
const wasActive = tagElement.classList.contains('active');
|
|
||||||
|
|
||||||
document.querySelectorAll('.folder-tags .tag').forEach(t => {
|
|
||||||
t.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wasActive) {
|
|
||||||
tagElement.classList.add('active');
|
|
||||||
this.pageState.activeFolder = folder;
|
|
||||||
setStorageItem(`${this.pageType}_activeFolder`, folder);
|
|
||||||
} else {
|
|
||||||
this.pageState.activeFolder = null;
|
|
||||||
setStorageItem(`${this.pageType}_activeFolder`, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resetAndReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore folder filter from storage
|
|
||||||
*/
|
|
||||||
restoreFolderFilter() {
|
|
||||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
|
||||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
|
||||||
|
|
||||||
if (folderTag) {
|
|
||||||
folderTag.classList.add('active');
|
|
||||||
this.pageState.activeFolder = activeFolder;
|
|
||||||
this.filterByFolder(activeFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter displayed cards by folder
|
|
||||||
* @param {string} folderPath - Folder path to filter by
|
|
||||||
*/
|
|
||||||
filterByFolder(folderPath) {
|
|
||||||
const cardSelector = this.pageType === 'loras' ? '.model-card' : '.checkpoint-card';
|
|
||||||
document.querySelectorAll(cardSelector).forEach(card => {
|
|
||||||
card.style.display = card.dataset.folder === folderPath ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the folder tags display with new folder list
|
|
||||||
* @param {Array} folders - List of folder names
|
|
||||||
*/
|
|
||||||
updateFolderTags(folders) {
|
|
||||||
const folderTagsContainer = document.querySelector('.folder-tags');
|
|
||||||
if (!folderTagsContainer) return;
|
|
||||||
|
|
||||||
// Keep track of currently selected folder
|
|
||||||
const currentFolder = this.pageState.activeFolder;
|
|
||||||
|
|
||||||
// Create HTML for folder tags
|
|
||||||
const tagsHTML = folders.map(folder => {
|
|
||||||
const isActive = folder === currentFolder;
|
|
||||||
return `<div class="tag ${isActive ? 'active' : ''}" data-folder="${folder}">${folder}</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Update the container
|
|
||||||
folderTagsContainer.innerHTML = tagsHTML;
|
|
||||||
|
|
||||||
// Scroll active folder into view (no need to reattach click handlers)
|
|
||||||
const activeTag = folderTagsContainer.querySelector(`.tag[data-folder="${currentFolder}"]`);
|
|
||||||
if (activeTag) {
|
|
||||||
activeTag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle visibility of folder tags
|
|
||||||
*/
|
|
||||||
toggleFolderTags() {
|
|
||||||
const folderTags = document.querySelector('.folder-tags');
|
|
||||||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
|
||||||
|
|
||||||
if (folderTags) {
|
|
||||||
folderTags.classList.toggle('collapsed');
|
|
||||||
|
|
||||||
if (folderTags.classList.contains('collapsed')) {
|
|
||||||
// Change icon to indicate folders are hidden
|
|
||||||
toggleBtn.className = 'fas fa-folder-plus';
|
|
||||||
toggleBtn.parentElement.title = 'Show folder tags';
|
|
||||||
setStorageItem('folderTagsCollapsed', 'true');
|
|
||||||
} else {
|
|
||||||
// Change icon to indicate folders are visible
|
|
||||||
toggleBtn.className = 'fas fa-folder-minus';
|
|
||||||
toggleBtn.parentElement.title = 'Hide folder tags';
|
|
||||||
setStorageItem('folderTagsCollapsed', 'false');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize folder tags visibility based on stored preference
|
|
||||||
*/
|
|
||||||
initFolderTagsVisibility() {
|
|
||||||
const isCollapsed = getStorageItem('folderTagsCollapsed');
|
|
||||||
if (isCollapsed) {
|
|
||||||
const folderTags = document.querySelector('.folder-tags');
|
|
||||||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
|
||||||
if (folderTags) {
|
|
||||||
folderTags.classList.add('collapsed');
|
|
||||||
}
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.className = 'fas fa-folder-plus';
|
|
||||||
toggleBtn.parentElement.title = 'Show folder tags';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.className = 'fas fa-folder-minus';
|
|
||||||
toggleBtn.parentElement.title = 'Hide folder tags';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load sort preference from storage
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
@@ -408,9 +282,14 @@ export class PageControls {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.resetAndReload(updateFolders);
|
await this.api.resetAndReload(updateFolders);
|
||||||
|
|
||||||
|
// Refresh sidebar after reload if folders were updated
|
||||||
|
if (updateFolders && this.sidebarManager) {
|
||||||
|
await this.sidebarManager.refresh();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reloading ${this.pageType}:`, error);
|
console.error(`Error reloading ${this.pageType}:`, error);
|
||||||
showToast(`Failed to reload ${this.pageType}: ${error.message}`, 'error');
|
showToast('toast.controls.reloadFailed', { pageType: this.pageType, message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,9 +305,14 @@ export class PageControls {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.refreshModels(fullRebuild);
|
await this.api.refreshModels(fullRebuild);
|
||||||
|
|
||||||
|
// Refresh sidebar after rebuild
|
||||||
|
if (this.sidebarManager) {
|
||||||
|
await this.sidebarManager.refresh();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
||||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
|
showToast('toast.controls.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', pageType: this.pageType, message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.modelDuplicatesManager) {
|
if (window.modelDuplicatesManager) {
|
||||||
@@ -450,7 +334,7 @@ export class PageControls {
|
|||||||
await this.api.fetchFromCivitai();
|
await this.api.fetchFromCivitai();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
showToast('Failed to fetch metadata: ' + error.message, 'error');
|
showToast('toast.controls.fetchMetadataFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,14 +346,9 @@ export class PageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle bulk mode (LoRAs only)
|
* Toggle bulk mode
|
||||||
*/
|
*/
|
||||||
toggleBulkMode() {
|
toggleBulkMode() {
|
||||||
if (this.pageType !== 'loras' || !this.api) {
|
|
||||||
console.error('Bulk mode is only available for LoRAs');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.api.toggleBulkMode();
|
this.api.toggleBulkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +365,7 @@ export class PageControls {
|
|||||||
await this.api.clearCustomFilter();
|
await this.api.clearCustomFilter();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error clearing custom filter:', error);
|
console.error('Error clearing custom filter:', error);
|
||||||
showToast('Failed to clear custom filter: ' + error.message, 'error');
|
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,4 +426,15 @@ export class PageControls {
|
|||||||
console.error('Model duplicates manager not available');
|
console.error('Model duplicates manager not available');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Note: We don't destroy the global sidebar manager, just clean it up
|
||||||
|
// The global instance will be reused for other page controls
|
||||||
|
if (this.sidebarManager && this.sidebarManager.isInitialized) {
|
||||||
|
this.sidebarManager.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,48 +8,47 @@ import { NSFW_LEVELS } from '../../utils/constants.js';
|
|||||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
import { eventManager } from '../../utils/EventManager.js';
|
||||||
|
|
||||||
// Add global event delegation handlers
|
// Add global event delegation handlers using event manager
|
||||||
export function setupModelCardEventDelegation(modelType) {
|
export function setupModelCardEventDelegation(modelType) {
|
||||||
const gridElement = document.getElementById('modelGrid');
|
// Remove any existing handler first
|
||||||
if (!gridElement) return;
|
eventManager.removeHandler('click', 'modelCard-delegation');
|
||||||
|
|
||||||
// Remove any existing event listener to prevent duplication
|
// Register model card event delegation with event manager
|
||||||
gridElement.removeEventListener('click', gridElement._handleModelCardEvent);
|
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
|
||||||
|
return handleModelCardEvent_internal(event, modelType);
|
||||||
// Create event handler with modelType context
|
}, {
|
||||||
const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType);
|
priority: 60, // Medium priority for model card interactions
|
||||||
|
targetSelector: '#modelGrid',
|
||||||
// Add the event delegation handler
|
skipWhenModalOpen: false // Allow model card interactions even when modals are open (for some actions)
|
||||||
gridElement.addEventListener('click', handleModelCardEvent);
|
});
|
||||||
|
|
||||||
// Store reference to the handler for cleanup
|
|
||||||
gridElement._handleModelCardEvent = handleModelCardEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation handler for all model card events
|
// Event delegation handler for all model card events
|
||||||
function handleModelCardEvent_internal(event, modelType) {
|
function handleModelCardEvent_internal(event, modelType) {
|
||||||
// Find the closest card element
|
// Find the closest card element
|
||||||
const card = event.target.closest('.model-card');
|
const card = event.target.closest('.model-card');
|
||||||
if (!card) return;
|
if (!card) return false; // Continue with other handlers
|
||||||
|
|
||||||
// Handle specific elements within the card
|
// Handle specific elements within the card
|
||||||
if (event.target.closest('.toggle-blur-btn')) {
|
if (event.target.closest('.toggle-blur-btn')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleBlurContent(card);
|
toggleBlurContent(card);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.show-content-btn')) {
|
if (event.target.closest('.show-content-btn')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
showBlurredContent(card);
|
showBlurredContent(card);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-star')) {
|
if (event.target.closest('.fa-star')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleFavorite(card);
|
toggleFavorite(card);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-globe')) {
|
if (event.target.closest('.fa-globe')) {
|
||||||
@@ -57,41 +56,42 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
if (card.dataset.from_civitai === 'true') {
|
if (card.dataset.from_civitai === 'true') {
|
||||||
openCivitai(card.dataset.filepath);
|
openCivitai(card.dataset.filepath);
|
||||||
}
|
}
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-paper-plane')) {
|
if (event.target.closest('.fa-paper-plane')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleSendToWorkflow(card, event.shiftKey, modelType);
|
handleSendToWorkflow(card, event.shiftKey, modelType);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-copy')) {
|
if (event.target.closest('.fa-copy')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleCopyAction(card, modelType);
|
handleCopyAction(card, modelType);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-trash')) {
|
if (event.target.closest('.fa-trash')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
showDeleteModal(card.dataset.filepath);
|
showDeleteModal(card.dataset.filepath);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-image')) {
|
if (event.target.closest('.fa-image')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
getModelApiClient().replaceModelPreview(card.dataset.filepath);
|
getModelApiClient().replaceModelPreview(card.dataset.filepath);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-folder-open')) {
|
if (event.target.closest('.fa-folder-open')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleExampleImagesAccess(card, modelType);
|
handleExampleImagesAccess(card, modelType);
|
||||||
return;
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||||
handleCardClick(card, modelType);
|
handleCardClick(card, modelType);
|
||||||
|
return false; // Continue with other handlers (e.g., bulk selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for event handling
|
// Helper functions for event handling
|
||||||
@@ -142,13 +142,13 @@ async function toggleFavorite(card) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (newFavoriteState) {
|
if (newFavoriteState) {
|
||||||
showToast('Added to favorites', 'success');
|
showToast('modelCard.favorites.added', {}, 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast('Removed from favorites', 'success');
|
showToast('modelCard.favorites.removed', {}, 'success');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update favorite status:', error);
|
console.error('Failed to update favorite status:', error);
|
||||||
showToast('Failed to update favorite status', 'error');
|
showToast('modelCard.favorites.updateFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
|||||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||||
} else {
|
} else {
|
||||||
// Checkpoint send functionality - to be implemented
|
// Checkpoint send functionality - to be implemented
|
||||||
showToast('Send checkpoint to workflow - feature to be implemented', 'info');
|
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +170,8 @@ function handleCopyAction(card, modelType) {
|
|||||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||||
// Checkpoint copy functionality - copy checkpoint name
|
// Checkpoint copy functionality - copy checkpoint name
|
||||||
const checkpointName = card.dataset.file_name;
|
const checkpointName = card.dataset.file_name;
|
||||||
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
|
||||||
|
copyToClipboard(checkpointName, message);
|
||||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||||
const embeddingName = card.dataset.file_name;
|
const embeddingName = card.dataset.file_name;
|
||||||
copyToClipboard(embeddingName, 'Embedding name copied');
|
copyToClipboard(embeddingName, 'Embedding name copied');
|
||||||
@@ -195,7 +196,7 @@ async function handleExampleImagesAccess(card, modelType) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking for example images:', error);
|
console.error('Error checking for example images:', error);
|
||||||
showToast('Error checking for example images', 'error');
|
showToast('modelCard.exampleImages.checkError', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ function handleCardClick(card, modelType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showModelModalFromCard(card, modelType) {
|
async function showModelModalFromCard(card, modelType) {
|
||||||
// Get the appropriate preview versions map
|
// Get the appropriate preview versions map
|
||||||
const previewVersionsKey = modelType;
|
const previewVersionsKey = modelType;
|
||||||
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
|
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
|
||||||
@@ -246,7 +247,7 @@ function showModelModalFromCard(card, modelType) {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
showModelModal(modelMeta, modelType);
|
await showModelModal(modelMeta, modelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to show the example access modal (generalized for lora and checkpoint)
|
// Function to show the example access modal (generalized for lora and checkpoint)
|
||||||
@@ -277,7 +278,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
// Get the model hash
|
// Get the model hash
|
||||||
const modelHash = card.dataset.sha256;
|
const modelHash = card.dataset.sha256;
|
||||||
if (!modelHash) {
|
if (!modelHash) {
|
||||||
showToast('Missing model hash information.', 'error');
|
showToast('modelCard.exampleImages.missingHash', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +299,8 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
downloadBtn.classList.add('disabled');
|
downloadBtn.classList.add('disabled');
|
||||||
downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai');
|
const noRemoteImagesTitle = translate('modelCard.exampleImages.noRemoteImagesAvailable', {}, 'No remote example images available for this model on Civitai');
|
||||||
|
downloadBtn.setAttribute('title', noRemoteImagesTitle);
|
||||||
downloadBtn.onclick = null;
|
downloadBtn.onclick = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +308,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
// Set up import button
|
// Set up import button
|
||||||
const importBtn = modal.querySelector('#importExamplesBtn');
|
const importBtn = modal.querySelector('#importExamplesBtn');
|
||||||
if (importBtn) {
|
if (importBtn) {
|
||||||
importBtn.onclick = () => {
|
importBtn.onclick = async () => {
|
||||||
modalManager.closeModal('exampleAccessModal');
|
modalManager.closeModal('exampleAccessModal');
|
||||||
|
|
||||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||||
@@ -333,7 +335,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show the model modal
|
// Show the model modal
|
||||||
showModelModal(modelMeta, modelType);
|
await showModelModal(modelMeta, modelType);
|
||||||
|
|
||||||
// Scroll to import area after modal is visible
|
// Scroll to import area after modal is visible
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -429,14 +431,14 @@ export function createModelCard(model, modelType) {
|
|||||||
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
|
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
|
||||||
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
||||||
|
|
||||||
// Determine NSFW warning text based on level
|
// Determine NSFW warning text based on level with i18n support
|
||||||
let nsfwText = "Mature Content";
|
let nsfwText = translate('modelCard.nsfw.matureContent', {}, 'Mature Content');
|
||||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||||
nsfwText = "XXX-rated Content";
|
nsfwText = translate('modelCard.nsfw.xxxRated', {}, 'XXX-rated Content');
|
||||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||||
nsfwText = "X-rated Content";
|
nsfwText = translate('modelCard.nsfw.xRated', {}, 'X-rated Content');
|
||||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||||
nsfwText = "R-rated Content";
|
nsfwText = translate('modelCard.nsfw.rRated', {}, 'R-rated Content');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if autoplayOnHover is enabled for video previews
|
// Check if autoplayOnHover is enabled for video previews
|
||||||
@@ -447,22 +449,36 @@ export function createModelCard(model, modelType) {
|
|||||||
// Get favorite status from model data
|
// Get favorite status from model data
|
||||||
const isFavorite = model.favorite === true;
|
const isFavorite = model.favorite === true;
|
||||||
|
|
||||||
// Generate action icons based on model type
|
// Generate action icons based on model type with i18n support
|
||||||
|
const favoriteTitle = isFavorite ?
|
||||||
|
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||||||
|
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||||
|
const globeTitle = model.from_civitai ?
|
||||||
|
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||||
|
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||||
|
const sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||||||
|
const copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||||||
|
|
||||||
const actionIcons = `
|
const actionIcons = `
|
||||||
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
||||||
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
|
title="${favoriteTitle}">
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-globe"
|
<i class="fas fa-globe"
|
||||||
title="${model.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
title="${globeTitle}"
|
||||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-paper-plane"
|
<i class="fas fa-paper-plane"
|
||||||
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
title="${sendTitle}">
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-copy"
|
<i class="fas fa-copy"
|
||||||
title="Copy LoRA Syntax">
|
title="${copyTitle}">
|
||||||
</i>`;
|
</i>`;
|
||||||
|
|
||||||
|
// Generate UI text with i18n support
|
||||||
|
const toggleBlurTitle = translate('modelCard.actions.toggleBlur', {}, 'Toggle blur');
|
||||||
|
const showButtonText = translate('modelCard.actions.show', {}, 'Show');
|
||||||
|
const openExampleImagesTitle = translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
@@ -473,7 +489,7 @@ export function createModelCard(model, modelType) {
|
|||||||
}
|
}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
`<button class="toggle-blur-btn" title="Toggle blur">
|
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
|
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
|
||||||
@@ -487,7 +503,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="nsfw-overlay">
|
<div class="nsfw-overlay">
|
||||||
<div class="nsfw-warning">
|
<div class="nsfw-warning">
|
||||||
<p>${nsfwText}</p>
|
<p>${nsfwText}</p>
|
||||||
<button class="show-content-btn">Show</button>
|
<button class="show-content-btn">${showButtonText}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -498,7 +514,7 @@ export function createModelCard(model, modelType) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<i class="fas fa-folder-open"
|
<i class="fas fa-folder-open"
|
||||||
title="Open Example Images Folder">
|
title="${openExampleImagesTitle}">
|
||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ModelDescription.js
|
* ModelDescription.js
|
||||||
@@ -12,7 +13,7 @@ export function setupTabSwitching() {
|
|||||||
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
|
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
|
||||||
|
|
||||||
tabButtons.forEach(button => {
|
tabButtons.forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', async () => {
|
||||||
// Remove active class from all tabs
|
// Remove active class from all tabs
|
||||||
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
|
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
|
||||||
btn.classList.remove('active')
|
btn.classList.remove('active')
|
||||||
@@ -26,29 +27,65 @@ export function setupTabSwitching() {
|
|||||||
const tabId = `${button.dataset.tab}-tab`;
|
const tabId = `${button.dataset.tab}-tab`;
|
||||||
document.getElementById(tabId).classList.add('active');
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
|
||||||
// If switching to description tab, make sure content is properly sized
|
// If switching to description tab, load content lazily
|
||||||
if (button.dataset.tab === 'description') {
|
if (button.dataset.tab === 'description') {
|
||||||
const descriptionContent = document.querySelector('.model-description-content');
|
await loadModelDescription();
|
||||||
if (descriptionContent) {
|
|
||||||
const hasContent = descriptionContent.innerHTML.trim() !== '';
|
|
||||||
document.querySelector('.model-description-loading')?.classList.add('hidden');
|
|
||||||
|
|
||||||
// If no content, show a message
|
|
||||||
if (!hasContent) {
|
|
||||||
descriptionContent.innerHTML = '<div class="no-description">No model description available</div>';
|
|
||||||
descriptionContent.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load model description lazily
|
||||||
|
*/
|
||||||
|
async function loadModelDescription() {
|
||||||
|
const descriptionContent = document.querySelector('.model-description-content');
|
||||||
|
const descriptionLoading = document.querySelector('.model-description-loading');
|
||||||
|
const showcaseSection = document.querySelector('.showcase-section');
|
||||||
|
|
||||||
|
if (!descriptionContent || !showcaseSection) return;
|
||||||
|
|
||||||
|
// Check if already loaded
|
||||||
|
if (descriptionContent.dataset.loaded === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = showcaseSection.dataset.filepath;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
descriptionLoading?.classList.remove('hidden');
|
||||||
|
descriptionContent.classList.add('hidden');
|
||||||
|
|
||||||
|
// Fetch description from API
|
||||||
|
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
||||||
|
const description = await getModelApiClient().fetchModelDescription(filePath);
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
const noDescriptionText = translate('modals.model.description.noDescription', {}, 'No model description available');
|
||||||
|
descriptionContent.innerHTML = description || `<div class="no-description">${noDescriptionText}</div>`;
|
||||||
|
descriptionContent.dataset.loaded = 'true';
|
||||||
|
|
||||||
|
// Set up editing functionality
|
||||||
|
await setupModelDescriptionEditing(filePath);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading model description:', error);
|
||||||
|
const failedText = translate('modals.model.description.failedToLoad', {}, 'Failed to load model description');
|
||||||
|
descriptionContent.innerHTML = `<div class="no-description">${failedText}</div>`;
|
||||||
|
} finally {
|
||||||
|
// Hide loading state
|
||||||
|
descriptionLoading?.classList.add('hidden');
|
||||||
|
descriptionContent.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up model description editing functionality
|
* Set up model description editing functionality
|
||||||
* @param {string} filePath - File path
|
* @param {string} filePath - File path
|
||||||
*/
|
*/
|
||||||
export function setupModelDescriptionEditing(filePath) {
|
export async function setupModelDescriptionEditing(filePath) {
|
||||||
const descContent = document.querySelector('.model-description-content');
|
const descContent = document.querySelector('.model-description-content');
|
||||||
const descContainer = document.querySelector('.model-description-container');
|
const descContainer = document.querySelector('.model-description-container');
|
||||||
if (!descContent || !descContainer) return;
|
if (!descContent || !descContainer) return;
|
||||||
@@ -58,7 +95,9 @@ export function setupModelDescriptionEditing(filePath) {
|
|||||||
if (!editBtn) {
|
if (!editBtn) {
|
||||||
editBtn = document.createElement('button');
|
editBtn = document.createElement('button');
|
||||||
editBtn.className = 'edit-model-description-btn';
|
editBtn.className = 'edit-model-description-btn';
|
||||||
editBtn.title = 'Edit model description';
|
// Set title using i18n
|
||||||
|
const editTitle = translate('modals.model.description.editTitle', {}, 'Edit model description');
|
||||||
|
editBtn.title = editTitle;
|
||||||
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
||||||
descContainer.insertBefore(editBtn, descContent);
|
descContainer.insertBefore(editBtn, descContent);
|
||||||
}
|
}
|
||||||
@@ -115,7 +154,7 @@ export function setupModelDescriptionEditing(filePath) {
|
|||||||
}
|
}
|
||||||
if (!newValue) {
|
if (!newValue) {
|
||||||
this.innerHTML = originalValue;
|
this.innerHTML = originalValue;
|
||||||
showToast('Description cannot be empty', 'error');
|
showToast('modals.model.description.validation.cannotBeEmpty', {}, 'error');
|
||||||
exitEditMode();
|
exitEditMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,10 +162,10 @@ export function setupModelDescriptionEditing(filePath) {
|
|||||||
// Save to backend
|
// Save to backend
|
||||||
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
||||||
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
|
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
|
||||||
showToast('Model description updated', 'success');
|
showToast('modals.model.description.messages.updated', {}, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.innerHTML = originalValue;
|
this.innerHTML = originalValue;
|
||||||
showToast('Failed to update model description', 'error');
|
showToast('modals.model.description.messages.updateFailed', {}, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
exitEditMode();
|
exitEditMode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
* ModelMetadata.js
|
* ModelMetadata.js
|
||||||
* Handles model metadata editing functionality - General version
|
* Handles model metadata editing functionality - General version
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { BASE_MODELS } from '../../utils/constants.js';
|
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +83,7 @@ export function setupModelNameEditing(filePath) {
|
|||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
|
|
||||||
showToast('Model name is limited to 100 characters', 'warning');
|
showToast('toast.models.nameTooLong', {}, 'warning');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ export function setupModelNameEditing(filePath) {
|
|||||||
if (!newModelName) {
|
if (!newModelName) {
|
||||||
// Restore original value if empty
|
// Restore original value if empty
|
||||||
this.textContent = originalValue;
|
this.textContent = originalValue;
|
||||||
showToast('Model name cannot be empty', 'error');
|
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
|
||||||
exitEditMode();
|
exitEditMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,11 +115,11 @@ export function setupModelNameEditing(filePath) {
|
|||||||
|
|
||||||
await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
|
await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
|
||||||
|
|
||||||
showToast('Model name updated successfully', 'success');
|
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating model name:', error);
|
console.error('Error updating model name:', error);
|
||||||
this.textContent = originalValue; // Restore original model name
|
this.textContent = originalValue; // Restore original model name
|
||||||
showToast('Failed to update model name', 'error');
|
showToast('toast.models.nameUpdateFailed', {}, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
exitEditMode();
|
exitEditMode();
|
||||||
}
|
}
|
||||||
@@ -171,21 +172,8 @@ export function setupBaseModelEditing(filePath) {
|
|||||||
// Flag to track if a change was made
|
// Flag to track if a change was made
|
||||||
let valueChanged = false;
|
let valueChanged = false;
|
||||||
|
|
||||||
// Add options from BASE_MODELS constants
|
// Add options from BASE_MODEL_CATEGORIES constants
|
||||||
const baseModelCategories = {
|
const baseModelCategories = BASE_MODEL_CATEGORIES;
|
||||||
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
|
|
||||||
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
|
|
||||||
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
|
|
||||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
|
||||||
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
|
|
||||||
'Other Models': [
|
|
||||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
|
|
||||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
|
||||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
|
||||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
|
||||||
BASE_MODELS.QWEN, BASE_MODELS.UNKNOWN
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create option groups for better organization
|
// Create option groups for better organization
|
||||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
||||||
@@ -293,9 +281,9 @@ async function saveBaseModel(filePath, originalValue) {
|
|||||||
try {
|
try {
|
||||||
await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel });
|
await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel });
|
||||||
|
|
||||||
showToast('Base model updated successfully', 'success');
|
showToast('toast.models.baseModelUpdated', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to update base model', 'error');
|
showToast('toast.models.baseModelUpdateFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +369,7 @@ export function setupFileNameEditing(filePath) {
|
|||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Invalid characters removed from filename', 'warning');
|
showToast('toast.models.invalidCharactersRemoved', {}, 'warning');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,7 +384,7 @@ export function setupFileNameEditing(filePath) {
|
|||||||
if (!newFileName) {
|
if (!newFileName) {
|
||||||
// Restore original value if empty
|
// Restore original value if empty
|
||||||
this.textContent = originalValue;
|
this.textContent = originalValue;
|
||||||
showToast('File name cannot be empty', 'error');
|
showToast('toast.models.filenameCannotBeEmpty', {}, 'error');
|
||||||
exitEditMode();
|
exitEditMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -415,7 +403,7 @@ export function setupFileNameEditing(filePath) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error renaming file:', error);
|
console.error('Error renaming file:', error);
|
||||||
this.textContent = originalValue; // Restore original file name
|
this.textContent = originalValue; // Restore original file name
|
||||||
showToast(`Failed to rename file: ${error.message}`, 'error');
|
showToast('toast.models.renameFailed', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
exitEditMode();
|
exitEditMode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
scrollToTop,
|
scrollToTop,
|
||||||
loadExampleImages
|
loadExampleImages
|
||||||
} from './showcase/ShowcaseView.js';
|
} from './showcase/ShowcaseView.js';
|
||||||
import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
|
import { setupTabSwitching } from './ModelDescription.js';
|
||||||
import {
|
import {
|
||||||
setupModelNameEditing,
|
setupModelNameEditing,
|
||||||
setupBaseModelEditing,
|
setupBaseModelEditing,
|
||||||
@@ -18,74 +18,100 @@ import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
|||||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||||
import { loadRecipesForLora } from './RecipeTab.js';
|
import { loadRecipesForLora } from './RecipeTab.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the model modal with the given model data
|
* Display the model modal with the given model data
|
||||||
* @param {Object} model - Model data object
|
* @param {Object} model - Model data object
|
||||||
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
* @param {string} modelType - Type of model ('lora' or 'checkpoint')
|
||||||
*/
|
*/
|
||||||
export function showModelModal(model, modelType) {
|
export async function showModelModal(model, modelType) {
|
||||||
const modalId = 'modelModal';
|
const modalId = 'modelModal';
|
||||||
const modalTitle = model.model_name;
|
const modalTitle = model.model_name;
|
||||||
|
|
||||||
|
// Fetch complete civitai metadata
|
||||||
|
let completeCivitaiData = model.civitai || {};
|
||||||
|
if (model.file_path) {
|
||||||
|
try {
|
||||||
|
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
|
||||||
|
completeCivitaiData = fullMetadata || model.civitai || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch complete metadata, using existing data:', error);
|
||||||
|
// Continue with existing data if fetch fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare LoRA specific data
|
// Update model with complete civitai data
|
||||||
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && model.civitai?.trainedWords?.length ?
|
const modelWithFullData = {
|
||||||
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
...model,
|
||||||
|
civitai: completeCivitaiData
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare LoRA specific data with complete civitai data
|
||||||
|
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
||||||
|
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||||
|
|
||||||
// Generate model type specific content
|
// Generate model type specific content
|
||||||
let typeSpecificContent;
|
let typeSpecificContent;
|
||||||
if (modelType === 'loras') {
|
if (modelType === 'loras') {
|
||||||
typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
|
typeSpecificContent = renderLoraSpecificContent(modelWithFullData, escapedWords);
|
||||||
} else if (modelType === 'embeddings') {
|
} else if (modelType === 'embeddings') {
|
||||||
typeSpecificContent = renderEmbeddingSpecificContent(model, escapedWords);
|
typeSpecificContent = renderEmbeddingSpecificContent(modelWithFullData, escapedWords);
|
||||||
} else {
|
} else {
|
||||||
typeSpecificContent = '';
|
typeSpecificContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate tabs based on model type
|
// Generate tabs based on model type
|
||||||
|
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
|
||||||
|
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
|
||||||
|
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
|
||||||
|
|
||||||
const tabsContent = modelType === 'loras' ?
|
const tabsContent = modelType === 'loras' ?
|
||||||
`<button class="tab-btn active" data-tab="showcase">Examples</button>
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||||
<button class="tab-btn" data-tab="description">Model Description</button>
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||||
<button class="tab-btn" data-tab="recipes">Recipes</button>` :
|
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
|
||||||
`<button class="tab-btn active" data-tab="showcase">Examples</button>
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||||
<button class="tab-btn" data-tab="description">Model Description</button>`;
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>`;
|
||||||
|
|
||||||
|
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
||||||
|
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
||||||
|
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
||||||
|
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
||||||
|
|
||||||
const tabPanesContent = modelType === 'loras' ?
|
const tabPanesContent = modelType === 'loras' ?
|
||||||
`<div id="showcase-tab" class="tab-pane active">
|
`<div id="showcase-tab" class="tab-pane active">
|
||||||
<div class="example-images-loading">
|
<div class="example-images-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading example images...
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-pane">
|
<div id="description-tab" class="tab-pane">
|
||||||
<div class="model-description-container">
|
<div class="model-description-container">
|
||||||
<div class="model-description-loading">
|
<div class="model-description-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
|
||||||
</div>
|
</div>
|
||||||
<div class="model-description-content">
|
<div class="model-description-content hidden">
|
||||||
${model.modelDescription || ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="recipes-tab" class="tab-pane">
|
<div id="recipes-tab" class="tab-pane">
|
||||||
<div class="recipes-loading">
|
<div class="recipes-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
|
||||||
</div>
|
</div>
|
||||||
</div>` :
|
</div>` :
|
||||||
`<div id="showcase-tab" class="tab-pane active">
|
`<div id="showcase-tab" class="tab-pane active">
|
||||||
<div class="recipes-loading">
|
<div class="recipes-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading examples...
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExamplesText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-pane">
|
<div id="description-tab" class="tab-pane">
|
||||||
<div class="model-description-container">
|
<div class="model-description-container">
|
||||||
<div class="model-description-loading">
|
<div class="model-description-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
<i class="fas fa-spinner fa-spin"></i> ${loadingDescriptionText}
|
||||||
</div>
|
</div>
|
||||||
<div class="model-description-content">
|
<div class="model-description-content hidden">
|
||||||
${model.modelDescription || ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -96,86 +122,86 @@ export function showModelModal(model, modelType) {
|
|||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<div class="model-name-header">
|
<div class="model-name-header">
|
||||||
<h2 class="model-name-content">${modalTitle}</h2>
|
<h2 class="model-name-content">${modalTitle}</h2>
|
||||||
<button class="edit-model-name-btn" title="Edit model name">
|
<button class="edit-model-name-btn" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
||||||
<i class="fas fa-pencil-alt"></i>
|
<i class="fas fa-pencil-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="creator-actions">
|
<div class="creator-actions">
|
||||||
${model.from_civitai ? `
|
${modelWithFullData.from_civitai ? `
|
||||||
<div class="civitai-view" title="View on Civitai" data-action="view-civitai" data-filepath="${model.file_path}">
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}">
|
||||||
<i class="fas fa-globe"></i> View on Civitai
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
${model.civitai?.creator ? `
|
${modelWithFullData.civitai?.creator ? `
|
||||||
<div class="creator-info" data-username="${model.civitai.creator.username}" data-action="view-creator" title="View Creator Profile">
|
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
||||||
${model.civitai.creator.image ?
|
${modelWithFullData.civitai.creator.image ?
|
||||||
`<div class="creator-avatar">
|
`<div class="creator-avatar">
|
||||||
<img src="${model.civitai.creator.image}" alt="${model.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
|
||||||
</div>` :
|
</div>` :
|
||||||
`<div class="creator-avatar creator-placeholder">
|
`<div class="creator-avatar creator-placeholder">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
<span class="creator-username">${model.civitai.creator.username}</span>
|
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${renderCompactTags(model.tags || [], model.file_path)}
|
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<label>Version</label>
|
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
|
||||||
<span>${model.civitai?.name || 'N/A'}</span>
|
<span>${modelWithFullData.civitai?.name || 'N/A'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<label>File Name</label>
|
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
|
||||||
<div class="file-name-wrapper">
|
<div class="file-name-wrapper">
|
||||||
<span id="file-name" class="file-name-content">${model.file_name || 'N/A'}</span>
|
<span id="file-name" class="file-name-content">${modelWithFullData.file_name || 'N/A'}</span>
|
||||||
<button class="edit-file-name-btn" title="Edit file name">
|
<button class="edit-file-name-btn" title="${translate('modals.model.actions.editFileName', {}, 'Edit file name')}">
|
||||||
<i class="fas fa-pencil-alt"></i>
|
<i class="fas fa-pencil-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item location-size">
|
<div class="info-item location-size">
|
||||||
<div class="location-wrapper">
|
<div class="location-wrapper">
|
||||||
<label>Location</label>
|
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
||||||
<span class="file-path">${model.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
<span class="file-path">${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item base-size">
|
<div class="info-item base-size">
|
||||||
<div class="base-wrapper">
|
<div class="base-wrapper">
|
||||||
<label>Base Model</label>
|
<label>${translate('modals.model.metadata.baseModel', {}, 'Base Model')}</label>
|
||||||
<div class="base-model-display">
|
<div class="base-model-display">
|
||||||
<span class="base-model-content">${model.base_model || 'Unknown'}</span>
|
<span class="base-model-content">${modelWithFullData.base_model || translate('modals.model.metadata.unknown', {}, 'Unknown')}</span>
|
||||||
<button class="edit-base-model-btn" title="Edit base model">
|
<button class="edit-base-model-btn" title="${translate('modals.model.actions.editBaseModel', {}, 'Edit base model')}">
|
||||||
<i class="fas fa-pencil-alt"></i>
|
<i class="fas fa-pencil-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="size-wrapper">
|
<div class="size-wrapper">
|
||||||
<label>Size</label>
|
<label>${translate('modals.model.metadata.size', {}, 'Size')}</label>
|
||||||
<span>${formatFileSize(model.file_size)}</span>
|
<span>${formatFileSize(modelWithFullData.file_size)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${typeSpecificContent}
|
${typeSpecificContent}
|
||||||
<div class="info-item notes">
|
<div class="info-item notes">
|
||||||
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
|
<label>${translate('modals.model.metadata.additionalNotes', {}, 'Additional Notes')} <i class="fas fa-info-circle notes-hint" title="${translate('modals.model.metadata.notesHint', {}, 'Press Enter to save, Shift+Enter for new line')}"></i></label>
|
||||||
<div class="editable-field">
|
<div class="editable-field">
|
||||||
<div class="notes-content" contenteditable="true" spellcheck="false">${model.notes || 'Add your notes here...'}</div>
|
<div class="notes-content" contenteditable="true" spellcheck="false">${modelWithFullData.notes || translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item full-width">
|
<div class="info-item full-width">
|
||||||
<label>About this version</label>
|
<label>${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</label>
|
||||||
<div class="description-text">${model.civitai?.description || 'N/A'}</div>
|
<div class="description-text">${modelWithFullData.civitai?.description || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="showcase-section" data-model-hash="${model.sha256 || ''}" data-filepath="${model.file_path}">
|
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${modelWithFullData.file_path}">
|
||||||
<div class="showcase-tabs">
|
<div class="showcase-tabs">
|
||||||
${tabsContent}
|
${tabsContent}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,16 +228,15 @@ export function showModelModal(model, modelType) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||||
setupEditableFields(model.file_path, modelType);
|
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||||
setupShowcaseScroll(modalId);
|
setupShowcaseScroll(modalId);
|
||||||
setupTabSwitching();
|
setupTabSwitching();
|
||||||
setupTagTooltip();
|
setupTagTooltip();
|
||||||
setupTagEditMode();
|
setupTagEditMode();
|
||||||
setupModelNameEditing(model.file_path);
|
setupModelNameEditing(modelWithFullData.file_path);
|
||||||
setupBaseModelEditing(model.file_path);
|
setupBaseModelEditing(modelWithFullData.file_path);
|
||||||
setupFileNameEditing(model.file_path);
|
setupFileNameEditing(modelWithFullData.file_path);
|
||||||
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
|
setupEventHandlers(modelWithFullData.file_path);
|
||||||
setupEventHandlers(model.file_path);
|
|
||||||
|
|
||||||
// LoRA specific setup
|
// LoRA specific setup
|
||||||
if (modelType === 'loras' || modelType === 'embeddings') {
|
if (modelType === 'loras' || modelType === 'embeddings') {
|
||||||
@@ -219,33 +244,33 @@ export function showModelModal(model, modelType) {
|
|||||||
|
|
||||||
if (modelType == 'loras') {
|
if (modelType == 'loras') {
|
||||||
// Load recipes for this LoRA
|
// Load recipes for this LoRA
|
||||||
loadRecipesForLora(model.model_name, model.sha256);
|
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load example images asynchronously - merge regular and custom images
|
// Load example images asynchronously - merge regular and custom images
|
||||||
const regularImages = model.civitai?.images || [];
|
const regularImages = modelWithFullData.civitai?.images || [];
|
||||||
const customImages = model.civitai?.customImages || [];
|
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||||
// Combine images - regular images first, then custom images
|
// Combine images - regular images first, then custom images
|
||||||
const allImages = [...regularImages, ...customImages];
|
const allImages = [...regularImages, ...customImages];
|
||||||
loadExampleImages(allImages, model.sha256);
|
loadExampleImages(allImages, modelWithFullData.sha256);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLoraSpecificContent(lora, escapedWords) {
|
function renderLoraSpecificContent(lora, escapedWords) {
|
||||||
return `
|
return `
|
||||||
<div class="info-item usage-tips">
|
<div class="info-item usage-tips">
|
||||||
<label>Usage Tips</label>
|
<label>${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</label>
|
||||||
<div class="editable-field">
|
<div class="editable-field">
|
||||||
<div class="preset-controls">
|
<div class="preset-controls">
|
||||||
<select id="preset-selector">
|
<select id="preset-selector">
|
||||||
<option value="">Add preset parameter...</option>
|
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Add preset parameter...')}</option>
|
||||||
<option value="strength_min">Strength Min</option>
|
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
||||||
<option value="strength_max">Strength Max</option>
|
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
||||||
<option value="strength">Strength</option>
|
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
||||||
<option value="clip_skip">Clip Skip</option>
|
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
|
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
|
||||||
<button class="add-preset-btn">Add</button>
|
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="preset-tags">
|
<div class="preset-tags">
|
||||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||||
@@ -413,9 +438,9 @@ async function saveNotes(filePath) {
|
|||||||
try {
|
try {
|
||||||
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
|
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
|
||||||
|
|
||||||
showToast('Notes saved successfully', 'success');
|
showToast('modals.model.notes.saved', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to save notes', 'error');
|
showToast('modals.model.notes.saveFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { PRESET_TAGS } from '../../utils/constants.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
|
// Create a named function so we can remove it later
|
||||||
let saveTagsHandler = null;
|
let saveTagsHandler = null;
|
||||||
@@ -138,7 +132,7 @@ export function setupTagEditMode() {
|
|||||||
// ...existing helper functions...
|
// ...existing helper functions...
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save tags - 支持LoRA和Checkpoint
|
* Save tags
|
||||||
*/
|
*/
|
||||||
async function saveTags() {
|
async function saveTags() {
|
||||||
const editBtn = document.querySelector('.edit-tags-btn');
|
const editBtn = document.querySelector('.edit-tags-btn');
|
||||||
@@ -216,10 +210,10 @@ async function saveTags() {
|
|||||||
// Exit edit mode
|
// Exit edit mode
|
||||||
editBtn.click();
|
editBtn.click();
|
||||||
|
|
||||||
showToast('Tags updated successfully', 'success');
|
showToast('modelTags.messages.updated', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving tags:', error);
|
console.error('Error saving tags:', error);
|
||||||
showToast('Failed to update tags', 'error');
|
showToast('modelTags.messages.updateFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,21 +355,21 @@ function addNewTag(tag) {
|
|||||||
|
|
||||||
// Validation: Check length
|
// Validation: Check length
|
||||||
if (tag.length > 30) {
|
if (tag.length > 30) {
|
||||||
showToast('Tag should not exceed 30 characters', 'error');
|
showToast('modelTags.validation.maxLength', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check total number
|
// Validation: Check total number
|
||||||
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
|
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
|
||||||
if (currentTags.length >= 30) {
|
if (currentTags.length >= 30) {
|
||||||
showToast('Maximum 30 tags allowed', 'error');
|
showToast('modelTags.validation.maxCount', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check for duplicates
|
// Validation: Check for duplicates
|
||||||
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
|
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
|
||||||
if (existingTags.includes(tag)) {
|
if (existingTags.includes(tag)) {
|
||||||
showToast('This tag already exists', 'error');
|
showToast('modelTags.validation.duplicate', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ function getLoraStatusTitle(totalCount, missingCount) {
|
|||||||
*/
|
*/
|
||||||
function copyRecipeSyntax(recipeId) {
|
function copyRecipeSyntax(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ function copyRecipeSyntax(recipeId) {
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed to copy: ', err);
|
console.error('Failed to copy: ', err);
|
||||||
showToast('Failed to copy recipe syntax', 'error');
|
showToast('toast.recipes.copyFailed', { message: err.message }, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Moved to shared directory for consistency
|
* Moved to shared directory for consistency
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +27,7 @@ async function fetchTrainedWords(filePath) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching trained words:', error);
|
console.error('Error fetching trained words:', error);
|
||||||
showToast('Could not load trained words', 'error');
|
showToast('toast.triggerWords.loadFailed', {}, 'error');
|
||||||
return { trainedWords: [], classTokens: null };
|
return { trainedWords: [], classTokens: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,9 +49,9 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
|
|
||||||
// No suggestions case
|
// No suggestions case
|
||||||
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
||||||
header.innerHTML = '<span>No suggestions available</span>';
|
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
|
||||||
dropdown.appendChild(header);
|
dropdown.appendChild(header);
|
||||||
dropdown.innerHTML += '<div class="no-suggestions">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
|
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
||||||
return dropdown;
|
return dropdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +66,8 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
const classTokensHeader = document.createElement('div');
|
const classTokensHeader = document.createElement('div');
|
||||||
classTokensHeader.className = 'metadata-suggestions-header';
|
classTokensHeader.className = 'metadata-suggestions-header';
|
||||||
classTokensHeader.innerHTML = `
|
classTokensHeader.innerHTML = `
|
||||||
<span>Class Token</span>
|
<span>${translate('modals.model.triggerWords.suggestions.classToken')}</span>
|
||||||
<small>Add to your prompt for best results</small>
|
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
|
||||||
`;
|
`;
|
||||||
dropdown.appendChild(classTokensHeader);
|
dropdown.appendChild(classTokensHeader);
|
||||||
|
|
||||||
@@ -77,13 +78,13 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
// Create a special item for the class token
|
// Create a special item for the class token
|
||||||
const tokenItem = document.createElement('div');
|
const tokenItem = document.createElement('div');
|
||||||
tokenItem.className = `metadata-suggestion-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.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
|
||||||
tokenItem.innerHTML = `
|
tokenItem.innerHTML = `
|
||||||
<span class="metadata-suggestion-text">${classTokens}</span>
|
<span class="metadata-suggestion-text">${classTokens}</span>
|
||||||
<div class="metadata-suggestion-meta">
|
<div class="metadata-suggestion-meta">
|
||||||
<span class="token-badge">Class Token</span>
|
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
|
||||||
${existingWords.includes(classTokens) ?
|
${existingWords.includes(classTokens) ?
|
||||||
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -119,8 +120,8 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
// Add trained words header if we have any
|
// Add trained words header if we have any
|
||||||
if (trainedWords && trainedWords.length > 0) {
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<span>Word Suggestions</span>
|
<span>${translate('modals.model.triggerWords.suggestions.wordSuggestions')}</span>
|
||||||
<small>${trainedWords.length} words found</small>
|
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
|
||||||
`;
|
`;
|
||||||
dropdown.appendChild(header);
|
dropdown.appendChild(header);
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
<span class="metadata-suggestion-text">${word}</span>
|
<span class="metadata-suggestion-text">${word}</span>
|
||||||
<div class="metadata-suggestion-meta">
|
<div class="metadata-suggestion-meta">
|
||||||
<span class="trained-word-freq">${frequency}</span>
|
<span class="trained-word-freq">${frequency}</span>
|
||||||
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
dropdown.appendChild(container);
|
dropdown.appendChild(container);
|
||||||
} else if (!classTokens) {
|
} else if (!classTokens) {
|
||||||
// If we have neither class tokens nor trained words
|
// If we have neither class tokens nor trained words
|
||||||
dropdown.innerHTML += '<div class="no-suggestions">No word suggestions found in this model. You can manually enter trigger words.</div>';
|
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dropdown;
|
return dropdown;
|
||||||
@@ -182,22 +183,22 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
if (!words.length) return `
|
if (!words.length) return `
|
||||||
<div class="info-item full-width trigger-words">
|
<div class="info-item full-width trigger-words">
|
||||||
<div class="trigger-words-header">
|
<div class="trigger-words-header">
|
||||||
<label>Trigger Words</label>
|
<label>${translate('modals.model.triggerWords.label')}</label>
|
||||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
|
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}">
|
||||||
<i class="fas fa-pencil-alt"></i>
|
<i class="fas fa-pencil-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="trigger-words-content">
|
<div class="trigger-words-content">
|
||||||
<span class="no-trigger-words">No trigger word needed</span>
|
<span class="no-trigger-words">${translate('modals.model.triggerWords.noTriggerWordsNeeded')}</span>
|
||||||
<div class="trigger-words-tags" style="display:none;"></div>
|
<div class="trigger-words-tags" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
<button class="metadata-save-btn" title="Save changes">
|
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||||
<i class="fas fa-save"></i> Save
|
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-add-form" style="display:none;">
|
<div class="metadata-add-form" style="display:none;">
|
||||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
<input type="text" class="metadata-input" placeholder="${translate('modals.model.triggerWords.addPlaceholder')}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -205,20 +206,20 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
return `
|
return `
|
||||||
<div class="info-item full-width trigger-words">
|
<div class="info-item full-width trigger-words">
|
||||||
<div class="trigger-words-header">
|
<div class="trigger-words-header">
|
||||||
<label>Trigger Words</label>
|
<label>${translate('modals.model.triggerWords.label')}</label>
|
||||||
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="Edit trigger words">
|
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}">
|
||||||
<i class="fas fa-pencil-alt"></i>
|
<i class="fas fa-pencil-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="trigger-words-content">
|
<div class="trigger-words-content">
|
||||||
<div class="trigger-words-tags">
|
<div class="trigger-words-tags">
|
||||||
${words.map(word => `
|
${words.map(word => `
|
||||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
|
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}">
|
||||||
<span class="trigger-word-content">${word}</span>
|
<span class="trigger-word-content">${word}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</span>
|
</span>
|
||||||
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
|
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,12 +227,12 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
<button class="metadata-save-btn" title="Save changes">
|
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||||
<i class="fas fa-save"></i> Save
|
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-add-form" style="display:none;">
|
<div class="metadata-add-form" style="display:none;">
|
||||||
<input type="text" class="metadata-input" placeholder="Type to add or click suggestions below">
|
<input type="text" class="metadata-input" placeholder="${translate('modals.model.triggerWords.addPlaceholder')}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -265,7 +266,7 @@ export function setupTriggerWordsEditMode() {
|
|||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||||
this.title = "Cancel editing";
|
this.title = translate('modals.model.triggerWords.cancel');
|
||||||
|
|
||||||
// Store original trigger words for potential restoration
|
// Store original trigger words for potential restoration
|
||||||
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||||
@@ -302,7 +303,7 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// Add loading indicator
|
// Add loading indicator
|
||||||
const loadingIndicator = document.createElement('div');
|
const loadingIndicator = document.createElement('div');
|
||||||
loadingIndicator.className = 'metadata-loading';
|
loadingIndicator.className = 'metadata-loading';
|
||||||
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
|
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
|
||||||
addForm.appendChild(loadingIndicator);
|
addForm.appendChild(loadingIndicator);
|
||||||
|
|
||||||
// Get currently added trigger words
|
// Get currently added trigger words
|
||||||
@@ -329,7 +330,7 @@ export function setupTriggerWordsEditMode() {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||||
this.title = "Edit trigger words";
|
this.title = translate('modals.model.triggerWords.edit');
|
||||||
|
|
||||||
// Hide edit controls and input form
|
// Hide edit controls and input form
|
||||||
editControls.style.display = 'none';
|
editControls.style.display = 'none';
|
||||||
@@ -499,21 +500,21 @@ function addNewTriggerWord(word) {
|
|||||||
|
|
||||||
// Validation: Check length
|
// Validation: Check length
|
||||||
if (word.split(/\s+/).length > 30) {
|
if (word.split(/\s+/).length > 30) {
|
||||||
showToast('Trigger word should not exceed 30 words', 'error');
|
showToast('toast.triggerWords.tooLong', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check total number
|
// Validation: Check total number
|
||||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||||
if (currentTags.length >= 30) {
|
if (currentTags.length >= 30) {
|
||||||
showToast('Maximum 30 trigger words allowed', 'error');
|
showToast('toast.triggerWords.tooMany', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check for duplicates
|
// Validation: Check for duplicates
|
||||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||||
if (existingWords.includes(word)) {
|
if (existingWords.includes(word)) {
|
||||||
showToast('This trigger word already exists', 'error');
|
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,10 +629,10 @@ async function saveTriggerWords() {
|
|||||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Trigger words updated successfully', 'success');
|
showToast('toast.triggerWords.updateSuccess', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving trigger words:', error);
|
console.error('Error saving trigger words:', error);
|
||||||
showToast('Failed to update trigger words', 'error');
|
showToast('toast.triggerWords.updateFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +645,6 @@ window.copyTriggerWord = async function(word) {
|
|||||||
await copyToClipboard(word, 'Trigger word copied');
|
await copyToClipboard(word, 'Trigger word copied');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Copy failed:', err);
|
console.error('Copy failed:', err);
|
||||||
showToast('Copy failed', 'error');
|
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -278,7 +278,7 @@ export function initMetadataPanelHandlers(container) {
|
|||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Copy failed:', err);
|
console.error('Copy failed:', err);
|
||||||
showToast('Copy failed', 'error');
|
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -432,7 +432,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('Example image deleted', 'success');
|
showToast('toast.exampleImages.deleted', {}, 'success');
|
||||||
|
|
||||||
// Create an update object with only the necessary properties
|
// Create an update object with only the necessary properties
|
||||||
const updateData = {
|
const updateData = {
|
||||||
@@ -445,7 +445,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
||||||
} else {
|
} else {
|
||||||
// Show error message
|
// Show error message
|
||||||
showToast(result.error || 'Failed to delete example image', 'error');
|
showToast('toast.exampleImages.deleteFailed', { error: result.error }, 'error');
|
||||||
|
|
||||||
// Reset button state
|
// Reset button state
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
@@ -456,7 +456,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting example image:', error);
|
console.error('Error deleting example image:', error);
|
||||||
showToast('Failed to delete example image', 'error');
|
showToast('toast.exampleImages.deleteFailed', {}, 'error');
|
||||||
|
|
||||||
// Reset button state
|
// Reset button state
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
@@ -536,7 +536,7 @@ function initSetPreviewHandlers(container) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting preview:', error);
|
console.error('Error setting preview:', error);
|
||||||
showToast('Failed to set preview image', 'error');
|
showToast('toast.exampleImages.setPreviewFailed', {}, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
// Restore button state
|
// Restore button state
|
||||||
this.innerHTML = '<i class="fas fa-image"></i>';
|
this.innerHTML = '<i class="fas fa-image"></i>';
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
|||||||
// Initialize the import UI for the new content
|
// Initialize the import UI for the new content
|
||||||
initExampleImport(modelHash, showcaseTab);
|
initExampleImport(modelHash, showcaseTab);
|
||||||
|
|
||||||
showToast('Example images imported successfully', 'success');
|
showToast('toast.import.imagesImported', {}, 'success');
|
||||||
|
|
||||||
// Update VirtualScroller if available
|
// Update VirtualScroller if available
|
||||||
if (state.virtualScroller && result.model_file_path) {
|
if (state.virtualScroller && result.model_file_path) {
|
||||||
@@ -430,7 +430,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error importing examples:', error);
|
console.error('Error importing examples:', error);
|
||||||
showToast(`Failed to import example images: ${error.message}`, 'error');
|
showToast('toast.import.importFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import { bulkManager } from './managers/BulkManager.js';
|
|||||||
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from './managers/HelpManager.js';
|
import { helpManager } from './managers/HelpManager.js';
|
||||||
import { bannerService } from './managers/BannerService.js';
|
import { bannerService } from './managers/BannerService.js';
|
||||||
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
|
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||||
|
import { i18n } from './i18n/index.js';
|
||||||
|
import { onboardingManager } from './managers/OnboardingManager.js';
|
||||||
|
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
|
||||||
|
import { createPageContextMenu } from './components/ContextMenu/index.js';
|
||||||
|
import { initializeEventManagement } from './utils/eventManagementInit.js';
|
||||||
|
|
||||||
// Core application class
|
// Core application class
|
||||||
export class AppCore {
|
export class AppCore {
|
||||||
@@ -26,6 +31,13 @@ export class AppCore {
|
|||||||
|
|
||||||
console.log('AppCore: Initializing...');
|
console.log('AppCore: Initializing...');
|
||||||
|
|
||||||
|
// Initialize i18n first
|
||||||
|
window.i18n = i18n;
|
||||||
|
// Wait for i18n to be ready
|
||||||
|
await window.i18n.waitForReady();
|
||||||
|
|
||||||
|
console.log(`AppCore: Language set: ${i18n.getCurrentLocale()}`);
|
||||||
|
|
||||||
// Initialize managers
|
// Initialize managers
|
||||||
state.loadingManager = new LoadingManager();
|
state.loadingManager = new LoadingManager();
|
||||||
modalManager.initialize();
|
modalManager.initialize();
|
||||||
@@ -42,9 +54,15 @@ export class AppCore {
|
|||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
|
// Initialize the bulk manager and context menu only if not on recipes page
|
||||||
|
if (state.currentPageType !== 'recipes') {
|
||||||
|
bulkManager.initialize();
|
||||||
|
|
||||||
// Initialize the bulk manager
|
// Initialize bulk context menu
|
||||||
bulkManager.initialize();
|
const bulkContextMenu = new BulkContextMenu();
|
||||||
|
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
@@ -53,10 +71,20 @@ export class AppCore {
|
|||||||
|
|
||||||
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
|
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
|
||||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||||
|
|
||||||
|
initializeEventManagement();
|
||||||
|
|
||||||
// Mark as initialized
|
// Mark as initialized
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
|
||||||
|
// Start onboarding if needed (after everything is initialized)
|
||||||
|
setTimeout(() => {
|
||||||
|
// Do not show onboarding if version-mismatch banner is visible
|
||||||
|
if (!bannerService.isBannerVisible('version-mismatch')) {
|
||||||
|
onboardingManager.start();
|
||||||
|
}
|
||||||
|
}, 1000); // Small delay to ensure all elements are rendered
|
||||||
|
|
||||||
// Return the core instance for chaining
|
// Return the core instance for chaining
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -67,22 +95,23 @@ export class AppCore {
|
|||||||
return body.dataset.page || 'unknown';
|
return body.dataset.page || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show toast messages
|
|
||||||
showToast(message, type = 'info') {
|
|
||||||
showToast(message, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize common UI features based on page type
|
// Initialize common UI features based on page type
|
||||||
initializePageFeatures() {
|
initializePageFeatures() {
|
||||||
const pageType = this.getPageType();
|
const pageType = this.getPageType();
|
||||||
|
|
||||||
// Initialize virtual scroll for pages that need it
|
|
||||||
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
||||||
|
this.initializeContextMenus(pageType);
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize context menus for the current page
|
||||||
|
initializeContextMenus(pageType) {
|
||||||
|
// Create page-specific context menu
|
||||||
|
window.pageContextMenu = createPageContextMenu(pageType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { EmbeddingContextMenu } from './components/ContextMenu/index.js';
|
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
|
||||||
@@ -30,14 +29,7 @@ class EmbeddingsPageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Initialize page-specific components
|
// Initialize common page features (including context menus)
|
||||||
this.pageControls.restoreFolderFilter();
|
|
||||||
this.pageControls.initFolderTagsVisibility();
|
|
||||||
|
|
||||||
// Initialize context menu
|
|
||||||
new EmbeddingContextMenu();
|
|
||||||
|
|
||||||
// Initialize common page features
|
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
console.log('Embeddings Manager initialized');
|
console.log('Embeddings Manager initialized');
|
||||||
|
|||||||
341
static/js/i18n/index.js
Normal file
341
static/js/i18n/index.js
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* Internationalization (i18n) system for LoRA Manager
|
||||||
|
* Uses user-selected language from settings with fallback to English
|
||||||
|
* Loads JSON translation files dynamically
|
||||||
|
*/
|
||||||
|
|
||||||
|
class I18nManager {
|
||||||
|
constructor() {
|
||||||
|
this.locales = {};
|
||||||
|
this.translations = {};
|
||||||
|
this.loadedLocales = new Set();
|
||||||
|
this.ready = false;
|
||||||
|
this.readyPromise = null;
|
||||||
|
|
||||||
|
// Available locales configuration
|
||||||
|
this.availableLocales = {
|
||||||
|
'en': { name: 'English', nativeName: 'English' },
|
||||||
|
'zh-CN': { name: 'Chinese (Simplified)', nativeName: '简体中文' },
|
||||||
|
'zh-TW': { name: 'Chinese (Traditional)', nativeName: '繁體中文' },
|
||||||
|
'zh': { name: 'Chinese (Simplified)', nativeName: '简体中文' }, // Fallback to zh-CN
|
||||||
|
'ru': { name: 'Russian', nativeName: 'Русский' },
|
||||||
|
'de': { name: 'German', nativeName: 'Deutsch' },
|
||||||
|
'ja': { name: 'Japanese', nativeName: '日本語' },
|
||||||
|
'ko': { name: 'Korean', nativeName: '한국어' },
|
||||||
|
'fr': { name: 'French', nativeName: 'Français' },
|
||||||
|
'es': { name: 'Spanish', nativeName: 'Español' }
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentLocale = this.getLanguageFromSettings();
|
||||||
|
|
||||||
|
// Initialize with current locale and create ready promise
|
||||||
|
this.readyPromise = this.initializeWithLocale(this.currentLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load translations for a specific locale from JSON file
|
||||||
|
* @param {string} locale - The locale to load
|
||||||
|
* @returns {Promise<Object>} Promise that resolves to the translation data
|
||||||
|
*/
|
||||||
|
async loadLocale(locale) {
|
||||||
|
// Handle fallback for 'zh' to 'zh-CN'
|
||||||
|
const normalizedLocale = locale === 'zh' ? 'zh-CN' : locale;
|
||||||
|
|
||||||
|
if (this.loadedLocales.has(normalizedLocale)) {
|
||||||
|
return this.locales[normalizedLocale];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/locales/${normalizedLocale}.json`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations = await response.json();
|
||||||
|
this.locales[normalizedLocale] = translations;
|
||||||
|
this.loadedLocales.add(normalizedLocale);
|
||||||
|
|
||||||
|
// Also set for 'zh' alias
|
||||||
|
if (normalizedLocale === 'zh-CN') {
|
||||||
|
this.locales['zh'] = translations;
|
||||||
|
this.loadedLocales.add('zh');
|
||||||
|
}
|
||||||
|
|
||||||
|
return translations;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load locale ${normalizedLocale}:`, error);
|
||||||
|
// Fallback to English if current locale fails and it's not English
|
||||||
|
if (normalizedLocale !== 'en') {
|
||||||
|
return this.loadLocale('en');
|
||||||
|
}
|
||||||
|
// Return empty object if even English fails
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize with a specific locale
|
||||||
|
* @param {string} locale - The locale to initialize with
|
||||||
|
*/
|
||||||
|
async initializeWithLocale(locale) {
|
||||||
|
try {
|
||||||
|
this.translations = await this.loadLocale(locale);
|
||||||
|
this.currentLocale = locale;
|
||||||
|
this.ready = true;
|
||||||
|
|
||||||
|
// Dispatch ready event
|
||||||
|
window.dispatchEvent(new CustomEvent('i18nReady', {
|
||||||
|
detail: { language: locale }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to initialize with locale ${locale}, falling back to English`, error);
|
||||||
|
this.translations = await this.loadLocale('en');
|
||||||
|
this.currentLocale = 'en';
|
||||||
|
this.ready = true;
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('i18nReady', {
|
||||||
|
detail: { language: 'en' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for i18n to be ready
|
||||||
|
* @returns {Promise} Promise that resolves when i18n is ready
|
||||||
|
*/
|
||||||
|
async waitForReady() {
|
||||||
|
if (this.ready) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return this.readyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if i18n is ready
|
||||||
|
* @returns {boolean} True if ready
|
||||||
|
*/
|
||||||
|
isReady() {
|
||||||
|
return this.ready && this.translations && Object.keys(this.translations).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get language from user settings with fallback to English
|
||||||
|
* @returns {string} Language code
|
||||||
|
*/
|
||||||
|
getLanguageFromSettings() {
|
||||||
|
// Check localStorage for user-selected language
|
||||||
|
const STORAGE_PREFIX = 'lora_manager_';
|
||||||
|
let userLanguage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = localStorage.getItem(STORAGE_PREFIX + 'settings');
|
||||||
|
if (settings) {
|
||||||
|
const parsedSettings = JSON.parse(settings);
|
||||||
|
userLanguage = parsedSettings.language;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse settings from localStorage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has selected a language, use it
|
||||||
|
if (userLanguage && this.availableLocales[userLanguage]) {
|
||||||
|
return userLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to English
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current language and save to settings
|
||||||
|
* @param {string} languageCode - The language code to set
|
||||||
|
* @returns {Promise<boolean>} True if language was successfully set
|
||||||
|
*/
|
||||||
|
async setLanguage(languageCode) {
|
||||||
|
if (!this.availableLocales[languageCode]) {
|
||||||
|
console.warn(`Language '${languageCode}' is not supported`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reset ready state
|
||||||
|
this.ready = false;
|
||||||
|
|
||||||
|
// Load the new locale
|
||||||
|
this.readyPromise = this.initializeWithLocale(languageCode);
|
||||||
|
await this.readyPromise;
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
const STORAGE_PREFIX = 'lora_manager_';
|
||||||
|
const currentSettings = localStorage.getItem(STORAGE_PREFIX + 'settings');
|
||||||
|
let settings = {};
|
||||||
|
|
||||||
|
if (currentSettings) {
|
||||||
|
settings = JSON.parse(currentSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.language = languageCode;
|
||||||
|
localStorage.setItem(STORAGE_PREFIX + 'settings', JSON.stringify(settings));
|
||||||
|
|
||||||
|
console.log(`Language changed to: ${languageCode}`);
|
||||||
|
|
||||||
|
// Dispatch event to notify components of language change
|
||||||
|
window.dispatchEvent(new CustomEvent('languageChanged', {
|
||||||
|
detail: { language: languageCode }
|
||||||
|
}));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set language:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available languages with their native names
|
||||||
|
* @returns {Array} Array of language objects
|
||||||
|
*/
|
||||||
|
getAvailableLanguages() {
|
||||||
|
return Object.entries(this.availableLocales).map(([code, info]) => ({
|
||||||
|
code,
|
||||||
|
name: info.name,
|
||||||
|
nativeName: info.nativeName
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translation for a key with optional parameters
|
||||||
|
* @param {string} key - Translation key (supports dot notation)
|
||||||
|
* @param {Object} params - Parameters for string interpolation
|
||||||
|
* @returns {string} Translated text
|
||||||
|
*/
|
||||||
|
t(key, params = {}) {
|
||||||
|
// If not ready, return key as fallback
|
||||||
|
if (!this.isReady()) {
|
||||||
|
console.warn(`i18n not ready, returning key: ${key}`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = key.split('.');
|
||||||
|
let value = this.translations;
|
||||||
|
|
||||||
|
// Navigate through nested object
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && typeof value === 'object' && k in value) {
|
||||||
|
value = value[k];
|
||||||
|
} else {
|
||||||
|
// Fallback to English if key not found in current locale
|
||||||
|
if (this.currentLocale !== 'en' && this.locales['en']) {
|
||||||
|
let fallbackValue = this.locales['en'];
|
||||||
|
for (const fallbackKey of keys) {
|
||||||
|
if (fallbackValue && typeof fallbackValue === 'object' && fallbackKey in fallbackValue) {
|
||||||
|
fallbackValue = fallbackValue[fallbackKey];
|
||||||
|
} else {
|
||||||
|
console.warn(`Translation key not found: ${key}`);
|
||||||
|
return key; // Return key as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = fallbackValue;
|
||||||
|
} else {
|
||||||
|
console.warn(`Translation key not found: ${key}`);
|
||||||
|
return key; // Return key as fallback
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
console.warn(`Translation key is not a string: ${key}`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace parameters in the string
|
||||||
|
return this.interpolate(value, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate parameters into a string
|
||||||
|
* Supports both {{param}} and {param} syntax
|
||||||
|
* @param {string} str - String with placeholders
|
||||||
|
* @param {Object} params - Parameters to interpolate
|
||||||
|
* @returns {string} Interpolated string
|
||||||
|
*/
|
||||||
|
interpolate(str, params) {
|
||||||
|
return str.replace(/\{\{?(\w+)\}?\}/g, (match, key) => {
|
||||||
|
return params[key] !== undefined ? params[key] : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current locale
|
||||||
|
* @returns {string} Current locale code
|
||||||
|
*/
|
||||||
|
getCurrentLocale() {
|
||||||
|
return this.currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current locale is RTL (Right-to-Left)
|
||||||
|
* @returns {boolean} True if RTL
|
||||||
|
*/
|
||||||
|
isRTL() {
|
||||||
|
const rtlLocales = ['ar', 'he', 'fa', 'ur'];
|
||||||
|
return rtlLocales.includes(this.currentLocale.split('-')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number according to current locale
|
||||||
|
* @param {number} number - Number to format
|
||||||
|
* @param {Object} options - Intl.NumberFormat options
|
||||||
|
* @returns {string} Formatted number
|
||||||
|
*/
|
||||||
|
formatNumber(number, options = {}) {
|
||||||
|
return new Intl.NumberFormat(this.currentLocale, options).format(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date according to current locale
|
||||||
|
* @param {Date|string|number} date - Date to format
|
||||||
|
* @param {Object} options - Intl.DateTimeFormat options
|
||||||
|
* @returns {string} Formatted date
|
||||||
|
*/
|
||||||
|
formatDate(date, options = {}) {
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
return new Intl.DateTimeFormat(this.currentLocale, options).format(dateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size with locale-specific formatting
|
||||||
|
* @param {number} bytes - Size in bytes
|
||||||
|
* @param {number} decimals - Number of decimal places
|
||||||
|
* @returns {string} Formatted size
|
||||||
|
*/
|
||||||
|
formatFileSize(bytes, decimals = 2) {
|
||||||
|
if (bytes === 0) return this.t('common.fileSize.zero');
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['bytes', 'kb', 'mb', 'gb', 'tb'];
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||||
|
|
||||||
|
return `${this.formatNumber(size)} ${this.t(`common.fileSize.${sizes[i]}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize i18n from user settings
|
||||||
|
* This prevents language flashing on page load
|
||||||
|
* @deprecated Use waitForReady() instead
|
||||||
|
*/
|
||||||
|
async initializeFromSettings() {
|
||||||
|
console.warn('initializeFromSettings() is deprecated, use waitForReady() instead');
|
||||||
|
return this.waitForReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
export const i18n = new I18nManager();
|
||||||
|
|
||||||
|
// Export for global access (will be attached to window)
|
||||||
|
export default i18n;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { state } from './state/index.js';
|
import { state } from './state/index.js';
|
||||||
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
||||||
import { LoraContextMenu } from './components/ContextMenu/index.js';
|
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
@@ -37,15 +36,10 @@ class LoraPageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Initialize page-specific components
|
|
||||||
this.pageControls.restoreFolderFilter();
|
|
||||||
this.pageControls.initFolderTagsVisibility();
|
|
||||||
new LoraContextMenu();
|
|
||||||
|
|
||||||
// Initialize cards for current bulk mode state (should be false initially)
|
// Initialize cards for current bulk mode state (should be false initially)
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
// Initialize common page features (virtual scroll)
|
// Initialize common page features (including context menus and virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ class BannerService {
|
|||||||
|
|
||||||
const actionsHtml = banner.actions ? banner.actions.map(action => {
|
const actionsHtml = banner.actions ? banner.actions.map(action => {
|
||||||
const actionAttribute = action.action ? `data-action="${action.action}"` : '';
|
const actionAttribute = action.action ? `data-action="${action.action}"` : '';
|
||||||
const href = action.url ? `href="${action.url}"` : '#';
|
const href = action.url ? `href="${action.url}"` : 'href="#"';
|
||||||
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
|
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
|
||||||
return `<a ${href ? `href="${href}"` : ''} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
|
return `<a ${href} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
|
||||||
<i class="${action.icon}"></i>
|
<i class="${action.icon}"></i>
|
||||||
<span>${action.text}</span>
|
<span>${action.text}</span>
|
||||||
</a>`;
|
</a>`;
|
||||||
@@ -171,6 +171,16 @@ class BannerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a banner is currently rendered and visible
|
||||||
|
* @param {string} bannerId
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isBannerVisible(bannerId) {
|
||||||
|
const el = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
||||||
|
return !!el && el.offsetParent !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update container visibility based on active banners
|
* Update container visibility based on active banners
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { LoadingManager } from './LoadingManager.js';
|
|||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -85,26 +86,26 @@ export class DownloadManager {
|
|||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
// Update modal title
|
// Update modal title
|
||||||
document.getElementById('downloadModalTitle').textContent = `Download ${config.displayName} from URL`;
|
document.getElementById('downloadModalTitle').textContent = translate('modals.download.titleWithType', { type: config.displayName });
|
||||||
|
|
||||||
// Update URL label
|
// Update URL label
|
||||||
document.getElementById('modelUrlLabel').textContent = 'Civitai URL:';
|
document.getElementById('modelUrlLabel').textContent = translate('modals.download.civitaiUrl');
|
||||||
|
|
||||||
// Update root selection label
|
// Update root selection label
|
||||||
document.getElementById('modelRootLabel').textContent = `Select ${config.displayName} Root:`;
|
document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: config.displayName });
|
||||||
|
|
||||||
// Update path preview labels
|
// Update path preview labels
|
||||||
const pathLabels = document.querySelectorAll('.path-preview label');
|
const pathLabels = document.querySelectorAll('.path-preview label');
|
||||||
pathLabels.forEach(label => {
|
pathLabels.forEach(label => {
|
||||||
if (label.textContent.includes('Location Preview')) {
|
if (label.textContent.includes('Location Preview')) {
|
||||||
label.textContent = 'Download Location Preview:';
|
label.textContent = translate('modals.download.locationPreview') + ':';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update initial path text
|
// Update initial path text
|
||||||
const pathText = document.querySelector('#targetPathDisplay .path-text');
|
const pathText = document.querySelector('#targetPathDisplay .path-text');
|
||||||
if (pathText) {
|
if (pathText) {
|
||||||
pathText.textContent = `Select a ${config.displayName} root directory`;
|
pathText.textContent = translate('modals.download.selectTypeRoot', { type: config.displayName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,17 +143,17 @@ export class DownloadManager {
|
|||||||
const errorElement = document.getElementById('urlError');
|
const errorElement = document.getElementById('urlError');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.loadingManager.showSimpleLoading('Fetching model versions...');
|
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
|
||||||
|
|
||||||
this.modelId = this.extractModelId(url);
|
this.modelId = this.extractModelId(url);
|
||||||
if (!this.modelId) {
|
if (!this.modelId) {
|
||||||
throw new Error('Invalid Civitai URL format');
|
throw new Error(translate('modals.download.errors.invalidUrl'));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId);
|
this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId);
|
||||||
|
|
||||||
if (!this.versions.length) {
|
if (!this.versions.length) {
|
||||||
throw new Error('No versions available for this model');
|
throw new Error(translate('modals.download.errors.noVersions'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a version ID from URL, pre-select it
|
// If we have a version ID from URL, pre-select it
|
||||||
@@ -199,15 +200,15 @@ export class DownloadManager {
|
|||||||
let earlyAccessBadge = '';
|
let earlyAccessBadge = '';
|
||||||
if (isEarlyAccess) {
|
if (isEarlyAccess) {
|
||||||
earlyAccessBadge = `
|
earlyAccessBadge = `
|
||||||
<div class="early-access-badge" title="Early access required">
|
<div class="early-access-badge" title="${translate('modals.download.earlyAccessTooltip')}">
|
||||||
<i class="fas fa-clock"></i> Early Access
|
<i class="fas fa-clock"></i> ${translate('modals.download.earlyAccess')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localStatus = existsLocally ?
|
const localStatus = existsLocally ?
|
||||||
`<div class="local-badge">
|
`<div class="local-badge">
|
||||||
<i class="fas fa-check"></i> In Library
|
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
||||||
<div class="local-path">${localPath || ''}</div>
|
<div class="local-path">${localPath || ''}</div>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ export class DownloadManager {
|
|||||||
${isEarlyAccess ? 'is-early-access' : ''}"
|
${isEarlyAccess ? 'is-early-access' : ''}"
|
||||||
data-version-id="${version.id}">
|
data-version-id="${version.id}">
|
||||||
<div class="version-thumbnail">
|
<div class="version-thumbnail">
|
||||||
<img src="${thumbnailUrl}" alt="Version preview">
|
<img src="${thumbnailUrl}" alt="${translate('modals.download.versionPreview')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="version-content">
|
<div class="version-content">
|
||||||
<div class="version-header">
|
<div class="version-header">
|
||||||
@@ -273,23 +274,23 @@ export class DownloadManager {
|
|||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
nextButton.disabled = true;
|
nextButton.disabled = true;
|
||||||
nextButton.classList.add('disabled');
|
nextButton.classList.add('disabled');
|
||||||
nextButton.textContent = 'Already in Library';
|
nextButton.textContent = translate('modals.download.alreadyInLibrary');
|
||||||
} else {
|
} else {
|
||||||
nextButton.disabled = false;
|
nextButton.disabled = false;
|
||||||
nextButton.classList.remove('disabled');
|
nextButton.classList.remove('disabled');
|
||||||
nextButton.textContent = 'Next';
|
nextButton.textContent = translate('common.actions.next');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async proceedToLocation() {
|
async proceedToLocation() {
|
||||||
if (!this.currentVersion) {
|
if (!this.currentVersion) {
|
||||||
showToast('Please select a version', 'error');
|
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existsLocally = this.currentVersion.existsLocally;
|
const existsLocally = this.currentVersion.existsLocally;
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
showToast('This version already exists in your library', 'info');
|
showToast('toast.loras.versionExists', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +344,7 @@ export class DownloadManager {
|
|||||||
|
|
||||||
this.updateTargetPath();
|
this.updateTargetPath();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, 'error');
|
showToast('toast.downloads.loadError', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +419,7 @@ export class DownloadManager {
|
|||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
if (!modelRoot) {
|
if (!modelRoot) {
|
||||||
showToast(`Please select a ${config.displayName} root directory`, 'error');
|
showToast('toast.models.pleaseSelectRoot', { type: config.displayName }, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,13 +456,13 @@ export class DownloadManager {
|
|||||||
updateProgress(data.progress, 0, this.currentVersion.name);
|
updateProgress(data.progress, 0, this.currentVersion.name);
|
||||||
|
|
||||||
if (data.progress < 3) {
|
if (data.progress < 3) {
|
||||||
this.loadingManager.setStatus(`Preparing download...`);
|
this.loadingManager.setStatus(translate('modals.download.status.preparing'));
|
||||||
} else if (data.progress === 3) {
|
} else if (data.progress === 3) {
|
||||||
this.loadingManager.setStatus(`Downloaded preview image`);
|
this.loadingManager.setStatus(translate('modals.download.status.downloadedPreview'));
|
||||||
} else if (data.progress > 3 && data.progress < 100) {
|
} else if (data.progress > 3 && data.progress < 100) {
|
||||||
this.loadingManager.setStatus(`Downloading ${config.singularName} file`);
|
this.loadingManager.setStatus(translate('modals.download.status.downloadingFile', { type: config.singularName }));
|
||||||
} else {
|
} else {
|
||||||
this.loadingManager.setStatus(`Finalizing download...`);
|
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -480,7 +481,7 @@ export class DownloadManager {
|
|||||||
downloadId
|
downloadId
|
||||||
);
|
);
|
||||||
|
|
||||||
showToast('Download completed successfully', 'success');
|
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -507,7 +508,7 @@ export class DownloadManager {
|
|||||||
await resetAndReload(true);
|
await resetAndReload(true);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, 'error');
|
showToast('toast.downloads.downloadError', { message: error.message }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingManager.hide();
|
this.loadingManager.hide();
|
||||||
}
|
}
|
||||||
@@ -523,11 +524,11 @@ export class DownloadManager {
|
|||||||
await this.folderTreeManager.loadTree(treeData.tree);
|
await this.folderTreeManager.loadTree(treeData.tree);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch folder tree:', treeData.error);
|
console.error('Failed to fetch folder tree:', treeData.error);
|
||||||
showToast('Failed to load folder tree', 'error');
|
showToast('toast.import.folderTreeFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing folder tree:', error);
|
console.error('Error initializing folder tree:', error);
|
||||||
showToast('Error loading folder tree', 'error');
|
showToast('toast.import.folderTreeError', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +587,7 @@ export class DownloadManager {
|
|||||||
const modelRoot = document.getElementById('modelRoot').value;
|
const modelRoot = document.getElementById('modelRoot').value;
|
||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
|
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
|
||||||
|
|
||||||
if (modelRoot) {
|
if (modelRoot) {
|
||||||
if (this.useDefaultPath) {
|
if (this.useDefaultPath) {
|
||||||
@@ -598,7 +599,7 @@ export class DownloadManager {
|
|||||||
fullPath += `/${template}`;
|
fullPath += `/${template}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch template:', error);
|
console.error('Failed to fetch template:', error);
|
||||||
fullPath += '/[Auto-organized by path template]';
|
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Show manual path selection
|
// Show manual path selection
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user