mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b1abb719 | ||
|
|
3e2cfb552b | ||
|
|
779be1b8d0 | ||
|
|
faf74de238 | ||
|
|
50a51c2e79 | ||
|
|
d31e641496 | ||
|
|
f2d36f5be9 | ||
|
|
0b55f61fac | ||
|
|
4156dcbafd | ||
|
|
36e6ac2362 | ||
|
|
9613199152 | ||
|
|
14328d7496 | ||
|
|
6af12d1acc | ||
|
|
9b44e49879 | ||
|
|
afee18f146 | ||
|
|
f007369a66 | ||
|
|
9a9c166dbe | ||
|
|
2f90e32dbf | ||
|
|
26355ccb79 | ||
|
|
27ea3c0c8e | ||
|
|
5aa35b211a | ||
|
|
92450385d2 | ||
|
|
8d15e23f3c | ||
|
|
73686d4146 | ||
|
|
0499ca1300 | ||
|
|
234c942f34 | ||
|
|
aec218ba00 | ||
|
|
b508f51fcf | ||
|
|
435628ea59 | ||
|
|
4933dbfb87 | ||
|
|
5a93c40b79 | ||
|
|
a8ec5af037 | ||
|
|
27db60ce68 | ||
|
|
195866b00d | ||
|
|
60575b6546 | ||
|
|
350b81d678 | ||
|
|
cc95314dae | ||
|
|
3f97087abb | ||
|
|
f04af2de21 | ||
|
|
e7871bf843 | ||
|
|
8e3308039a | ||
|
|
b65350b7cb | ||
|
|
069ebce895 | ||
|
|
63aa4e188e | ||
|
|
c31c9c16cf | ||
|
|
5a8a402fdc | ||
|
|
85c3e33343 | ||
|
|
1420ab31a2 | ||
|
|
fd1435537f | ||
|
|
4e0473ce11 | ||
|
|
450592b0d4 | ||
|
|
7cae0ee169 | ||
|
|
ecd0e05f79 | ||
|
|
6e3b4178ac | ||
|
|
ba18cbabfd | ||
|
|
dec757c23b | ||
|
|
0459710c9b | ||
|
|
83582ef8a3 | ||
|
|
0dc396e148 | ||
|
|
86958e1420 | ||
|
|
c5b8e629fb | ||
|
|
b0a495b4f6 | ||
|
|
7d2809467b | ||
|
|
af90eeaf37 | ||
|
|
509e513f3a | ||
|
|
80671e474c | ||
|
|
a166d859e7 | ||
|
|
6af1e0aeb7 | ||
|
|
370ffb5d7c | ||
|
|
0ba288d09e | ||
|
|
008d86983b | ||
|
|
205bdfce5c | ||
|
|
27248b197d | ||
|
|
e216b4c455 | ||
|
|
c402f53258 | ||
|
|
93329abe8b | ||
|
|
f69b3d96b6 | ||
|
|
8690a8f11a | ||
|
|
6aa2342be1 | ||
|
|
042153329b | ||
|
|
2b67091986 | ||
|
|
3da35cf0db | ||
|
|
e566484a17 | ||
|
|
e7dffbbb1e | ||
|
|
a31712ad1f | ||
|
|
2958f81adc | ||
|
|
95380fbbfb | ||
|
|
4cc6996406 | ||
|
|
372d74ec71 | ||
|
|
19ef73a07f | ||
|
|
bb3d73b87c | ||
|
|
30e9e7168f | ||
|
|
fce58f3206 | ||
|
|
b3e5ac395f | ||
|
|
3ebe9d159a | ||
|
|
ff95274757 | ||
|
|
8e653e2173 | ||
|
|
4bff17aa1a | ||
|
|
d4f300645d | ||
|
|
4ee32f02c5 | ||
|
|
2cf4440a1e | ||
|
|
644ee31654 | ||
|
|
34078d8a60 | ||
|
|
5cfae7198d | ||
|
|
6a10cda61f | ||
|
|
c149e73ef7 | ||
|
|
b11757c913 | ||
|
|
607ab35cce | ||
|
|
19ff2ebfe1 | ||
|
|
4a47dc2073 | ||
|
|
addf92d966 | ||
|
|
c987338c84 | ||
|
|
a88b0239eb | ||
|
|
caf5b1528c | ||
|
|
90f74018ae | ||
|
|
d7a253cba3 | ||
|
|
8a28846bac | ||
|
|
04545c5706 | ||
|
|
32fa81cf93 | ||
|
|
7924e4000c | ||
|
|
f9c54690b0 | ||
|
|
c3aaef3916 | ||
|
|
03dfe13769 | ||
|
|
f38b51b85a | ||
|
|
0017a6cce5 | ||
|
|
541ad624c5 | ||
|
|
7c56825f9b | ||
|
|
8a871ae643 | ||
|
|
e2191ab4b4 | ||
|
|
4264dd19a8 | ||
|
|
78f8d4ecc7 | ||
|
|
e2cc3145de | ||
|
|
710857dd41 | ||
|
|
1bfe12a288 | ||
|
|
14a88e2cfa | ||
|
|
0580130d47 | ||
|
|
a4ee82b51f | ||
|
|
1034282161 | ||
|
|
b0a8b0cc6f | ||
|
|
3f38764a0e | ||
|
|
3338c17e8f | ||
|
|
22085e5174 | ||
|
|
d7c643ee9b | ||
|
|
406284a045 | ||
|
|
50babfd471 | ||
|
|
edd36427ac | ||
|
|
9f2289329c | ||
|
|
9a1fe19cc8 | ||
|
|
09f5e2961e | ||
|
|
756ad399bf | ||
|
|
02adced7b8 | ||
|
|
9059795816 | ||
|
|
6920944724 | ||
|
|
c76b287aed | ||
|
|
5c62ec1177 | ||
|
|
09b2fdfc59 | ||
|
|
e498c9ce29 | ||
|
|
9bb4d7078e | ||
|
|
5e4d2c7760 | ||
|
|
426e84cfa3 | ||
|
|
b77df8f89f | ||
|
|
f7c946778d | ||
|
|
81599b8f43 | ||
|
|
9c0dcb2853 | ||
|
|
d3e4534673 | ||
|
|
dd81c86540 | ||
|
|
3620376c3c | ||
|
|
444e8004c7 | ||
|
|
0b0caa1142 | ||
|
|
e7233c147d | ||
|
|
004c203ef2 | ||
|
|
db04c349a7 | ||
|
|
e57a72d12b | ||
|
|
c88388da67 | ||
|
|
2ea0fa8471 | ||
|
|
7f088e58bc | ||
|
|
e992ace11c | ||
|
|
0cad6b5cbc | ||
|
|
e9a703451c | ||
|
|
03ddd51a91 | ||
|
|
9142cc4cde | ||
|
|
8e5e16ce68 | ||
|
|
9a4124c709 | ||
|
|
6f49a73f9c | ||
|
|
a2c51f4779 | ||
|
|
79ebe25bc2 | ||
|
|
ad56cafd62 | ||
|
|
5a6c412845 | ||
|
|
be5e8bad17 | ||
|
|
d63a70474b | ||
|
|
f48b954fb7 | ||
|
|
c48da5300e | ||
|
|
2783782669 | ||
|
|
cbb76580e4 | ||
|
|
2bdecf571b | ||
|
|
72a82707ea | ||
|
|
564e507fa9 | ||
|
|
721bef3ff8 | ||
|
|
1803a9085d | ||
|
|
c3fe58f055 | ||
|
|
0069f84630 | ||
|
|
d69406c4cb | ||
|
|
250e8445bb | ||
|
|
e6aafe8773 | ||
|
|
e8e5012f0c | ||
|
|
d6ed5b7fec | ||
|
|
2186b7ee26 | ||
|
|
7bb6a470b7 | ||
|
|
14aef237a9 | ||
|
|
a01a336259 | ||
|
|
59716ce3c3 | ||
|
|
95dfcee90c | ||
|
|
0c4914909a | ||
|
|
69b1773ced | ||
|
|
a9b3131e64 | ||
|
|
2e47b30ed5 | ||
|
|
53ab6d8ab4 | ||
|
|
5c917b88c7 | ||
|
|
a1e9e440ed | ||
|
|
fb75757d6c | ||
|
|
c9c86d8c0f | ||
|
|
5c521e40d4 | ||
|
|
cfcc954ffe | ||
|
|
61ea42353d | ||
|
|
5e6cce936d | ||
|
|
493dff5c19 | ||
|
|
520a4b10ae | ||
|
|
f614dbf700 | ||
|
|
c17f2c885a | ||
|
|
879df0d563 |
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### **LoRA Manager Version**
|
||||
- Version: `vX.X.X`
|
||||
|
||||
### **Environment Information**
|
||||
- **Operating System**: (e.g., Windows 11, macOS Ventura, Ubuntu 22.04)
|
||||
- **Browser & Version**: (e.g., Chrome 120.0.0, Edge 115.0.0)
|
||||
|
||||
### **Issue Description**
|
||||
- Describe the issue in detail.
|
||||
|
||||
### **Steps to Reproduce**
|
||||
1. Open LoRA Manager in [your browser].
|
||||
2. Perform [specific action].
|
||||
3. Observe the issue.
|
||||
|
||||
### **Expected Behavior**
|
||||
- What did you expect to happen?
|
||||
|
||||
### **Screenshots** *(If applicable)*
|
||||
- Upload screenshots or screen recordings.
|
||||
|
||||
### **Logs**
|
||||
- Provide the **ComfyUI startup log** and any relevant error messages.
|
||||
- Check the browser developer console (F12 → Console tab) and attach any errors.
|
||||
|
||||
### **Additional Context** *(Optional)*
|
||||
- Any other relevant details.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
__pycache__/
|
||||
settings.json
|
||||
settings.json
|
||||
output/*
|
||||
py/run_test.py
|
||||
.vscode/
|
||||
|
||||
97
README.md
97
README.md
@@ -1,53 +1,58 @@
|
||||
# ComfyUI LoRA Manager
|
||||
|
||||
A web-based management interface designed to help you organize and manage your local LoRA models in ComfyUI. Access the interface at: `http://localhost:8188/loras`
|
||||
> **Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!**
|
||||
|
||||

|
||||
[](https://discord.gg/vcqNrWVFvM)
|
||||
[](https://github.com/willmiao/ComfyUI-Lora-Manager/releases)
|
||||
[](https://github.com/willmiao/ComfyUI-Lora-Manager/releases)
|
||||
|
||||
A comprehensive toolset that streamlines organizing, downloading, and applying LoRA models in ComfyUI. With powerful features like recipe management and one-click workflow integration, working with LoRAs becomes faster, smoother, and significantly easier. Access the interface at: `http://localhost:8188/loras`
|
||||
|
||||

|
||||
|
||||
## 📺 Tutorial: One-Click LoRA Integration
|
||||
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
||||
|
||||
[](https://youtu.be/qS95OjX3e70)
|
||||
[](https://youtu.be/noN7f_ER7yo)
|
||||
|
||||
---
|
||||
|
||||
## [Update 0.7.33] Enhanced UI Controls & Trigger Word Management
|
||||
- 🎛️ **Enhanced LoRA Loader Node**:
|
||||
- New visual widgets for easier LoRA strength adjustment
|
||||
- Toggle switches for quick LoRA enable/disable
|
||||
- Image tooltips for quick LoRA preview identification
|
||||
- Improved UI for managing multiple LoRAs
|
||||
## Release Notes
|
||||
|
||||
- 🏷️ **New TriggerWord Toggle Node**:
|
||||
- Visual display of applicable trigger words
|
||||
- Click-to-toggle individual trigger words
|
||||
- Easy exclusion of unwanted trigger words
|
||||
- Connects directly after LoRA Loader node
|
||||
### v0.8.4
|
||||
* **Node Layout Improvements** - Fixed layout issues with LoRA Loader and Trigger Words Toggle nodes in newer ComfyUI frontend versions
|
||||
* **Recipe LoRA Reconnection** - Added ability to reconnect deleted LoRAs in recipes by clicking the "deleted" badge in recipe details
|
||||
* **Bug Fixes & Stability** - Resolved various issues for improved reliability
|
||||
|
||||
- 🐛 **Bug Fixes and Stability Improvements**
|
||||
### v0.8.3
|
||||
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility
|
||||
* **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration
|
||||
* **New Save Image Node** - Added experimental node with metadata support for perfect CivitAI compatibility
|
||||
* Supports dynamic filename prefixes with variables [1](https://github.com/nkchocoai/ComfyUI-SaveImageWithMetaData?tab=readme-ov-file#filename_prefix)
|
||||
* **Default LoRA Root Setting** - Added configuration option for setting your preferred LoRA directory
|
||||
|
||||
## [Update 0.7.3] One-Click Integration & Workflow Automation
|
||||
- ✨ **One-Click LoRA Integration**:
|
||||
- Use "Lora Loader (LoraManager)" custom node in workflows
|
||||
- Copy LoRA syntax directly from manager interface
|
||||
- Auto-applies preset strength values
|
||||
- Auto-loads trigger words when available
|
||||
- Example workflow included
|
||||
### v0.8.2
|
||||
* **Faster Initialization for Forge Users** - Improved first-run efficiency by utilizing existing `.json` and `.civitai.info` files from Forge’s CivitAI helper extension, making migration smoother.
|
||||
* **LoRA Filename Editing** - Added support for renaming LoRA files directly within LoRA Manager.
|
||||
* **Recipe Editing** - Users can now edit recipe names and tags.
|
||||
* **Retain Deleted LoRAs in Recipes** - Deleted LoRAs will remain listed in recipes, allowing future functionality to reconnect them once re-obtained.
|
||||
* **Download Missing LoRAs from Recipes** - Easily fetch missing LoRAs associated with a recipe.
|
||||
|
||||
## [Update 0.7.0] Major Features Enhancement
|
||||
### v0.8.1
|
||||
* **Base Model Correction** - Added support for modifying base model associations to fix incorrect metadata for non-CivitAI LoRAs
|
||||
* **LoRA Loader Flexibility** - Made CLIP input optional for model-only workflows like Hunyuan video generation
|
||||
* **Expanded Recipe Support** - Added compatibility with 3 additional recipe metadata formats
|
||||
* **Enhanced Showcase Images** - Generation parameters now displayed alongside LoRA preview images
|
||||
* **UI Improvements & Bug Fixes** - Various interface refinements and stability enhancements
|
||||
|
||||
- 🚀 **Direct CivitAI Integration**:
|
||||
- Download LoRAs directly from CivitAI URLs
|
||||
- Version selection support for model downloads
|
||||
- Choose target folder for downloads
|
||||
- 📋 **New Context Menu Features**:
|
||||
- Right-click menu for quick actions
|
||||
- Force refresh CivitAI data
|
||||
- Move LoRAs between folders
|
||||
- 📝 **Enhanced Model Details**:
|
||||
- Save personal usage tips
|
||||
- Add custom notes for each LoRA
|
||||
- Improved performance for details window
|
||||
### v0.8.0
|
||||
* **Introduced LoRA Recipes** - Create, import, save, and share your favorite LoRA combinations
|
||||
* **Recipe Management System** - Easily browse, search, and organize your LoRA recipes
|
||||
* **Workflow Integration** - Save recipes directly from your workflow with generation parameters preserved
|
||||
* **Simplified Workflow Application** - Quickly apply saved recipes to new projects
|
||||
* **Enhanced UI & UX** - Improved interface design and user experience
|
||||
* **Bug Fixes & Stability** - Resolved various issues and enhanced overall performance
|
||||
|
||||
[View Update History](./update_logs.md)
|
||||
|
||||
@@ -81,6 +86,12 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
- Trigger words at a glance
|
||||
- One-click workflow integration with preset values
|
||||
|
||||
- 🧩 **LoRA Recipes**
|
||||
- Save and share favorite LoRA combinations
|
||||
- Preserve generation parameters for future reference
|
||||
- Quick application to workflows
|
||||
- Import/export functionality for community sharing
|
||||
|
||||
- 💻 **User Friendly**
|
||||
- One-click access from ComfyUI menu
|
||||
- Context menu for quick actions
|
||||
@@ -126,6 +137,15 @@ pip install requirements.txt
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
This project has been inspired by and benefited from other excellent ComfyUI extensions:
|
||||
|
||||
- [ComfyUI-SaveImageWithMetaData](https://github.com/Comfy-Community/ComfyUI-SaveImageWithMetaData) - For the image metadata functionality
|
||||
- [rgthree-comfy](https://github.com/rgthree/rgthree-comfy) - For the lora loader functionality
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
If you have suggestions, bug reports, or improvements, feel free to open an issue or contribute directly to the codebase. Pull requests are always welcome!
|
||||
@@ -144,12 +164,3 @@ Join our Discord community for support, discussions, and updates:
|
||||
[Discord Server](https://discord.gg/vcqNrWVFvM)
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- ✅ One-click integration of LoRAs into ComfyUI workflows with preset strength values
|
||||
- 🤝 Improved usage tips retrieval from CivitAI model pages
|
||||
- 🔌 Integration with Power LoRA Loader and other management tools
|
||||
- 🛡️ Configurable NSFW level settings for content filtering
|
||||
|
||||
---
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from .py.lora_manager import LoraManager
|
||||
from .py.nodes.lora_loader import LoraManagerLoader
|
||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||
from .py.nodes.lora_stacker import LoraStacker
|
||||
from .py.nodes.save_image import SaveImage
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||
TriggerWordToggle.NAME: TriggerWordToggle
|
||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||
LoraStacker.NAME: LoraStacker,
|
||||
SaveImage.NAME: SaveImage
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
# Register routes on import
|
||||
LoraManager.add_routes()
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
|
||||
|
||||
16
py/config.py
16
py/config.py
@@ -17,6 +17,7 @@ class Config:
|
||||
# 静态路由映射字典, target to route mapping
|
||||
self._route_mappings = {}
|
||||
self.loras_roots = self._init_lora_paths()
|
||||
self.temp_directory = folder_paths.get_temp_directory()
|
||||
# 在初始化时扫描符号链接
|
||||
self._scan_symbolic_links()
|
||||
|
||||
@@ -84,12 +85,23 @@ class Config:
|
||||
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
||||
return mapped_path
|
||||
return path
|
||||
|
||||
def map_link_to_path(self, link_path: str) -> str:
|
||||
"""将符号链接路径映射回实际路径"""
|
||||
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
||||
# 检查路径是否包含在任何映射的目标路径中
|
||||
for target_path, link_path in self._path_mappings.items():
|
||||
if normalized_link.startswith(target_path):
|
||||
# 如果路径以目标路径开头,则替换为实际路径
|
||||
mapped_path = normalized_link.replace(target_path, link_path, 1)
|
||||
return mapped_path
|
||||
return link_path
|
||||
|
||||
def _init_lora_paths(self) -> List[str]:
|
||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||
paths = list(set(path.replace(os.sep, "/")
|
||||
paths = sorted(set(path.replace(os.sep, "/")
|
||||
for path in folder_paths.get_folder_paths("loras")
|
||||
if os.path.exists(path)))
|
||||
if os.path.exists(path)), key=lambda p: p.lower())
|
||||
print("Found LoRA roots:", "\n - " + "\n - ".join(paths))
|
||||
|
||||
if not paths:
|
||||
|
||||
@@ -4,9 +4,13 @@ from server import PromptServer # type: ignore
|
||||
from .config import config
|
||||
from .routes.lora_routes import LoraRoutes
|
||||
from .routes.api_routes import ApiRoutes
|
||||
from .routes.recipe_routes import RecipeRoutes
|
||||
from .routes.checkpoints_routes import CheckpointsRoutes
|
||||
from .services.lora_scanner import LoraScanner
|
||||
from .services.recipe_scanner import RecipeScanner
|
||||
from .services.file_monitor import LoraFileMonitor
|
||||
from .services.lora_cache import LoraCache
|
||||
from .services.recipe_cache import RecipeCache
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,36 +60,42 @@ class LoraManager:
|
||||
|
||||
# Setup feature routes
|
||||
routes = LoraRoutes()
|
||||
checkpoints_routes = CheckpointsRoutes()
|
||||
|
||||
# Setup file monitoring
|
||||
monitor = LoraFileMonitor(routes.scanner, config.loras_roots)
|
||||
monitor.start()
|
||||
|
||||
routes.setup_routes(app)
|
||||
checkpoints_routes.setup_routes(app)
|
||||
ApiRoutes.setup_routes(app, monitor)
|
||||
RecipeRoutes.setup_routes(app)
|
||||
|
||||
# Store monitor in app for cleanup
|
||||
app['lora_monitor'] = monitor
|
||||
|
||||
# Schedule cache initialization using the application's startup handler
|
||||
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner))
|
||||
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner, routes.recipe_scanner))
|
||||
|
||||
# Add cleanup
|
||||
app.on_shutdown.append(cls._cleanup)
|
||||
app.on_shutdown.append(ApiRoutes.cleanup)
|
||||
|
||||
@classmethod
|
||||
async def _schedule_cache_init(cls, scanner: LoraScanner):
|
||||
async def _schedule_cache_init(cls, scanner: LoraScanner, recipe_scanner: RecipeScanner):
|
||||
"""Schedule cache initialization in the running event loop"""
|
||||
try:
|
||||
# 创建低优先级的初始化任务
|
||||
asyncio.create_task(cls._initialize_cache(scanner), name='lora_cache_init')
|
||||
lora_task = asyncio.create_task(cls._initialize_lora_cache(scanner), name='lora_cache_init')
|
||||
|
||||
# Schedule recipe cache initialization with a delay to let lora scanner initialize first
|
||||
recipe_task = asyncio.create_task(cls._initialize_recipe_cache(recipe_scanner, delay=2), name='recipe_cache_init')
|
||||
except Exception as e:
|
||||
print(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||
logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _initialize_cache(cls, scanner: LoraScanner):
|
||||
"""Initialize cache in background"""
|
||||
async def _initialize_lora_cache(cls, scanner: LoraScanner):
|
||||
"""Initialize lora cache in background"""
|
||||
try:
|
||||
# 设置初始缓存占位
|
||||
scanner._cache = LoraCache(
|
||||
@@ -98,10 +108,29 @@ class LoraManager:
|
||||
# 分阶段加载缓存
|
||||
await scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as e:
|
||||
print(f"LoRA Manager: Error initializing cache: {e}")
|
||||
logger.error(f"LoRA Manager: Error initializing lora cache: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _initialize_recipe_cache(cls, scanner: RecipeScanner, delay: float = 2.0):
|
||||
"""Initialize recipe cache in background with a delay"""
|
||||
try:
|
||||
# Wait for the specified delay to let lora scanner initialize first
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Set initial empty cache
|
||||
scanner._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
|
||||
# Force refresh to load the actual data
|
||||
await scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA Manager: Error initializing recipe cache: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _cleanup(cls, app):
|
||||
"""Cleanup resources"""
|
||||
if 'lora_monitor' in app:
|
||||
app['lora_monitor'].stop()
|
||||
app['lora_monitor'].stop()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from nodes import LoraLoader
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
@@ -6,16 +7,18 @@ import asyncio
|
||||
import os
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LoraManagerLoader:
|
||||
NAME = "Lora Loader (LoraManager)"
|
||||
CATEGORY = "loaders"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
# "clip": ("CLIP",),
|
||||
"text": (IO.STRING, {
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
@@ -26,8 +29,8 @@ class LoraManagerLoader:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING)
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words")
|
||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
async def get_lora_info(self, lora_name):
|
||||
@@ -49,29 +52,73 @@ class LoraManagerLoader:
|
||||
return relative_path, trigger_words
|
||||
return lora_name, [] # Fallback if not found
|
||||
|
||||
def load_loras(self, model, clip, text, **kwargs):
|
||||
"""Loads multiple LoRAs based on the kwargs input."""
|
||||
def extract_lora_name(self, lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
|
||||
def _get_loras_list(self, kwargs):
|
||||
"""Helper to extract loras list from either old or new kwargs format"""
|
||||
if 'loras' not in kwargs:
|
||||
return []
|
||||
|
||||
loras_data = kwargs['loras']
|
||||
# Handle new format: {'loras': {'__value__': [...]}}
|
||||
if isinstance(loras_data, dict) and '__value__' in loras_data:
|
||||
return loras_data['__value__']
|
||||
# Handle old format: {'loras': [...]}
|
||||
elif isinstance(loras_data, list):
|
||||
return loras_data
|
||||
# Unexpected format
|
||||
else:
|
||||
logger.warning(f"Unexpected loras format: {type(loras_data)}")
|
||||
return []
|
||||
|
||||
def load_loras(self, model, text, **kwargs):
|
||||
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
||||
loaded_loras = []
|
||||
all_trigger_words = []
|
||||
|
||||
if 'loras' in kwargs:
|
||||
for lora in kwargs['loras']:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
strength = float(lora['strength'])
|
||||
clip = kwargs.get('clip', None)
|
||||
lora_stack = kwargs.get('lora_stack', None)
|
||||
# First process lora_stack if available
|
||||
if lora_stack:
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
# Apply the LoRA using the provided path and strengths
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||
# Extract lora name for trigger words lookup
|
||||
lora_name = self.extract_lora_name(lora_path)
|
||||
_, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||
|
||||
# Apply the LoRA using the resolved path
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
|
||||
loaded_loras.append(f"{lora_name}: {strength}")
|
||||
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
trigger_words_text = ", ".join(all_trigger_words) if all_trigger_words else ""
|
||||
# Then process loras from kwargs with support for both old and new formats
|
||||
loras_list = self._get_loras_list(kwargs)
|
||||
for lora in loras_list:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
strength = float(lora['strength'])
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||
|
||||
# Apply the LoRA using the resolved path
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
|
||||
loaded_loras.append(f"{lora_name}: {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 as <lora:lora_name:strength> separated by spaces
|
||||
formatted_loras = " ".join([f"<lora:{name.split(':')[0].strip()}:{str(strength).strip()}>"
|
||||
for name, strength in [item.split(':') for item in loaded_loras]])
|
||||
|
||||
return (model, clip, trigger_words_text)
|
||||
return (model, clip, trigger_words_text, formatted_loras)
|
||||
118
py/nodes/lora_stacker.py
Normal file
118
py/nodes/lora_stacker.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
from ..config import config
|
||||
import asyncio
|
||||
import os
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LoraStacker:
|
||||
NAME = "Lora Stacker (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (IO.STRING, {
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK", IO.STRING, IO.STRING)
|
||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||
FUNCTION = "stack_loras"
|
||||
|
||||
async def get_lora_info(self, lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
scanner = await LoraScanner.get_instance()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
if file_path:
|
||||
for root in config.loras_roots:
|
||||
root = root.replace(os.sep, '/')
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(os.sep, '/')
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get('civitai', {})
|
||||
trigger_words = civitai.get('trainedWords', []) if civitai else []
|
||||
return relative_path, trigger_words
|
||||
return lora_name, [] # Fallback if not found
|
||||
|
||||
def extract_lora_name(self, lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
|
||||
def _get_loras_list(self, kwargs):
|
||||
"""Helper to extract loras list from either old or new kwargs format"""
|
||||
if 'loras' not in kwargs:
|
||||
return []
|
||||
|
||||
loras_data = kwargs['loras']
|
||||
# Handle new format: {'loras': {'__value__': [...]}}
|
||||
if isinstance(loras_data, dict) and '__value__' in loras_data:
|
||||
return loras_data['__value__']
|
||||
# Handle old format: {'loras': [...]}
|
||||
elif isinstance(loras_data, list):
|
||||
return loras_data
|
||||
# Unexpected format
|
||||
else:
|
||||
logger.warning(f"Unexpected loras format: {type(loras_data)}")
|
||||
return []
|
||||
|
||||
def stack_loras(self, text, **kwargs):
|
||||
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
|
||||
stack = []
|
||||
active_loras = []
|
||||
all_trigger_words = []
|
||||
|
||||
# Process existing lora_stack if available
|
||||
lora_stack = kwargs.get('lora_stack', None)
|
||||
if lora_stack:
|
||||
stack.extend(lora_stack)
|
||||
# Get trigger words from existing stack entries
|
||||
for lora_path, _, _ in lora_stack:
|
||||
lora_name = self.extract_lora_name(lora_path)
|
||||
_, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Process loras from kwargs with support for both old and new formats
|
||||
loras_list = self._get_loras_list(kwargs)
|
||||
for lora in loras_list:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
model_strength = float(lora['strength'])
|
||||
clip_strength = model_strength # Using same strength for both as in the original loader
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||
|
||||
# Add to stack without loading
|
||||
# replace '/' with os.sep to avoid different OS path format
|
||||
stack.append((lora_path.replace('/', os.sep), model_strength, clip_strength))
|
||||
active_loras.append((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 active_loras as <lora:lora_name:strength> separated by spaces
|
||||
active_loras_text = " ".join([f"<lora:{name}:{str(strength).strip()}>"
|
||||
for name, strength in active_loras])
|
||||
|
||||
return (stack, trigger_words_text, active_loras_text)
|
||||
375
py/nodes/save_image.py
Normal file
375
py/nodes/save_image.py
Normal file
@@ -0,0 +1,375 @@
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import re
|
||||
import numpy as np
|
||||
import folder_paths # type: ignore
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
from ..workflow.parser import WorkflowParser
|
||||
from PIL import Image, PngImagePlugin
|
||||
import piexif
|
||||
from io import BytesIO
|
||||
|
||||
class SaveImage:
|
||||
NAME = "Save Image (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
self.counter = 0
|
||||
|
||||
# Add pattern format regex for filename substitution
|
||||
pattern_format = re.compile(r"(%[^%]+%)")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
"file_format": (["png", "jpeg", "webp"],),
|
||||
},
|
||||
"optional": {
|
||||
"custom_prompt": ("STRING", {"default": "", "forceInput": True}),
|
||||
"lossless_webp": ("BOOLEAN", {"default": True}),
|
||||
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
|
||||
"embed_workflow": ("BOOLEAN", {"default": False}),
|
||||
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "process_image"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
async def get_lora_hash(self, lora_name):
|
||||
"""Get the lora hash from cache"""
|
||||
scanner = await LoraScanner.get_instance()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
return item.get('sha256')
|
||||
return None
|
||||
|
||||
async def format_metadata(self, parsed_workflow, custom_prompt=None):
|
||||
"""Format metadata in the requested format similar to userComment example"""
|
||||
if not parsed_workflow:
|
||||
return ""
|
||||
|
||||
# Extract the prompt and negative prompt
|
||||
prompt = parsed_workflow.get('prompt', '')
|
||||
negative_prompt = parsed_workflow.get('negative_prompt', '')
|
||||
|
||||
# Override prompt with custom_prompt if provided
|
||||
if custom_prompt:
|
||||
prompt = custom_prompt
|
||||
|
||||
# Extract loras from the prompt if present
|
||||
loras_text = parsed_workflow.get('loras', '')
|
||||
lora_hashes = {}
|
||||
|
||||
# If loras are found, add them on a new line after the prompt
|
||||
if loras_text:
|
||||
prompt_with_loras = f"{prompt}\n{loras_text}"
|
||||
|
||||
# Extract lora names from the format <lora:name:strength>
|
||||
lora_matches = re.findall(r'<lora:([^:]+):([^>]+)>', loras_text)
|
||||
|
||||
# Get hash for each lora
|
||||
for lora_name, strength in lora_matches:
|
||||
hash_value = await self.get_lora_hash(lora_name)
|
||||
if hash_value:
|
||||
lora_hashes[lora_name] = hash_value
|
||||
else:
|
||||
prompt_with_loras = prompt
|
||||
|
||||
# Format the first part (prompt and loras)
|
||||
metadata_parts = [prompt_with_loras]
|
||||
|
||||
# Add negative prompt
|
||||
if negative_prompt:
|
||||
metadata_parts.append(f"Negative prompt: {negative_prompt}")
|
||||
|
||||
# Format the second part (generation parameters)
|
||||
params = []
|
||||
|
||||
# Add standard parameters in the correct order
|
||||
if 'steps' in parsed_workflow:
|
||||
params.append(f"Steps: {parsed_workflow.get('steps')}")
|
||||
|
||||
if 'sampler' in parsed_workflow:
|
||||
sampler = parsed_workflow.get('sampler')
|
||||
# Convert ComfyUI sampler names to user-friendly names
|
||||
sampler_mapping = {
|
||||
'euler': 'Euler',
|
||||
'euler_ancestral': 'Euler a',
|
||||
'dpm_2': 'DPM2',
|
||||
'dpm_2_ancestral': 'DPM2 a',
|
||||
'heun': 'Heun',
|
||||
'dpm_fast': 'DPM fast',
|
||||
'dpm_adaptive': 'DPM adaptive',
|
||||
'lms': 'LMS',
|
||||
'dpmpp_2s_ancestral': 'DPM++ 2S a',
|
||||
'dpmpp_sde': 'DPM++ SDE',
|
||||
'dpmpp_sde_gpu': 'DPM++ SDE',
|
||||
'dpmpp_2m': 'DPM++ 2M',
|
||||
'dpmpp_2m_sde': 'DPM++ 2M SDE',
|
||||
'dpmpp_2m_sde_gpu': 'DPM++ 2M SDE',
|
||||
'ddim': 'DDIM'
|
||||
}
|
||||
sampler_name = sampler_mapping.get(sampler, sampler)
|
||||
params.append(f"Sampler: {sampler_name}")
|
||||
|
||||
if 'scheduler' in parsed_workflow:
|
||||
scheduler = parsed_workflow.get('scheduler')
|
||||
scheduler_mapping = {
|
||||
'normal': 'Simple',
|
||||
'karras': 'Karras',
|
||||
'exponential': 'Exponential',
|
||||
'sgm_uniform': 'SGM Uniform',
|
||||
'sgm_quadratic': 'SGM Quadratic'
|
||||
}
|
||||
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
|
||||
params.append(f"Schedule type: {scheduler_name}")
|
||||
|
||||
# CFG scale (cfg in parsed_workflow)
|
||||
if 'cfg_scale' in parsed_workflow:
|
||||
params.append(f"CFG scale: {parsed_workflow.get('cfg_scale')}")
|
||||
elif 'cfg' in parsed_workflow:
|
||||
params.append(f"CFG scale: {parsed_workflow.get('cfg')}")
|
||||
|
||||
# Seed
|
||||
if 'seed' in parsed_workflow:
|
||||
params.append(f"Seed: {parsed_workflow.get('seed')}")
|
||||
|
||||
# Size
|
||||
if 'size' in parsed_workflow:
|
||||
params.append(f"Size: {parsed_workflow.get('size')}")
|
||||
|
||||
# Model info
|
||||
if 'checkpoint' in parsed_workflow:
|
||||
# Extract basename without path
|
||||
checkpoint = os.path.basename(parsed_workflow.get('checkpoint', ''))
|
||||
# Remove extension if present
|
||||
checkpoint = os.path.splitext(checkpoint)[0]
|
||||
params.append(f"Model: {checkpoint}")
|
||||
|
||||
# Add LoRA hashes if available
|
||||
if lora_hashes:
|
||||
lora_hash_parts = []
|
||||
for lora_name, hash_value in lora_hashes.items():
|
||||
lora_hash_parts.append(f"{lora_name}: {hash_value}")
|
||||
|
||||
if lora_hash_parts:
|
||||
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
|
||||
|
||||
# Combine all parameters with commas
|
||||
metadata_parts.append(", ".join(params))
|
||||
|
||||
# Join all parts with a new line
|
||||
return "\n".join(metadata_parts)
|
||||
|
||||
# credit to nkchocoai
|
||||
# Add format_filename method to handle pattern substitution
|
||||
def format_filename(self, filename, parsed_workflow):
|
||||
"""Format filename with metadata values"""
|
||||
if not parsed_workflow:
|
||||
return filename
|
||||
|
||||
result = re.findall(self.pattern_format, filename)
|
||||
for segment in result:
|
||||
parts = segment.replace("%", "").split(":")
|
||||
key = parts[0]
|
||||
|
||||
if key == "seed" and 'seed' in parsed_workflow:
|
||||
filename = filename.replace(segment, str(parsed_workflow.get('seed', '')))
|
||||
elif key == "width" and 'size' in parsed_workflow:
|
||||
size = parsed_workflow.get('size', 'x')
|
||||
w = size.split('x')[0] if isinstance(size, str) else size[0]
|
||||
filename = filename.replace(segment, str(w))
|
||||
elif key == "height" and 'size' in parsed_workflow:
|
||||
size = parsed_workflow.get('size', 'x')
|
||||
h = size.split('x')[1] if isinstance(size, str) else size[1]
|
||||
filename = filename.replace(segment, str(h))
|
||||
elif key == "pprompt" and 'prompt' in parsed_workflow:
|
||||
prompt = parsed_workflow.get('prompt', '').replace("\n", " ")
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "nprompt" and 'negative_prompt' in parsed_workflow:
|
||||
prompt = parsed_workflow.get('negative_prompt', '').replace("\n", " ")
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "model" and 'checkpoint' in parsed_workflow:
|
||||
model = parsed_workflow.get('checkpoint', '')
|
||||
model = os.path.splitext(os.path.basename(model))[0]
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
model = model[:length]
|
||||
filename = filename.replace(segment, model)
|
||||
elif key == "date":
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
date_table = {
|
||||
"yyyy": str(now.year),
|
||||
"MM": str(now.month).zfill(2),
|
||||
"dd": str(now.day).zfill(2),
|
||||
"hh": str(now.hour).zfill(2),
|
||||
"mm": str(now.minute).zfill(2),
|
||||
"ss": str(now.second).zfill(2),
|
||||
}
|
||||
if len(parts) >= 2:
|
||||
date_format = parts[1]
|
||||
for k, v in date_table.items():
|
||||
date_format = date_format.replace(k, v)
|
||||
filename = filename.replace(segment, date_format)
|
||||
else:
|
||||
date_format = "yyyyMMddhhmmss"
|
||||
for k, v in date_table.items():
|
||||
date_format = date_format.replace(k, v)
|
||||
filename = filename.replace(segment, date_format)
|
||||
|
||||
return filename
|
||||
|
||||
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
|
||||
custom_prompt=None):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
|
||||
# Parse the workflow using the WorkflowParser
|
||||
parser = WorkflowParser()
|
||||
if prompt:
|
||||
parsed_workflow = parser.parse_workflow(prompt)
|
||||
else:
|
||||
parsed_workflow = {}
|
||||
|
||||
# Get or create metadata asynchronously
|
||||
metadata = asyncio.run(self.format_metadata(parsed_workflow, custom_prompt))
|
||||
|
||||
# Process filename_prefix with pattern substitution
|
||||
filename_prefix = self.format_filename(filename_prefix, parsed_workflow)
|
||||
|
||||
# Get initial save path info once for the batch
|
||||
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
if not os.path.exists(full_output_folder):
|
||||
os.makedirs(full_output_folder, exist_ok=True)
|
||||
|
||||
# Process each image with incrementing counter
|
||||
for i, image in enumerate(images):
|
||||
# Convert the tensor image to numpy array
|
||||
img = 255. * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
base_filename += f"_{current_counter:05}"
|
||||
|
||||
# Set file extension and prepare saving parameters
|
||||
if file_format == "png":
|
||||
file = base_filename + ".png"
|
||||
file_extension = ".png"
|
||||
save_kwargs = {"optimize": True, "compress_level": self.compress_level}
|
||||
pnginfo = PngImagePlugin.PngInfo()
|
||||
elif file_format == "jpeg":
|
||||
file = base_filename + ".jpg"
|
||||
file_extension = ".jpg"
|
||||
save_kwargs = {"quality": quality, "optimize": True}
|
||||
elif file_format == "webp":
|
||||
file = base_filename + ".webp"
|
||||
file_extension = ".webp"
|
||||
save_kwargs = {"quality": quality, "lossless": lossless_webp}
|
||||
|
||||
# Full save path
|
||||
file_path = os.path.join(full_output_folder, file)
|
||||
|
||||
# Save the image with metadata
|
||||
try:
|
||||
if file_format == "png":
|
||||
if metadata:
|
||||
pnginfo.add_text("parameters", metadata)
|
||||
if embed_workflow and extra_pnginfo is not None:
|
||||
workflow_json = json.dumps(extra_pnginfo["workflow"])
|
||||
pnginfo.add_text("workflow", workflow_json)
|
||||
save_kwargs["pnginfo"] = pnginfo
|
||||
img.save(file_path, format="PNG", **save_kwargs)
|
||||
elif file_format == "jpeg":
|
||||
# For JPEG, use piexif
|
||||
if metadata:
|
||||
try:
|
||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
except Exception as e:
|
||||
print(f"Error adding EXIF data: {e}")
|
||||
img.save(file_path, format="JPEG", **save_kwargs)
|
||||
elif file_format == "webp":
|
||||
# For WebP, also use piexif for metadata
|
||||
if metadata:
|
||||
try:
|
||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
except Exception as e:
|
||||
print(f"Error adding EXIF data: {e}")
|
||||
img.save(file_path, format="WEBP", **save_kwargs)
|
||||
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving image: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
|
||||
custom_prompt=""):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
# Ensure images is always a list of images
|
||||
if len(images.shape) == 3: # Single image (height, width, channels)
|
||||
images = [images]
|
||||
else: # Multiple images (batch, height, width, channels)
|
||||
images = [img for img in images]
|
||||
|
||||
# Save all images
|
||||
results = self.save_images(
|
||||
images,
|
||||
filename_prefix,
|
||||
file_format,
|
||||
prompt,
|
||||
extra_pnginfo,
|
||||
lossless_webp,
|
||||
quality,
|
||||
embed_workflow,
|
||||
add_counter_to_filename,
|
||||
custom_prompt if custom_prompt.strip() else None
|
||||
)
|
||||
|
||||
return (images,)
|
||||
@@ -1,17 +1,22 @@
|
||||
import json
|
||||
import re
|
||||
from server import PromptServer # type: ignore
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TriggerWordToggle:
|
||||
NAME = "TriggerWord Toggle (LoraManager)"
|
||||
CATEGORY = "lora manager"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = "Toggle trigger words on/off"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"trigger_words": ("STRING", {"defaultInput": True, "forceInput": True}),
|
||||
"group_mode": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
"hidden": {
|
||||
@@ -23,7 +28,24 @@ class TriggerWordToggle:
|
||||
RETURN_NAMES = ("filtered_trigger_words",)
|
||||
FUNCTION = "process_trigger_words"
|
||||
|
||||
def process_trigger_words(self, trigger_words, id, **kwargs):
|
||||
def _get_toggle_data(self, kwargs, key='toggle_trigger_words'):
|
||||
"""Helper to extract data from either old or new kwargs format"""
|
||||
if key not in kwargs:
|
||||
return None
|
||||
|
||||
data = kwargs[key]
|
||||
# Handle new format: {'key': {'__value__': ...}}
|
||||
if isinstance(data, dict) and '__value__' in data:
|
||||
return data['__value__']
|
||||
# Handle old format: {'key': ...}
|
||||
else:
|
||||
return data
|
||||
|
||||
def process_trigger_words(self, id, group_mode, **kwargs):
|
||||
# Handle both old and new formats for trigger_words
|
||||
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
|
||||
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
|
||||
|
||||
# Send trigger words to frontend
|
||||
PromptServer.instance.send_sync("trigger_word_update", {
|
||||
"id": id,
|
||||
@@ -32,31 +54,43 @@ class TriggerWordToggle:
|
||||
|
||||
filtered_triggers = trigger_words
|
||||
|
||||
if 'toggle_trigger_words' in kwargs:
|
||||
# Get toggle data with support for both formats
|
||||
trigger_data = self._get_toggle_data(kwargs, 'toggle_trigger_words')
|
||||
if trigger_data:
|
||||
try:
|
||||
# Get trigger word toggle data
|
||||
trigger_data = kwargs['toggle_trigger_words']
|
||||
|
||||
# Convert to list if it's a JSON string
|
||||
if isinstance(trigger_data, str):
|
||||
trigger_data = json.loads(trigger_data)
|
||||
|
||||
# Create dictionaries to track active state of words
|
||||
# Create dictionaries to track active state of words or groups
|
||||
active_state = {item['text']: item.get('active', False) for item in trigger_data}
|
||||
|
||||
# Split original trigger words
|
||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
||||
|
||||
# Filter words: keep those not in toggle_trigger_words or those that are active
|
||||
filtered_words = [word for word in original_words if word not in active_state or active_state[word]]
|
||||
|
||||
# Join them in the same format as input
|
||||
if filtered_words:
|
||||
filtered_triggers = ', '.join(filtered_words)
|
||||
if group_mode:
|
||||
# Split by two or more consecutive commas to get groups
|
||||
groups = re.split(r',{2,}', trigger_words)
|
||||
# Remove leading/trailing whitespace from each group
|
||||
groups = [group.strip() for group in groups]
|
||||
|
||||
# Filter groups: keep those not in toggle_trigger_words or those that are active
|
||||
filtered_groups = [group for group in groups if group not in active_state or active_state[group]]
|
||||
|
||||
if filtered_groups:
|
||||
filtered_triggers = ', '.join(filtered_groups)
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
# Original behavior for individual words mode
|
||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
||||
# Filter out empty strings
|
||||
original_words = [word for word in original_words if word]
|
||||
filtered_words = [word for word in original_words if word not in active_state or active_state[word]]
|
||||
|
||||
if filtered_words:
|
||||
filtered_triggers = ', '.join(filtered_words)
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing trigger words: {e}")
|
||||
logger.error(f"Error processing trigger words: {e}")
|
||||
|
||||
return (filtered_triggers,)
|
||||
@@ -4,6 +4,7 @@ class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
# Credit to Regis Gaughan, III (rgthree)
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""A special class to make flexible nodes that pass data to our python handlers.
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from aiohttp import web
|
||||
from typing import Dict, List
|
||||
|
||||
from ..utils.model_utils import determine_base_model
|
||||
|
||||
from ..services.file_monitor import LoraFileMonitor
|
||||
from ..services.download_manager import DownloadManager
|
||||
from ..services.civitai_client import CivitaiClient
|
||||
@@ -13,6 +15,8 @@ from operator import itemgetter
|
||||
from ..services.websocket_manager import ws_manager
|
||||
from ..services.settings_manager import settings
|
||||
import asyncio
|
||||
from .update_routes import UpdateRoutes
|
||||
from ..services.recipe_scanner import RecipeScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,12 +40,24 @@ class ApiRoutes:
|
||||
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
|
||||
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
|
||||
app.router.add_get('/api/lora-roots', routes.get_lora_roots)
|
||||
app.router.add_get('/api/folders', routes.get_folders)
|
||||
app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions)
|
||||
app.router.add_get('/api/civitai/model/{modelVersionId}', routes.get_civitai_model)
|
||||
app.router.add_get('/api/civitai/model/{hash}', routes.get_civitai_model)
|
||||
app.router.add_post('/api/download-lora', routes.download_lora)
|
||||
app.router.add_post('/api/settings', routes.update_settings)
|
||||
app.router.add_post('/api/move_model', routes.move_model)
|
||||
app.router.add_get('/api/lora-model-description', routes.get_lora_model_description) # Add new route
|
||||
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
|
||||
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
|
||||
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
|
||||
app.router.add_get('/api/loras/top-tags', routes.get_top_tags) # Add new route for top tags
|
||||
app.router.add_get('/api/loras/base-models', routes.get_base_models) # Add new route for base models
|
||||
app.router.add_get('/api/lora-civitai-url', routes.get_lora_civitai_url) # Add new route for Civitai URL
|
||||
app.router.add_post('/api/rename_lora', routes.rename_lora) # Add new route for renaming LoRA files
|
||||
|
||||
# Add update check routes
|
||||
UpdateRoutes.setup_routes(app)
|
||||
|
||||
async def delete_model(self, request: web.Request) -> web.Response:
|
||||
"""Handle model deletion request"""
|
||||
@@ -119,6 +135,15 @@ class ApiRoutes:
|
||||
folder = request.query.get('folder')
|
||||
search = request.query.get('search', '').lower()
|
||||
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
|
||||
|
||||
# Parse base models filter parameter
|
||||
base_models = request.query.get('base_models', '').split(',')
|
||||
base_models = [model.strip() for model in base_models if model.strip()]
|
||||
|
||||
# Parse search options
|
||||
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
||||
search_modelname = request.query.get('search_modelname', 'true').lower() == 'true'
|
||||
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
||||
recursive = request.query.get('recursive', 'false').lower() == 'true'
|
||||
|
||||
# Validate parameters
|
||||
@@ -132,7 +157,11 @@ class ApiRoutes:
|
||||
'error': 'Invalid sort parameter'
|
||||
}, status=400)
|
||||
|
||||
# Get paginated data with search
|
||||
# Parse tags filter parameter
|
||||
tags = request.query.get('tags', '').split(',')
|
||||
tags = [tag.strip() for tag in tags if tag.strip()]
|
||||
|
||||
# Get paginated data with search and filters
|
||||
result = await self.scanner.get_paginated_data(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -140,7 +169,14 @@ class ApiRoutes:
|
||||
folder=folder,
|
||||
search=search,
|
||||
fuzzy=fuzzy,
|
||||
recursive=recursive # 添加递归参数
|
||||
base_models=base_models, # Pass base models filter
|
||||
tags=tags, # Add tags parameter
|
||||
search_options={
|
||||
'filename': search_filename,
|
||||
'modelname': search_modelname,
|
||||
'tags': search_tags,
|
||||
'recursive': recursive
|
||||
}
|
||||
)
|
||||
|
||||
# Format the response data
|
||||
@@ -173,12 +209,15 @@ class ApiRoutes:
|
||||
"model_name": lora["model_name"],
|
||||
"file_name": lora["file_name"],
|
||||
"preview_url": config.get_preview_static_url(lora["preview_url"]),
|
||||
"preview_nsfw_level": lora.get("preview_nsfw_level", 0),
|
||||
"base_model": lora["base_model"],
|
||||
"folder": lora["folder"],
|
||||
"sha256": lora["sha256"],
|
||||
"file_path": lora["file_path"].replace(os.sep, "/"),
|
||||
"file_size": lora["size"],
|
||||
"modified": lora["modified"],
|
||||
"tags": lora["tags"],
|
||||
"modelDescription": lora["modelDescription"],
|
||||
"from_civitai": lora.get("from_civitai", True),
|
||||
"usage_tips": lora.get("usage_tips", ""),
|
||||
"notes": lora.get("notes", ""),
|
||||
@@ -233,6 +272,9 @@ class ApiRoutes:
|
||||
cache = await self.scanner.get_cached_data()
|
||||
cache.raw_data = [item for item in cache.raw_data if item['file_path'] != main_path]
|
||||
await cache.resort()
|
||||
|
||||
# update hash index
|
||||
self.scanner._hash_index.remove_by_path(main_path)
|
||||
|
||||
# Delete optional files
|
||||
for pattern in patterns[1:]:
|
||||
@@ -321,11 +363,19 @@ class ApiRoutes:
|
||||
|
||||
# Update model name if available
|
||||
if 'model' in civitai_metadata:
|
||||
local_metadata['model_name'] = civitai_metadata['model'].get('name',
|
||||
local_metadata.get('model_name'))
|
||||
if civitai_metadata.get('model', {}).get('name'):
|
||||
local_metadata['model_name'] = civitai_metadata['model']['name']
|
||||
|
||||
# Fetch additional model metadata (description and tags) if we have model ID
|
||||
model_id = civitai_metadata['modelId']
|
||||
if model_id:
|
||||
model_metadata, _ = await client.get_model_metadata(str(model_id))
|
||||
if model_metadata:
|
||||
local_metadata['modelDescription'] = model_metadata.get('description', '')
|
||||
local_metadata['tags'] = model_metadata.get('tags', [])
|
||||
|
||||
# Update base model
|
||||
local_metadata['base_model'] = civitai_metadata.get('baseModel')
|
||||
local_metadata['base_model'] = determine_base_model(civitai_metadata.get('baseModel'))
|
||||
|
||||
# Update preview if needed
|
||||
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
|
||||
@@ -338,6 +388,7 @@ class ApiRoutes:
|
||||
|
||||
if await client.download_preview_image(first_preview['url'], preview_path):
|
||||
local_metadata['preview_url'] = preview_path.replace(os.sep, '/')
|
||||
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
|
||||
|
||||
# Save updated metadata
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
@@ -357,7 +408,7 @@ class ApiRoutes:
|
||||
# 准备要处理的 loras
|
||||
to_process = [
|
||||
lora for lora in cache.raw_data
|
||||
if lora.get('sha256') and not lora.get('civitai') and lora.get('from_civitai')
|
||||
if lora.get('sha256') and (not lora.get('civitai') or 'id' not in lora.get('civitai')) and lora.get('from_civitai') # TODO: for lora not from CivitAI but added traineWords
|
||||
]
|
||||
total_to_process = len(to_process)
|
||||
|
||||
@@ -477,18 +528,63 @@ class ApiRoutes:
|
||||
return web.json_response({
|
||||
'roots': config.loras_roots
|
||||
})
|
||||
|
||||
async def get_folders(self, request: web.Request) -> web.Response:
|
||||
"""Get all folders in the cache"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
return web.json_response({
|
||||
'folders': cache.folders
|
||||
})
|
||||
|
||||
async def get_civitai_versions(self, request: web.Request) -> web.Response:
|
||||
"""Get available versions for a Civitai model"""
|
||||
"""Get available versions for a Civitai model with local availability info"""
|
||||
try:
|
||||
model_id = request.match_info['model_id']
|
||||
versions = await self.civitai_client.get_model_versions(model_id)
|
||||
if not versions:
|
||||
return web.Response(status=404, text="Model not found")
|
||||
|
||||
# Check local availability for each version
|
||||
for version in versions:
|
||||
# Find the model file (type="Model") in the files list
|
||||
model_file = next((file for file in version.get('files', [])
|
||||
if file.get('type') == 'Model'), None)
|
||||
|
||||
if model_file:
|
||||
sha256 = model_file.get('hashes', {}).get('SHA256')
|
||||
if sha256:
|
||||
# Set existsLocally and localPath at the version level
|
||||
version['existsLocally'] = self.scanner.has_lora_hash(sha256)
|
||||
if version['existsLocally']:
|
||||
version['localPath'] = self.scanner.get_lora_path_by_hash(sha256)
|
||||
|
||||
# Also set the model file size at the version level for easier access
|
||||
version['modelSizeKB'] = model_file.get('sizeKB')
|
||||
else:
|
||||
# No model file found in this version
|
||||
version['existsLocally'] = False
|
||||
|
||||
return web.json_response(versions)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model versions: {e}")
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
async def get_civitai_model(self, request: web.Request) -> web.Response:
|
||||
"""Get CivitAI model details by model version ID or hash"""
|
||||
try:
|
||||
model_version_id = request.match_info['modelVersionId']
|
||||
if not model_version_id:
|
||||
hash = request.match_info['hash']
|
||||
model = await self.civitai_client.get_model_by_hash(hash)
|
||||
return web.json_response(model)
|
||||
|
||||
# Get model details from Civitai API
|
||||
model = await self.civitai_client.get_model_version_info(model_version_id)
|
||||
return web.json_response(model)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model details: {e}")
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
async def download_lora(self, request: web.Request) -> web.Response:
|
||||
async with self._download_lock:
|
||||
@@ -502,20 +598,54 @@ class ApiRoutes:
|
||||
'progress': progress
|
||||
})
|
||||
|
||||
# Check which identifier is provided
|
||||
download_url = data.get('download_url')
|
||||
model_hash = data.get('model_hash')
|
||||
model_version_id = data.get('model_version_id')
|
||||
|
||||
# Validate that at least one identifier is provided
|
||||
if not any([download_url, model_hash, model_version_id]):
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="Missing required parameter: Please provide either 'download_url', 'hash', or 'modelVersionId'"
|
||||
)
|
||||
|
||||
result = await self.download_manager.download_from_civitai(
|
||||
download_url=data.get('download_url'),
|
||||
download_url=download_url,
|
||||
model_hash=model_hash,
|
||||
model_version_id=model_version_id,
|
||||
save_dir=data.get('lora_root'),
|
||||
relative_path=data.get('relative_path'),
|
||||
progress_callback=progress_callback # Add progress callback
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
if not result.get('success', False):
|
||||
return web.Response(status=500, text=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.Response(
|
||||
status=401, # Use 401 status code to match Civitai's response
|
||||
text=f"Early Access Restriction: {error_message}"
|
||||
)
|
||||
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading LoRA: {e}")
|
||||
return web.Response(status=500, text=str(e))
|
||||
error_message = str(e)
|
||||
|
||||
# Check if this might be an early access error
|
||||
if '401' in error_message:
|
||||
logger.warning(f"Early access error (401): {error_message}")
|
||||
return web.Response(
|
||||
status=401,
|
||||
text="Early Access Restriction: This LoRA requires purchase. Please buy early access on Civitai.com."
|
||||
)
|
||||
|
||||
logger.error(f"Error downloading LoRA: {error_message}")
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
async def update_settings(self, request: web.Request) -> web.Response:
|
||||
"""Update application settings"""
|
||||
@@ -525,6 +655,8 @@ class ApiRoutes:
|
||||
# Validate and update settings
|
||||
if 'civitai_api_key' in data:
|
||||
settings.set('civitai_api_key', data['civitai_api_key'])
|
||||
if 'show_only_sfw' in data:
|
||||
settings.set('show_only_sfw', data['show_only_sfw'])
|
||||
|
||||
return web.json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -535,12 +667,28 @@ class ApiRoutes:
|
||||
"""Handle model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
target_path = data.get('target_path')
|
||||
file_path = data.get('file_path') # full path of the model file, e.g. /path/to/model.safetensors
|
||||
target_path = data.get('target_path') # folder path to move the model to, e.g. /path/to/target_folder
|
||||
|
||||
if not file_path or not target_path:
|
||||
return web.Response(text='File path and target path are required', status=400)
|
||||
|
||||
# Check if source and destination are the same
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||
|
||||
# Check if target file already exists
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Target file already exists: {target_file_path}"
|
||||
}, status=409) # 409 Conflict
|
||||
|
||||
# Call scanner to handle the move operation
|
||||
success = await self.scanner.move_model(file_path, target_path)
|
||||
|
||||
@@ -580,8 +728,15 @@ class ApiRoutes:
|
||||
else:
|
||||
metadata = {}
|
||||
|
||||
# Update metadata with new values
|
||||
metadata.update(metadata_updates)
|
||||
# Handle nested updates (for civitai.trainedWords)
|
||||
for key, value in metadata_updates.items():
|
||||
if isinstance(value, dict) and key in metadata and isinstance(metadata[key], dict):
|
||||
# Deep update for nested dictionaries
|
||||
for nested_key, nested_value in value.items():
|
||||
metadata[key][nested_key] = nested_value
|
||||
else:
|
||||
# Regular update for top-level keys
|
||||
metadata[key] = value
|
||||
|
||||
# Save updated metadata
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
@@ -590,6 +745,11 @@ class ApiRoutes:
|
||||
# Update cache
|
||||
await self.scanner.update_single_lora_cache(file_path, file_path, metadata)
|
||||
|
||||
# If model_name was updated, resort the cache
|
||||
if 'model_name' in metadata_updates:
|
||||
cache = await self.scanner.get_cached_data()
|
||||
await cache.resort(name_only=True)
|
||||
|
||||
return web.json_response({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
@@ -630,3 +790,361 @@ class ApiRoutes:
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
|
||||
"""Get the Civitai URL for a LoRA file"""
|
||||
try:
|
||||
# Get lora file name from query parameters
|
||||
lora_name = request.query.get('name')
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
# Get cache data
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
# Search for the lora in cache data
|
||||
for lora in cache.raw_data:
|
||||
file_name = lora['file_name']
|
||||
if file_name == lora_name:
|
||||
civitai_data = lora.get('civitai', {})
|
||||
model_id = civitai_data.get('modelId')
|
||||
version_id = civitai_data.get('id')
|
||||
|
||||
if model_id:
|
||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'civitai_url': civitai_url,
|
||||
'model_id': model_id,
|
||||
'version_id': version_id
|
||||
})
|
||||
break
|
||||
|
||||
# If no Civitai data found
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No Civitai data found for the specified lora'
|
||||
}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||
"""Handle bulk model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_paths = data.get('file_paths', []) # list of full paths of the model files, e.g. ["/path/to/model1.safetensors", "/path/to/model2.safetensors"]
|
||||
target_path = data.get('target_path') # folder path to move the models to, e.g. "/path/to/target_folder"
|
||||
|
||||
if not file_paths or not target_path:
|
||||
return web.Response(text='File paths and target path are required', status=400)
|
||||
|
||||
results = []
|
||||
for file_path in file_paths:
|
||||
# Check if source and destination are the same
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": True,
|
||||
"message": "Source and target directories are the same"
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if target file already exists
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": False,
|
||||
"message": f"Target file already exists: {target_file_path}"
|
||||
})
|
||||
continue
|
||||
|
||||
# Try to move the model
|
||||
success = await self.scanner.move_model(file_path, target_path)
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": success,
|
||||
"message": "Success" if success else "Failed to move model"
|
||||
})
|
||||
|
||||
# Count successes and failures
|
||||
success_count = sum(1 for r in results if r["success"])
|
||||
failure_count = len(results) - success_count
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||
'results': results,
|
||||
'success_count': success_count,
|
||||
'failure_count': failure_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
||||
"""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 = []
|
||||
if file_path:
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
description = metadata.get('modelDescription')
|
||||
tags = metadata.get('tags', [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata from {metadata_path}: {e}")
|
||||
|
||||
# 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', [])
|
||||
|
||||
# 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'
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
metadata['modelDescription'] = description
|
||||
metadata['tags'] = tags
|
||||
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
logger.info(f"Saved model metadata to file for {file_path}")
|
||||
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
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model metadata: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||
"""Handle request for top tags sorted by frequency"""
|
||||
try:
|
||||
# Parse query parameters
|
||||
limit = int(request.query.get('limit', '20'))
|
||||
|
||||
# Validate limit
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20 # Default to a reasonable limit
|
||||
|
||||
# Get top tags
|
||||
top_tags = await self.scanner.get_top_tags(limit)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'tags': top_tags
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting top tags: {str(e)}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Internal server error'
|
||||
}, status=500)
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
"""Get base models used in loras"""
|
||||
try:
|
||||
# Parse query parameters
|
||||
limit = int(request.query.get('limit', '20'))
|
||||
|
||||
# Validate limit
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20 # Default to a reasonable limit
|
||||
|
||||
# Get base models
|
||||
base_models = await self.scanner.get_base_models(limit)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'base_models': base_models
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving base models: {e}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
def get_multipart_ext(self, filename):
|
||||
parts = filename.split(".")
|
||||
if len(parts) > 2: # 如果包含多级扩展名
|
||||
return "." + ".".join(parts[-2:]) # 取最后两部分,如 ".metadata.json"
|
||||
return os.path.splitext(filename)[1] # 否则取普通扩展名,如 ".safetensors"
|
||||
|
||||
async def rename_lora(self, request: web.Request) -> web.Response:
|
||||
"""Handle renaming a LoRA file and its associated files"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
new_file_name = data.get('new_file_name')
|
||||
|
||||
if not file_path or not new_file_name:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'File path and new file name are required'
|
||||
}, status=400)
|
||||
|
||||
# Validate the new file name (no path separators or invalid characters)
|
||||
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
if any(char in new_file_name for char in invalid_chars):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Invalid characters in file name'
|
||||
}, status=400)
|
||||
|
||||
# Get the directory and current file name
|
||||
target_dir = os.path.dirname(file_path)
|
||||
old_file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
# Check if the target file already exists
|
||||
new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/')
|
||||
if os.path.exists(new_file_path):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'A file with this name already exists'
|
||||
}, status=400)
|
||||
|
||||
# Define the patterns for associated files
|
||||
patterns = [
|
||||
f"{old_file_name}.safetensors", # Required
|
||||
f"{old_file_name}.metadata.json",
|
||||
f"{old_file_name}.preview.png",
|
||||
f"{old_file_name}.preview.jpg",
|
||||
f"{old_file_name}.preview.jpeg",
|
||||
f"{old_file_name}.preview.webp",
|
||||
f"{old_file_name}.preview.mp4",
|
||||
f"{old_file_name}.png",
|
||||
f"{old_file_name}.jpg",
|
||||
f"{old_file_name}.jpeg",
|
||||
f"{old_file_name}.webp",
|
||||
f"{old_file_name}.mp4"
|
||||
]
|
||||
|
||||
# Find all matching files
|
||||
existing_files = []
|
||||
for pattern in patterns:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
existing_files.append((path, pattern))
|
||||
|
||||
# Get the hash from the main file to update hash index
|
||||
hash_value = None
|
||||
metadata = None
|
||||
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
|
||||
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
hash_value = metadata.get('sha256')
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata for rename: {e}")
|
||||
|
||||
# Rename all files
|
||||
renamed_files = []
|
||||
new_metadata_path = None
|
||||
|
||||
# Notify file monitor to ignore these events
|
||||
main_file_path = os.path.join(target_dir, f"{old_file_name}.safetensors")
|
||||
if os.path.exists(main_file_path) and self.download_manager.file_monitor:
|
||||
# Add old and new paths to ignore list
|
||||
file_size = os.path.getsize(main_file_path)
|
||||
self.download_manager.file_monitor.handler.add_ignore_path(main_file_path, file_size)
|
||||
self.download_manager.file_monitor.handler.add_ignore_path(new_file_path, file_size)
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
# Get the file extension like .safetensors or .metadata.json
|
||||
ext = self.get_multipart_ext(pattern)
|
||||
|
||||
# Create the new path
|
||||
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
|
||||
|
||||
# Rename the file
|
||||
os.rename(old_path, new_path)
|
||||
renamed_files.append(new_path)
|
||||
|
||||
# Keep track of metadata path for later update
|
||||
if ext == '.metadata.json':
|
||||
new_metadata_path = new_path
|
||||
|
||||
# Update the metadata file with new file name and paths
|
||||
if new_metadata_path and metadata:
|
||||
# Update file_name, file_path and preview_url in metadata
|
||||
metadata['file_name'] = new_file_name
|
||||
metadata['file_path'] = new_file_path
|
||||
|
||||
# Update preview_url if it exists
|
||||
if 'preview_url' in metadata and metadata['preview_url']:
|
||||
old_preview = metadata['preview_url']
|
||||
ext = self.get_multipart_ext(old_preview)
|
||||
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
|
||||
metadata['preview_url'] = new_preview
|
||||
|
||||
# Save updated metadata
|
||||
with open(new_metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Update the scanner cache
|
||||
if metadata:
|
||||
await self.scanner.update_single_lora_cache(file_path, new_file_path, metadata)
|
||||
|
||||
# Update recipe files and cache if hash is available
|
||||
if hash_value:
|
||||
recipe_scanner = RecipeScanner(self.scanner)
|
||||
recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name)
|
||||
logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed LoRA")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'new_file_path': new_file_path,
|
||||
'renamed_files': renamed_files,
|
||||
'reload_required': False
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error renaming LoRA: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
44
py/routes/checkpoints_routes.py
Normal file
44
py/routes/checkpoints_routes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
from aiohttp import web
|
||||
import jinja2
|
||||
import logging
|
||||
from ..config import config
|
||||
from ..services.settings_manager import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger('asyncio').setLevel(logging.CRITICAL)
|
||||
|
||||
class CheckpointsRoutes:
|
||||
"""Route handlers for Checkpoints management endpoints"""
|
||||
|
||||
def __init__(self):
|
||||
self.template_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||
autoescape=True
|
||||
)
|
||||
|
||||
async def handle_checkpoints_page(self, request: web.Request) -> web.Response:
|
||||
"""Handle GET /checkpoints request"""
|
||||
try:
|
||||
template = self.template_env.get_template('checkpoints.html')
|
||||
rendered = template.render(
|
||||
is_initializing=False,
|
||||
settings=settings,
|
||||
request=request
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
text=rendered,
|
||||
content_type='text/html'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling checkpoints request: {e}", exc_info=True)
|
||||
return web.Response(
|
||||
text="Error loading checkpoints page",
|
||||
status=500
|
||||
)
|
||||
|
||||
def setup_routes(self, app: web.Application):
|
||||
"""Register routes with the application"""
|
||||
app.router.add_get('/checkpoints', self.handle_checkpoints_page)
|
||||
@@ -4,6 +4,7 @@ import jinja2
|
||||
from typing import Dict, List
|
||||
import logging
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
from ..services.recipe_scanner import RecipeScanner
|
||||
from ..config import config
|
||||
from ..services.settings_manager import settings # Add this import
|
||||
|
||||
@@ -15,6 +16,7 @@ class LoraRoutes:
|
||||
|
||||
def __init__(self):
|
||||
self.scanner = LoraScanner()
|
||||
self.recipe_scanner = RecipeScanner(self.scanner)
|
||||
self.template_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||
autoescape=True
|
||||
@@ -26,10 +28,16 @@ class LoraRoutes:
|
||||
"model_name": lora["model_name"],
|
||||
"file_name": lora["file_name"],
|
||||
"preview_url": config.get_preview_static_url(lora["preview_url"]),
|
||||
"preview_nsfw_level": lora.get("preview_nsfw_level", 0),
|
||||
"base_model": lora["base_model"],
|
||||
"folder": lora["folder"],
|
||||
"sha256": lora["sha256"],
|
||||
"file_path": lora["file_path"].replace(os.sep, "/"),
|
||||
"size": lora["size"],
|
||||
"tags": lora["tags"],
|
||||
"modelDescription": lora["modelDescription"],
|
||||
"usage_tips": lora["usage_tips"],
|
||||
"notes": lora["notes"],
|
||||
"modified": lora["modified"],
|
||||
"from_civitai": lora.get("from_civitai", True),
|
||||
"civitai": self._filter_civitai_data(lora.get("civitai", {}))
|
||||
@@ -50,11 +58,13 @@ class LoraRoutes:
|
||||
async def handle_loras_page(self, request: web.Request) -> web.Response:
|
||||
"""Handle GET /loras request"""
|
||||
try:
|
||||
# 不等待缓存数据,直接检查缓存状态
|
||||
# 检查缓存初始化状态,增强判断条件
|
||||
is_initializing = (
|
||||
self.scanner._cache is None and
|
||||
self.scanner._cache is None or
|
||||
(self.scanner._initialization_task is not None and
|
||||
not self.scanner._initialization_task.done())
|
||||
not self.scanner._initialization_task.done()) or
|
||||
(self.scanner._cache is not None and len(self.scanner._cache.raw_data) == 0 and
|
||||
self.scanner._initialization_task is not None)
|
||||
)
|
||||
|
||||
if is_initializing:
|
||||
@@ -63,17 +73,34 @@ class LoraRoutes:
|
||||
rendered = template.render(
|
||||
folders=[], # 空文件夹列表
|
||||
is_initializing=True, # 新增标志
|
||||
settings=settings # Pass settings to template
|
||||
settings=settings, # Pass settings to template
|
||||
request=request # Pass the request object to the template
|
||||
)
|
||||
|
||||
logger.info("Loras page is initializing, returning loading page")
|
||||
else:
|
||||
# 正常流程
|
||||
cache = await self.scanner.get_cached_data()
|
||||
template = self.template_env.get_template('loras.html')
|
||||
rendered = template.render(
|
||||
folders=cache.folders,
|
||||
is_initializing=False,
|
||||
settings=settings # Pass settings to template
|
||||
)
|
||||
# 正常流程 - 但不要等待缓存刷新
|
||||
try:
|
||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||
template = self.template_env.get_template('loras.html')
|
||||
rendered = template.render(
|
||||
folders=cache.folders,
|
||||
is_initializing=False,
|
||||
settings=settings, # Pass settings to template
|
||||
request=request # Pass the request object to the template
|
||||
)
|
||||
logger.debug(f"Loras page loaded successfully with {len(cache.raw_data)} items")
|
||||
except Exception as cache_error:
|
||||
logger.error(f"Error loading cache data: {cache_error}")
|
||||
# 如果获取缓存失败,也显示初始化页面
|
||||
template = self.template_env.get_template('loras.html')
|
||||
rendered = template.render(
|
||||
folders=[],
|
||||
is_initializing=True,
|
||||
settings=settings,
|
||||
request=request
|
||||
)
|
||||
logger.info("Cache error, returning initialization page")
|
||||
|
||||
return web.Response(
|
||||
text=rendered,
|
||||
@@ -87,6 +114,65 @@ class LoraRoutes:
|
||||
status=500
|
||||
)
|
||||
|
||||
async def handle_recipes_page(self, request: web.Request) -> web.Response:
|
||||
"""Handle GET /loras/recipes request"""
|
||||
try:
|
||||
# Check cache initialization status
|
||||
is_initializing = (
|
||||
self.recipe_scanner._cache is None and
|
||||
(self.recipe_scanner._initialization_task is not None and
|
||||
not self.recipe_scanner._initialization_task.done())
|
||||
)
|
||||
|
||||
if is_initializing:
|
||||
# If initializing, return a loading page
|
||||
template = self.template_env.get_template('recipes.html')
|
||||
rendered = template.render(
|
||||
is_initializing=True,
|
||||
settings=settings,
|
||||
request=request # Pass the request object to the template
|
||||
)
|
||||
else:
|
||||
# return empty recipes
|
||||
recipes_data = []
|
||||
|
||||
template = self.template_env.get_template('recipes.html')
|
||||
rendered = template.render(
|
||||
recipes=recipes_data,
|
||||
is_initializing=False,
|
||||
settings=settings,
|
||||
request=request # Pass the request object to the template
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
text=rendered,
|
||||
content_type='text/html'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling recipes request: {e}", exc_info=True)
|
||||
return web.Response(
|
||||
text="Error loading recipes page",
|
||||
status=500
|
||||
)
|
||||
|
||||
def _format_recipe_file_url(self, file_path: str) -> str:
|
||||
"""Format file path for recipe image as a URL - same as in recipe_routes"""
|
||||
try:
|
||||
# Return the file URL directly for the first lora root's preview
|
||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/')
|
||||
if file_path.replace(os.sep, '/').startswith(recipes_dir):
|
||||
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/')
|
||||
return f"/loras_static/root1/preview/{relative_path}"
|
||||
|
||||
# If not in recipes dir, try to create a valid URL from the file path
|
||||
file_name = os.path.basename(file_path)
|
||||
return f"/loras_static/root1/preview/recipes/{file_name}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error formatting recipe file URL: {e}", exc_info=True)
|
||||
return '/loras_static/images/no-preview.png' # Return default image on error
|
||||
|
||||
def setup_routes(self, app: web.Application):
|
||||
"""Register routes with the application"""
|
||||
app.router.add_get('/loras', self.handle_loras_page)
|
||||
app.router.add_get('/loras/recipes', self.handle_recipes_page)
|
||||
|
||||
1136
py/routes/recipe_routes.py
Normal file
1136
py/routes/recipe_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
176
py/routes/update_routes.py
Normal file
176
py/routes/update_routes.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import os
|
||||
import aiohttp
|
||||
import logging
|
||||
import toml
|
||||
from aiohttp import web
|
||||
from typing import Dict, Any, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UpdateRoutes:
|
||||
"""Routes for handling plugin update checks"""
|
||||
|
||||
@staticmethod
|
||||
def setup_routes(app):
|
||||
"""Register update check routes"""
|
||||
app.router.add_get('/loras/api/check-updates', UpdateRoutes.check_updates)
|
||||
|
||||
@staticmethod
|
||||
async def check_updates(request):
|
||||
"""
|
||||
Check for plugin updates by comparing local version with GitHub
|
||||
Returns update status and version information
|
||||
"""
|
||||
try:
|
||||
# Read local version from pyproject.toml
|
||||
local_version = UpdateRoutes._get_local_version()
|
||||
|
||||
# Fetch remote version from GitHub
|
||||
remote_version, changelog = await UpdateRoutes._get_remote_version()
|
||||
|
||||
# Compare versions
|
||||
update_available = UpdateRoutes._compare_versions(
|
||||
local_version.replace('v', ''),
|
||||
remote_version.replace('v', '')
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'current_version': local_version,
|
||||
'latest_version': remote_version,
|
||||
'update_available': update_available,
|
||||
'changelog': changelog
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check for updates: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _get_local_version() -> str:
|
||||
"""Get local plugin version from pyproject.toml"""
|
||||
try:
|
||||
# Find the plugin's pyproject.toml file
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
pyproject_path = os.path.join(plugin_root, 'pyproject.toml')
|
||||
|
||||
# Read and parse the toml file
|
||||
if os.path.exists(pyproject_path):
|
||||
with open(pyproject_path, 'r', encoding='utf-8') as f:
|
||||
project_data = toml.load(f)
|
||||
version = project_data.get('project', {}).get('version', '0.0.0')
|
||||
return f"v{version}"
|
||||
else:
|
||||
logger.warning(f"pyproject.toml not found at {pyproject_path}")
|
||||
return "v0.0.0"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get local version: {e}", exc_info=True)
|
||||
return "v0.0.0"
|
||||
|
||||
@staticmethod
|
||||
async def _get_remote_version() -> tuple[str, List[str]]:
|
||||
"""
|
||||
Fetch remote version from GitHub
|
||||
Returns:
|
||||
tuple: (version string, changelog list)
|
||||
"""
|
||||
repo_owner = "willmiao"
|
||||
repo_name = "ComfyUI-Lora-Manager"
|
||||
|
||||
# Use GitHub API to fetch the latest release
|
||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(github_url, headers={'Accept': 'application/vnd.github+json'}) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(f"Failed to fetch GitHub release: {response.status}")
|
||||
return "v0.0.0", []
|
||||
|
||||
data = await response.json()
|
||||
version = data.get('tag_name', '')
|
||||
if not version.startswith('v'):
|
||||
version = f"v{version}"
|
||||
|
||||
# Extract changelog from release notes
|
||||
body = data.get('body', '')
|
||||
changelog = UpdateRoutes._parse_changelog(body)
|
||||
|
||||
return version, changelog
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching remote version: {e}", exc_info=True)
|
||||
return "v0.0.0", []
|
||||
|
||||
@staticmethod
|
||||
def _parse_changelog(release_notes: str) -> List[str]:
|
||||
"""
|
||||
Parse GitHub release notes to extract changelog items
|
||||
|
||||
Args:
|
||||
release_notes: GitHub release notes markdown text
|
||||
|
||||
Returns:
|
||||
List of changelog items
|
||||
"""
|
||||
changelog = []
|
||||
|
||||
# Simple parsing - extract bullet points
|
||||
lines = release_notes.split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Look for bullet points or numbered items
|
||||
if line.startswith('- ') or line.startswith('* '):
|
||||
item = line[2:].strip()
|
||||
if item:
|
||||
changelog.append(item)
|
||||
# Match numbered items like "1. Item"
|
||||
elif len(line) > 2 and line[0].isdigit() and line[1:].startswith('. '):
|
||||
item = line[line.index('. ')+2:].strip()
|
||||
if item:
|
||||
changelog.append(item)
|
||||
|
||||
# If we couldn't parse specific items, use the whole text (limited)
|
||||
if not changelog and release_notes:
|
||||
# Limit to first 500 chars and add ellipsis
|
||||
summary = release_notes.strip()[:500]
|
||||
if len(release_notes) > 500:
|
||||
summary += "..."
|
||||
changelog.append(summary)
|
||||
|
||||
return changelog
|
||||
|
||||
@staticmethod
|
||||
def _compare_versions(version1: str, version2: str) -> bool:
|
||||
"""
|
||||
Compare two semantic version strings
|
||||
Returns True if version2 is newer than version1
|
||||
"""
|
||||
try:
|
||||
# Split versions into components
|
||||
v1_parts = [int(x) for x in version1.split('.')]
|
||||
v2_parts = [int(x) for x in version2.split('.')]
|
||||
|
||||
# Ensure both have 3 components (major.minor.patch)
|
||||
while len(v1_parts) < 3:
|
||||
v1_parts.append(0)
|
||||
while len(v2_parts) < 3:
|
||||
v2_parts.append(0)
|
||||
|
||||
# Compare version components
|
||||
for i in range(3):
|
||||
if v2_parts[i] > v1_parts[i]:
|
||||
return True
|
||||
elif v2_parts[i] < v1_parts[i]:
|
||||
return False
|
||||
|
||||
# Versions are equal
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error comparing versions: {e}", exc_info=True)
|
||||
return False
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import json
|
||||
import logging
|
||||
from email.parser import Parser
|
||||
from typing import Optional, Dict, Tuple
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
from urllib.parse import unquote
|
||||
from ..utils.models import LoraMetadata
|
||||
|
||||
@@ -76,6 +76,18 @@ class CivitaiClient:
|
||||
headers = self._get_request_headers()
|
||||
async with session.get(url, headers=headers, allow_redirects=True) as response:
|
||||
if response.status != 200:
|
||||
# Handle 401 unauthorized responses
|
||||
if response.status == 401:
|
||||
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
||||
|
||||
return False, "Invalid or missing CivitAI API key, or early access restriction."
|
||||
|
||||
# Handle other client errors that might be permission-related
|
||||
if response.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."
|
||||
|
||||
# Generic error response for other status codes
|
||||
return False, f"Download failed with status {response.status}"
|
||||
|
||||
# Get filename from content-disposition header
|
||||
@@ -135,16 +147,15 @@ class CivitaiClient:
|
||||
print(f"Download Error: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Fetch all versions of a model"""
|
||||
async def get_model_versions(self, model_id: str) -> List[Dict]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
session = await self.session
|
||||
url = f"{self.base_url}/models/{model_id}"
|
||||
async with session.get(url, headers=self.headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data.get('modelVersions', [])
|
||||
return None
|
||||
session = await self.session # 等待获取 session
|
||||
async with session.get(f"{self.base_url}/models/{model_id}") as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
data = await response.json()
|
||||
return data.get('modelVersions', [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model versions: {e}")
|
||||
return None
|
||||
@@ -164,8 +175,78 @@ class CivitaiClient:
|
||||
logger.error(f"Error fetching model version info: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
|
||||
"""Fetch model metadata (description and tags) from Civitai API
|
||||
|
||||
Args:
|
||||
model_id: The Civitai model ID
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[Dict], int]: A tuple containing:
|
||||
- A dictionary with model metadata or None if not found
|
||||
- The HTTP status code from the request
|
||||
"""
|
||||
try:
|
||||
session = await self.session
|
||||
headers = self._get_request_headers()
|
||||
url = f"{self.base_url}/models/{model_id}"
|
||||
|
||||
async with session.get(url, headers=headers) as response:
|
||||
status_code = response.status
|
||||
|
||||
if status_code != 200:
|
||||
logger.warning(f"Failed to fetch model metadata: Status {status_code}")
|
||||
return None, status_code
|
||||
|
||||
data = await response.json()
|
||||
|
||||
# Extract relevant metadata
|
||||
metadata = {
|
||||
"description": data.get("description") or "No model description available",
|
||||
"tags": data.get("tags", [])
|
||||
}
|
||||
|
||||
if metadata["description"] or metadata["tags"]:
|
||||
return metadata, status_code
|
||||
else:
|
||||
logger.warning(f"No metadata found for model {model_id}")
|
||||
return None, status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model metadata: {e}", exc_info=True)
|
||||
return None, 0
|
||||
|
||||
# Keep old method for backward compatibility, delegating to the new one
|
||||
async def get_model_description(self, model_id: str) -> Optional[str]:
|
||||
"""Fetch the model description from Civitai API (Legacy method)"""
|
||||
metadata, _ = await self.get_model_metadata(model_id)
|
||||
return metadata.get("description") if metadata else None
|
||||
|
||||
async def close(self):
|
||||
"""Close the session if it exists"""
|
||||
if self._session is not None:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
self._session = None
|
||||
|
||||
async def _get_hash_from_civitai(self, model_version_id: str) -> Optional[str]:
|
||||
"""Get hash from Civitai API"""
|
||||
try:
|
||||
if not self._session:
|
||||
return None
|
||||
|
||||
version_info = await self._session.get(f"{self.base_url}/model-versions/{model_version_id}")
|
||||
|
||||
if not version_info or not version_info.json().get('files'):
|
||||
return None
|
||||
|
||||
# Get hash from the first file
|
||||
for file_info in version_info.json().get('files', []):
|
||||
if file_info.get('hashes', {}).get('SHA256'):
|
||||
# Convert hash to lowercase to standardize
|
||||
hash_value = file_info['hashes']['SHA256'].lower()
|
||||
return hash_value
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting hash from Civitai: {e}")
|
||||
return None
|
||||
|
||||
@@ -13,8 +13,9 @@ class DownloadManager:
|
||||
self.civitai_client = CivitaiClient()
|
||||
self.file_monitor = file_monitor
|
||||
|
||||
async def download_from_civitai(self, download_url: str, save_dir: str, relative_path: str = '',
|
||||
progress_callback=None) -> Dict:
|
||||
async def download_from_civitai(self, download_url: str = None, model_hash: str = None,
|
||||
model_version_id: str = None, save_dir: str = None,
|
||||
relative_path: str = '', progress_callback=None) -> Dict:
|
||||
try:
|
||||
# Update save directory with relative path if provided
|
||||
if relative_path:
|
||||
@@ -22,12 +23,43 @@ class DownloadManager:
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
# Get version info
|
||||
version_id = download_url.split('/')[-1]
|
||||
version_info = await self.civitai_client.get_model_version_info(version_id)
|
||||
# Get version info based on the provided identifier
|
||||
version_info = None
|
||||
|
||||
if download_url:
|
||||
# Extract version ID from download URL
|
||||
version_id = download_url.split('/')[-1]
|
||||
version_info = await self.civitai_client.get_model_version_info(version_id)
|
||||
elif model_version_id:
|
||||
# Use model version ID directly
|
||||
version_info = await self.civitai_client.get_model_version_info(model_version_id)
|
||||
elif model_hash:
|
||||
# Get model by hash
|
||||
version_info = await self.civitai_client.get_model_by_hash(model_hash)
|
||||
|
||||
|
||||
if not version_info:
|
||||
return {'success': False, 'error': 'Failed to fetch model metadata'}
|
||||
|
||||
# Check if this is an early access LoRA
|
||||
if version_info.get('earlyAccessEndsAt'):
|
||||
early_access_date = version_info.get('earlyAccessEndsAt', '')
|
||||
# Convert to a readable date if possible
|
||||
try:
|
||||
from datetime import datetime
|
||||
date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00'))
|
||||
formatted_date = date_obj.strftime('%Y-%m-%d')
|
||||
early_access_msg = f"This LoRA requires early access payment (until {formatted_date}). "
|
||||
except:
|
||||
early_access_msg = "This LoRA requires early access payment. "
|
||||
|
||||
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
|
||||
logger.warning(f"Early access LoRA detected: {version_info.get('name', 'Unknown')}")
|
||||
|
||||
# We'll still try to download, but log a warning and prepare for potential failure
|
||||
if progress_callback:
|
||||
await progress_callback(1) # Show minimal progress to indicate we're trying
|
||||
|
||||
# Report initial progress
|
||||
if progress_callback:
|
||||
await progress_callback(0)
|
||||
@@ -42,18 +74,28 @@ class DownloadManager:
|
||||
save_path = os.path.join(save_dir, file_name)
|
||||
file_size = file_info.get('sizeKB', 0) * 1024
|
||||
|
||||
# 4. 通知文件监控系统
|
||||
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
|
||||
self.file_monitor.handler.add_ignore_path(
|
||||
save_path.replace(os.sep, '/'),
|
||||
file_size
|
||||
save_path.replace(os.sep, '/'),
|
||||
file_size
|
||||
)
|
||||
|
||||
# 5. 准备元数据
|
||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
|
||||
# 5.1 获取并更新模型标签和描述信息
|
||||
model_id = version_info.get('modelId')
|
||||
if model_id:
|
||||
model_metadata, _ = await self.civitai_client.get_model_metadata(str(model_id))
|
||||
if model_metadata:
|
||||
if model_metadata.get("tags"):
|
||||
metadata.tags = model_metadata.get("tags", [])
|
||||
if model_metadata.get("description"):
|
||||
metadata.modelDescription = model_metadata.get("description", "")
|
||||
|
||||
# 6. 开始下载流程
|
||||
result = await self._execute_download(
|
||||
download_url=download_url,
|
||||
download_url=file_info.get('downloadUrl', ''),
|
||||
save_dir=save_dir,
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
@@ -65,6 +107,10 @@ class DownloadManager:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
|
||||
# Check if this might be an early access error
|
||||
error_str = str(e).lower()
|
||||
if "403" in error_str or "401" in error_str or "unauthorized" in error_str or "early access" in error_str:
|
||||
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def _execute_download(self, download_url: str, save_dir: str,
|
||||
@@ -86,6 +132,7 @@ class DownloadManager:
|
||||
preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext
|
||||
if await self.civitai_client.download_preview_image(images[0]['url'], preview_path):
|
||||
metadata.preview_url = preview_path.replace(os.sep, '/')
|
||||
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -124,6 +171,12 @@ class DownloadManager:
|
||||
all_folders = set(cache.folders)
|
||||
all_folders.add(relative_path)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# Update the hash index with the new LoRA entry
|
||||
self.file_monitor.scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||
|
||||
# Update the hash index with the new LoRA entry
|
||||
self.file_monitor.scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||
|
||||
# Report 100% completion
|
||||
if progress_callback:
|
||||
|
||||
@@ -2,9 +2,10 @@ from operator import itemgetter
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileDeletedEvent
|
||||
from typing import List
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from typing import List, Dict, Set
|
||||
from threading import Lock
|
||||
from .lora_scanner import LoraScanner
|
||||
from ..config import config
|
||||
@@ -23,6 +24,14 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
self._ignore_paths = set() # Add ignore paths set
|
||||
self._min_ignore_timeout = 5 # minimum timeout in seconds
|
||||
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
|
||||
|
||||
# Track modified files with timestamps for debouncing
|
||||
self.modified_files: Dict[str, float] = {}
|
||||
self.debounce_timer = None
|
||||
self.debounce_delay = 3.0 # seconds to wait after last modification
|
||||
|
||||
# Track files that are already scheduled for processing
|
||||
self.scheduled_files: Set[str] = set()
|
||||
|
||||
def _should_ignore(self, path: str) -> bool:
|
||||
"""Check if path should be ignored"""
|
||||
@@ -37,28 +46,142 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
|
||||
timeout = 5
|
||||
|
||||
asyncio.get_event_loop().call_later(
|
||||
self.loop.call_later(
|
||||
timeout,
|
||||
self._ignore_paths.discard,
|
||||
real_path.replace(os.sep, '/')
|
||||
)
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||
if event.is_directory:
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
|
||||
# Handle safetensors files directly
|
||||
if event.src_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
# We'll process this file directly and ignore subsequent modifications
|
||||
# to prevent duplicate processing
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
if normalized_path not in self.scheduled_files:
|
||||
logger.info(f"LoRA file created: {event.src_path}")
|
||||
self.scheduled_files.add(normalized_path)
|
||||
self._schedule_update('add', event.src_path)
|
||||
|
||||
# Ignore modifications for a short period after creation
|
||||
# This helps avoid duplicate processing
|
||||
self.loop.call_later(
|
||||
self.debounce_delay * 2,
|
||||
self.scheduled_files.discard,
|
||||
normalized_path
|
||||
)
|
||||
|
||||
# For browser downloads, we'll catch them when they're renamed to .safetensors
|
||||
|
||||
def on_modified(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
logger.info(f"LoRA file created: {event.src_path}")
|
||||
self._schedule_update('add', event.src_path)
|
||||
|
||||
# Only process safetensors files
|
||||
if event.src_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
|
||||
# Skip if this file is already scheduled for processing
|
||||
if normalized_path in self.scheduled_files:
|
||||
return
|
||||
|
||||
# Update the timestamp for this file
|
||||
self.modified_files[normalized_path] = time.time()
|
||||
|
||||
# Cancel any existing timer
|
||||
if self.debounce_timer:
|
||||
self.debounce_timer.cancel()
|
||||
|
||||
# Set a new timer to process modified files after debounce period
|
||||
self.debounce_timer = self.loop.call_later(
|
||||
self.debounce_delay,
|
||||
self.loop.call_soon_threadsafe,
|
||||
self._process_modified_files
|
||||
)
|
||||
|
||||
def _process_modified_files(self):
|
||||
"""Process files that have been modified after debounce period"""
|
||||
current_time = time.time()
|
||||
files_to_process = []
|
||||
|
||||
# Find files that haven't been modified for debounce_delay seconds
|
||||
for file_path, last_modified in list(self.modified_files.items()):
|
||||
if current_time - last_modified >= self.debounce_delay:
|
||||
# Only process if not already scheduled
|
||||
if file_path not in self.scheduled_files:
|
||||
files_to_process.append(file_path)
|
||||
self.scheduled_files.add(file_path)
|
||||
|
||||
# Auto-remove from scheduled list after reasonable time
|
||||
self.loop.call_later(
|
||||
self.debounce_delay * 2,
|
||||
self.scheduled_files.discard,
|
||||
file_path
|
||||
)
|
||||
|
||||
del self.modified_files[file_path]
|
||||
|
||||
# Process stable files
|
||||
for file_path in files_to_process:
|
||||
logger.info(f"Processing modified LoRA file: {file_path}")
|
||||
self._schedule_update('add', file_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
# Remove from scheduled files if present
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
self.scheduled_files.discard(normalized_path)
|
||||
|
||||
logger.info(f"LoRA file deleted: {event.src_path}")
|
||||
self._schedule_update('remove', event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
"""Handle file move/rename events"""
|
||||
|
||||
# If destination is a safetensors file, treat it as a new file
|
||||
if event.dest_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.dest_path):
|
||||
return
|
||||
|
||||
normalized_path = os.path.realpath(event.dest_path).replace(os.sep, '/')
|
||||
|
||||
# Only process if not already scheduled
|
||||
if normalized_path not in self.scheduled_files:
|
||||
logger.info(f"LoRA file renamed/moved to: {event.dest_path}")
|
||||
self.scheduled_files.add(normalized_path)
|
||||
self._schedule_update('add', event.dest_path)
|
||||
|
||||
# Auto-remove from scheduled list after reasonable time
|
||||
self.loop.call_later(
|
||||
self.debounce_delay * 2,
|
||||
self.scheduled_files.discard,
|
||||
normalized_path
|
||||
)
|
||||
|
||||
# If source was a safetensors file, treat it as deleted
|
||||
if event.src_path.endswith('.safetensors'):
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||
self.scheduled_files.discard(normalized_path)
|
||||
|
||||
logger.info(f"LoRA file moved/renamed from: {event.src_path}")
|
||||
self._schedule_update('remove', event.src_path)
|
||||
|
||||
def _schedule_update(self, action: str, file_path: str): #file_path is a real path
|
||||
"""Schedule a cache update"""
|
||||
with self.lock:
|
||||
@@ -86,26 +209,52 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
if not changes:
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"Processing {len(changes)} file changes")
|
||||
|
||||
cache = await self.scanner.get_cached_data() # 先完成可能的初始化
|
||||
cache = await self.scanner.get_cached_data()
|
||||
needs_resort = False
|
||||
new_folders = set() # 用于收集新的文件夹
|
||||
new_folders = set()
|
||||
|
||||
for action, file_path in changes:
|
||||
try:
|
||||
if action == 'add':
|
||||
# 扫描新文件
|
||||
# Check if file already exists in cache
|
||||
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
|
||||
if existing:
|
||||
logger.info(f"File {file_path} already in cache, skipping")
|
||||
continue
|
||||
|
||||
# Scan new file
|
||||
lora_data = await self.scanner.scan_single_lora(file_path)
|
||||
if lora_data:
|
||||
# Update tags count
|
||||
for tag in lora_data.get('tags', []):
|
||||
self.scanner._tags_count[tag] = self.scanner._tags_count.get(tag, 0) + 1
|
||||
|
||||
cache.raw_data.append(lora_data)
|
||||
new_folders.add(lora_data['folder']) # 收集新文件夹
|
||||
new_folders.add(lora_data['folder'])
|
||||
# Update hash index
|
||||
if 'sha256' in lora_data:
|
||||
self.scanner._hash_index.add_entry(
|
||||
lora_data['sha256'],
|
||||
lora_data['file_path']
|
||||
)
|
||||
needs_resort = True
|
||||
|
||||
elif action == 'remove':
|
||||
# 从缓存中移除
|
||||
# Find the lora to remove so we can update tags count
|
||||
lora_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
|
||||
if lora_to_remove:
|
||||
# Update tags count by reducing counts
|
||||
for tag in lora_to_remove.get('tags', []):
|
||||
if tag in self.scanner._tags_count:
|
||||
self.scanner._tags_count[tag] = max(0, self.scanner._tags_count[tag] - 1)
|
||||
if self.scanner._tags_count[tag] == 0:
|
||||
del self.scanner._tags_count[tag]
|
||||
|
||||
# Remove from cache and hash index
|
||||
logger.info(f"Removing {file_path} from cache")
|
||||
self.scanner._hash_index.remove_by_path(file_path)
|
||||
cache.raw_data = [
|
||||
item for item in cache.raw_data
|
||||
if item['file_path'] != file_path
|
||||
@@ -118,7 +267,7 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
if needs_resort:
|
||||
await cache.resort()
|
||||
|
||||
# 更新文件夹列表,包括新添加的文件夹
|
||||
# Update folder list
|
||||
all_folders = set(cache.folders) | new_folders
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
|
||||
54
py/services/lora_hash_index.py
Normal file
54
py/services/lora_hash_index.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class LoraHashIndex:
|
||||
"""Index for mapping LoRA file hashes to their file paths"""
|
||||
|
||||
def __init__(self):
|
||||
self._hash_to_path: Dict[str, str] = {}
|
||||
|
||||
def add_entry(self, sha256: str, file_path: str) -> None:
|
||||
"""Add or update a hash -> path mapping"""
|
||||
if not sha256 or not file_path:
|
||||
return
|
||||
# Always store lowercase hashes for consistency
|
||||
self._hash_to_path[sha256.lower()] = file_path
|
||||
|
||||
def remove_entry(self, sha256: str) -> None:
|
||||
"""Remove a hash entry"""
|
||||
if sha256:
|
||||
self._hash_to_path.pop(sha256.lower(), None)
|
||||
|
||||
def remove_by_path(self, file_path: str) -> None:
|
||||
"""Remove entry by file path"""
|
||||
for sha256, path in list(self._hash_to_path.items()):
|
||||
if path == file_path:
|
||||
del self._hash_to_path[sha256]
|
||||
break
|
||||
|
||||
def get_path(self, sha256: str) -> Optional[str]:
|
||||
"""Get file path for a given hash"""
|
||||
if not sha256:
|
||||
return None
|
||||
return self._hash_to_path.get(sha256.lower())
|
||||
|
||||
def get_hash(self, file_path: str) -> Optional[str]:
|
||||
"""Get hash for a given file path"""
|
||||
for sha256, path in self._hash_to_path.items():
|
||||
if path == file_path:
|
||||
return sha256
|
||||
return None
|
||||
|
||||
def has_hash(self, sha256: str) -> bool:
|
||||
"""Check if hash exists in index"""
|
||||
if not sha256:
|
||||
return False
|
||||
return sha256.lower() in self._hash_to_path
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries"""
|
||||
self._hash_to_path.clear()
|
||||
@@ -3,13 +3,19 @@ import os
|
||||
import logging
|
||||
import asyncio
|
||||
import shutil
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
|
||||
from ..utils.models import LoraMetadata
|
||||
from ..config import config
|
||||
from ..utils.file_utils import load_metadata, get_file_info
|
||||
from ..utils.file_utils import load_metadata, get_file_info, normalize_path, find_preview_file, save_metadata
|
||||
from ..utils.lora_metadata import extract_lora_metadata
|
||||
from .lora_cache import LoraCache
|
||||
from difflib import SequenceMatcher
|
||||
from .lora_hash_index import LoraHashIndex
|
||||
from .settings_manager import settings
|
||||
from ..utils.constants import NSFW_LEVELS
|
||||
from ..utils.utils import fuzzy_match
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,10 +34,12 @@ class LoraScanner:
|
||||
# 确保初始化只执行一次
|
||||
if not hasattr(self, '_initialized'):
|
||||
self._cache: Optional[LoraCache] = None
|
||||
self._hash_index = LoraHashIndex()
|
||||
self._initialization_lock = asyncio.Lock()
|
||||
self._initialization_task: Optional[asyncio.Task] = None
|
||||
self._initialized = True
|
||||
self.file_monitor = None # Add this line
|
||||
self._tags_count = {} # Add a dictionary to store tag counts
|
||||
|
||||
def set_file_monitor(self, monitor):
|
||||
"""Set file monitor instance"""
|
||||
@@ -85,9 +93,26 @@ class LoraScanner:
|
||||
async def _initialize_cache(self) -> None:
|
||||
"""Initialize or refresh the cache"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
# Clear existing hash index
|
||||
self._hash_index.clear()
|
||||
|
||||
# Clear existing tags count
|
||||
self._tags_count = {}
|
||||
|
||||
# Scan for new data
|
||||
raw_data = await self.scan_all_loras()
|
||||
|
||||
# Build hash index and tags count
|
||||
for lora_data in raw_data:
|
||||
if 'sha256' in lora_data and 'file_path' in lora_data:
|
||||
self._hash_index.add_entry(lora_data['sha256'].lower(), lora_data['file_path'])
|
||||
|
||||
# Count tags
|
||||
if 'tags' in lora_data and lora_data['tags']:
|
||||
for tag in lora_data['tags']:
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
# Update cache
|
||||
self._cache = LoraCache(
|
||||
raw_data=raw_data,
|
||||
@@ -100,7 +125,7 @@ class LoraScanner:
|
||||
await self._cache.resort()
|
||||
|
||||
self._initialization_task = None
|
||||
logger.info("LoRA Manager: Cache initialization completed")
|
||||
logger.info(f"LoRA Manager: Cache initialization completed in {time.time() - start_time:.2f} seconds, found {len(raw_data)} loras")
|
||||
except Exception as e:
|
||||
logger.error(f"LoRA Manager: Error initializing cache: {e}")
|
||||
self._cache = LoraCache(
|
||||
@@ -110,45 +135,10 @@ class LoraScanner:
|
||||
folders=[]
|
||||
)
|
||||
|
||||
def fuzzy_match(self, text: str, pattern: str, threshold: float = 0.7) -> bool:
|
||||
"""
|
||||
Check if text matches pattern using fuzzy matching.
|
||||
Returns True if similarity ratio is above threshold.
|
||||
"""
|
||||
if not pattern or not text:
|
||||
return False
|
||||
|
||||
# Convert both to lowercase for case-insensitive matching
|
||||
text = text.lower()
|
||||
pattern = pattern.lower()
|
||||
|
||||
# Split pattern into words
|
||||
search_words = pattern.split()
|
||||
|
||||
# Check each word
|
||||
for word in search_words:
|
||||
# First check if word is a substring (faster)
|
||||
if word in text:
|
||||
continue
|
||||
|
||||
# If not found as substring, try fuzzy matching
|
||||
# Check if any part of the text matches this word
|
||||
found_match = False
|
||||
for text_part in text.split():
|
||||
ratio = SequenceMatcher(None, text_part, word).ratio()
|
||||
if ratio >= threshold:
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
return False
|
||||
|
||||
# All words found either as substrings or fuzzy matches
|
||||
return True
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
||||
folder: str = None, search: str = None, fuzzy: bool = False,
|
||||
recursive: bool = False):
|
||||
base_models: list = None, tags: list = None,
|
||||
search_options: dict = None) -> Dict:
|
||||
"""Get paginated and filtered lora data
|
||||
|
||||
Args:
|
||||
@@ -158,50 +148,105 @@ class LoraScanner:
|
||||
folder: Filter by folder path
|
||||
search: Search term
|
||||
fuzzy: Use fuzzy matching for search
|
||||
recursive: Include subfolders when folder filter is applied
|
||||
base_models: List of base models to filter by
|
||||
tags: List of tags to filter by
|
||||
search_options: Dictionary with search options (filename, modelname, tags, recursive)
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# 先获取基础数据集
|
||||
# Get default search options if not provided
|
||||
if search_options is None:
|
||||
search_options = {
|
||||
'filename': True,
|
||||
'modelname': True,
|
||||
'tags': False,
|
||||
'recursive': False
|
||||
}
|
||||
|
||||
# Get the base data set
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# 应用文件夹过滤
|
||||
# Apply SFW filtering if enabled
|
||||
if settings.get('show_only_sfw', False):
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
||||
]
|
||||
|
||||
# Apply folder filtering
|
||||
if folder is not None:
|
||||
if recursive:
|
||||
# 递归模式:匹配所有以该文件夹开头的路径
|
||||
if search_options.get('recursive', False):
|
||||
# Recursive mode: match all paths starting with this folder
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'].startswith(folder + '/') or item['folder'] == folder
|
||||
]
|
||||
else:
|
||||
# 非递归模式:只匹配确切的文件夹
|
||||
# Non-recursive mode: match exact folder
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'] == folder
|
||||
]
|
||||
|
||||
# 应用搜索过滤
|
||||
# Apply base model filtering
|
||||
if base_models and len(base_models) > 0:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item.get('base_model') in base_models
|
||||
]
|
||||
|
||||
# Apply tag filtering
|
||||
if tags and len(tags) > 0:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in item.get('tags', []) for tag in tags)
|
||||
]
|
||||
|
||||
# Apply search filtering
|
||||
if search:
|
||||
if fuzzy:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(
|
||||
self.fuzzy_match(str(value), search)
|
||||
for value in [
|
||||
item.get('model_name', ''),
|
||||
item.get('base_model', '')
|
||||
]
|
||||
if value
|
||||
)
|
||||
]
|
||||
else:
|
||||
# Original exact search logic
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if search in str(item.get('model_name', '')).lower()
|
||||
]
|
||||
search_results = []
|
||||
for item in filtered_data:
|
||||
# Check filename if enabled
|
||||
if search_options.get('filename', True):
|
||||
if fuzzy:
|
||||
if fuzzy_match(item.get('file_name', ''), search):
|
||||
search_results.append(item)
|
||||
continue
|
||||
else:
|
||||
if search.lower() in item.get('file_name', '').lower():
|
||||
search_results.append(item)
|
||||
continue
|
||||
|
||||
# Check model name if enabled
|
||||
if search_options.get('modelname', True):
|
||||
if fuzzy:
|
||||
if fuzzy_match(item.get('model_name', ''), search):
|
||||
search_results.append(item)
|
||||
continue
|
||||
else:
|
||||
if search.lower() in item.get('model_name', '').lower():
|
||||
search_results.append(item)
|
||||
continue
|
||||
|
||||
# Check tags if enabled
|
||||
if search_options.get('tags', False) and item.get('tags'):
|
||||
found_tag = False
|
||||
for tag in item['tags']:
|
||||
if fuzzy:
|
||||
if fuzzy_match(tag, search):
|
||||
found_tag = True
|
||||
break
|
||||
else:
|
||||
if search.lower() in tag.lower():
|
||||
found_tag = True
|
||||
break
|
||||
if found_tag:
|
||||
search_results.append(item)
|
||||
continue
|
||||
|
||||
filtered_data = search_results
|
||||
|
||||
# 计算分页
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
@@ -288,16 +333,112 @@ class LoraScanner:
|
||||
metadata = await load_metadata(file_path)
|
||||
|
||||
if metadata is None:
|
||||
# Create new metadata if none exists
|
||||
metadata = await get_file_info(file_path)
|
||||
# Try to find and use .civitai.info file first
|
||||
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
|
||||
if os.path.exists(civitai_info_path):
|
||||
try:
|
||||
with open(civitai_info_path, 'r', encoding='utf-8') as f:
|
||||
version_info = json.load(f)
|
||||
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
if file_info:
|
||||
# Create a minimal file_info with the required fields
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
file_info['name'] = file_name
|
||||
|
||||
# Use from_civitai_info to create metadata
|
||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
await save_metadata(file_path, metadata)
|
||||
logger.debug(f"Created metadata from .civitai.info for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
|
||||
|
||||
# If still no metadata, create new metadata using get_file_info
|
||||
if metadata is None:
|
||||
metadata = await get_file_info(file_path)
|
||||
|
||||
# Convert to dict and add folder info
|
||||
lora_data = metadata.to_dict()
|
||||
# Try to fetch missing metadata from Civitai if needed
|
||||
await self._fetch_missing_metadata(file_path, lora_data)
|
||||
rel_path = os.path.relpath(file_path, root_path)
|
||||
folder = os.path.dirname(rel_path)
|
||||
lora_data['folder'] = folder.replace(os.path.sep, '/')
|
||||
|
||||
return lora_data
|
||||
|
||||
async def _fetch_missing_metadata(self, file_path: str, lora_data: Dict) -> None:
|
||||
"""Fetch missing description and tags from Civitai if needed
|
||||
|
||||
Args:
|
||||
file_path: Path to the lora file
|
||||
lora_data: Lora metadata dictionary to update
|
||||
"""
|
||||
try:
|
||||
# Skip if already marked as deleted on Civitai
|
||||
if lora_data.get('civitai_deleted', False):
|
||||
logger.debug(f"Skipping metadata fetch for {file_path}: marked as deleted on Civitai")
|
||||
return
|
||||
|
||||
# Check if we need to fetch additional metadata from Civitai
|
||||
needs_metadata_update = False
|
||||
model_id = None
|
||||
|
||||
# Check if we have Civitai model ID but missing metadata
|
||||
if lora_data.get('civitai'):
|
||||
# Try to get model ID directly from the correct location
|
||||
model_id = lora_data['civitai'].get('modelId')
|
||||
|
||||
if model_id:
|
||||
model_id = str(model_id)
|
||||
# Check if tags are missing or empty
|
||||
tags_missing = not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0
|
||||
|
||||
# Check if description is missing or empty
|
||||
desc_missing = not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")
|
||||
|
||||
needs_metadata_update = tags_missing or desc_missing
|
||||
|
||||
# Fetch missing metadata if needed
|
||||
if needs_metadata_update and model_id:
|
||||
logger.debug(f"Fetching missing metadata for {file_path} with model ID {model_id}")
|
||||
from ..services.civitai_client import CivitaiClient
|
||||
client = CivitaiClient()
|
||||
|
||||
# Get metadata and status code
|
||||
model_metadata, status_code = await client.get_model_metadata(model_id)
|
||||
await client.close()
|
||||
|
||||
# Handle 404 status (model deleted from Civitai)
|
||||
if status_code == 404:
|
||||
logger.warning(f"Model {model_id} appears to be deleted from Civitai (404 response)")
|
||||
# Mark as deleted to avoid future API calls
|
||||
lora_data['civitai_deleted'] = True
|
||||
|
||||
# Save the updated metadata back to file
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(lora_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Process valid metadata if available
|
||||
elif model_metadata:
|
||||
logger.debug(f"Updating metadata for {file_path} with model ID {model_id}")
|
||||
|
||||
# Update tags if they were missing
|
||||
if model_metadata.get('tags') and (not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0):
|
||||
lora_data['tags'] = model_metadata['tags']
|
||||
|
||||
# Update description if it was missing
|
||||
if model_metadata.get('description') and (not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")):
|
||||
lora_data['modelDescription'] = model_metadata['description']
|
||||
|
||||
# Save the updated metadata back to file
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(lora_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||
|
||||
async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
|
||||
"""Update preview URL in cache for a specific lora
|
||||
@@ -408,20 +549,59 @@ class LoraScanner:
|
||||
|
||||
async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Find the existing item to remove its tags from count
|
||||
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
|
||||
if existing_item and 'tags' in existing_item:
|
||||
for tag in existing_item.get('tags', []):
|
||||
if tag in self._tags_count:
|
||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||
if self._tags_count[tag] == 0:
|
||||
del self._tags_count[tag]
|
||||
|
||||
# Remove old path from hash index if exists
|
||||
self._hash_index.remove_by_path(original_path)
|
||||
|
||||
# Remove the old entry from raw_data
|
||||
cache.raw_data = [
|
||||
item for item in cache.raw_data
|
||||
if item['file_path'] != original_path
|
||||
]
|
||||
item for item in cache.raw_data
|
||||
if item['file_path'] != original_path
|
||||
]
|
||||
|
||||
if metadata:
|
||||
metadata['folder'] = self._calculate_folder(new_path)
|
||||
# If this is an update to an existing path (not a move), ensure folder is preserved
|
||||
if original_path == new_path:
|
||||
# Find the folder from existing entries or calculate it
|
||||
existing_folder = next((item['folder'] for item in cache.raw_data
|
||||
if item['file_path'] == original_path), None)
|
||||
if existing_folder:
|
||||
metadata['folder'] = existing_folder
|
||||
else:
|
||||
metadata['folder'] = self._calculate_folder(new_path)
|
||||
else:
|
||||
# For moved files, recalculate the folder
|
||||
metadata['folder'] = self._calculate_folder(new_path)
|
||||
|
||||
# Add the updated metadata to raw_data
|
||||
cache.raw_data.append(metadata)
|
||||
all_folders = set(cache.folders)
|
||||
all_folders.add(metadata['folder'])
|
||||
|
||||
# Update hash index with new path
|
||||
if 'sha256' in metadata:
|
||||
self._hash_index.add_entry(metadata['sha256'].lower(), new_path)
|
||||
|
||||
# Update folders list
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# Update tags count with the new/updated tags
|
||||
if 'tags' in metadata:
|
||||
for tag in metadata.get('tags', []):
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
# Resort cache
|
||||
await cache.resort()
|
||||
|
||||
|
||||
return True
|
||||
|
||||
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
|
||||
"""Update file paths in metadata file"""
|
||||
@@ -449,3 +629,136 @@ class LoraScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
||||
|
||||
# Add new methods for hash index functionality
|
||||
def has_lora_hash(self, sha256: str) -> bool:
|
||||
"""Check if a LoRA with given hash exists"""
|
||||
return self._hash_index.has_hash(sha256.lower())
|
||||
|
||||
def get_lora_path_by_hash(self, sha256: str) -> Optional[str]:
|
||||
"""Get file path for a LoRA by its hash"""
|
||||
return self._hash_index.get_path(sha256.lower())
|
||||
|
||||
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||
"""Get hash for a LoRA by its file path"""
|
||||
return self._hash_index.get_hash(file_path)
|
||||
|
||||
def get_preview_url_by_hash(self, sha256: str) -> Optional[str]:
|
||||
"""Get preview static URL for a LoRA by its hash"""
|
||||
# Get the file path first
|
||||
file_path = self._hash_index.get_path(sha256.lower())
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
# Determine the preview file path (typically same name with different extension)
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4',
|
||||
'.png', '.jpeg', '.jpg', '.mp4']
|
||||
|
||||
for ext in preview_extensions:
|
||||
preview_path = f"{base_name}{ext}"
|
||||
if os.path.exists(preview_path):
|
||||
# Convert to static URL using config
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
return None
|
||||
|
||||
# Add new method to get top tags
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get top tags sorted by count
|
||||
|
||||
Args:
|
||||
limit: Maximum number of tags to return
|
||||
|
||||
Returns:
|
||||
List of dictionaries with tag name and count, sorted by count
|
||||
"""
|
||||
# Make sure cache is initialized
|
||||
await self.get_cached_data()
|
||||
|
||||
# Sort tags by count in descending order
|
||||
sorted_tags = sorted(
|
||||
[{"tag": tag, "count": count} for tag, count in self._tags_count.items()],
|
||||
key=lambda x: x['count'],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Return limited number
|
||||
return sorted_tags[:limit]
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get base models used in loras sorted by frequency
|
||||
|
||||
Args:
|
||||
limit: Maximum number of base models to return
|
||||
|
||||
Returns:
|
||||
List of dictionaries with base model name and count, sorted by count
|
||||
"""
|
||||
# Make sure cache is initialized
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Count base model occurrences
|
||||
base_model_counts = {}
|
||||
for lora in cache.raw_data:
|
||||
if 'base_model' in lora and lora['base_model']:
|
||||
base_model = lora['base_model']
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
|
||||
# Sort base models by count
|
||||
sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()]
|
||||
sorted_models.sort(key=lambda x: x['count'], reverse=True)
|
||||
|
||||
# Return limited number
|
||||
return sorted_models[:limit]
|
||||
|
||||
async def diagnose_hash_index(self):
|
||||
"""Diagnostic method to verify hash index functionality"""
|
||||
print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr)
|
||||
|
||||
# First check if the hash index has any entries
|
||||
if hasattr(self, '_hash_index'):
|
||||
index_entries = len(self._hash_index._hash_to_path)
|
||||
print(f"Hash index has {index_entries} entries", file=sys.stderr)
|
||||
|
||||
# Print a few example entries if available
|
||||
if index_entries > 0:
|
||||
print("\nSample hash index entries:", file=sys.stderr)
|
||||
count = 0
|
||||
for hash_val, path in self._hash_index._hash_to_path.items():
|
||||
if count < 5: # Just show the first 5
|
||||
print(f"Hash: {hash_val[:8]}... -> Path: {path}", file=sys.stderr)
|
||||
count += 1
|
||||
else:
|
||||
break
|
||||
else:
|
||||
print("Hash index not initialized", file=sys.stderr)
|
||||
|
||||
# Try looking up by a known hash for testing
|
||||
if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path:
|
||||
print("No hash entries to test lookup with", file=sys.stderr)
|
||||
return
|
||||
|
||||
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
|
||||
test_path = self._hash_index.get_path(test_hash)
|
||||
print(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}", file=sys.stderr)
|
||||
|
||||
# Also test reverse lookup
|
||||
test_hash_result = self._hash_index.get_hash(test_path)
|
||||
print(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n", file=sys.stderr)
|
||||
|
||||
async def get_lora_info_by_name(self, name):
|
||||
"""Get LoRA information by name"""
|
||||
try:
|
||||
# Get cached data
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Find the LoRA by name
|
||||
for lora in cache.raw_data:
|
||||
if lora.get("file_name") == name:
|
||||
return lora
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting LoRA info by name: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
85
py/services/recipe_cache.py
Normal file
85
py/services/recipe_cache.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
|
||||
@dataclass
|
||||
class RecipeCache:
|
||||
"""Cache structure for Recipe data"""
|
||||
raw_data: List[Dict]
|
||||
sorted_by_name: List[Dict]
|
||||
sorted_by_date: List[Dict]
|
||||
|
||||
def __post_init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def resort(self, name_only: bool = False):
|
||||
"""Resort all cached data views"""
|
||||
async with self._lock:
|
||||
self.sorted_by_name = sorted(
|
||||
self.raw_data,
|
||||
key=lambda x: x.get('title', '').lower() # Case-insensitive sort
|
||||
)
|
||||
if not name_only:
|
||||
self.sorted_by_date = sorted(
|
||||
self.raw_data,
|
||||
key=itemgetter('created_date', 'file_path'),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: Dict) -> bool:
|
||||
"""Update metadata for a specific recipe in all cached data
|
||||
|
||||
Args:
|
||||
recipe_id: The ID of the recipe to update
|
||||
metadata: The new metadata
|
||||
|
||||
Returns:
|
||||
bool: True if the update was successful, False if the recipe wasn't found
|
||||
"""
|
||||
|
||||
# Update in raw_data
|
||||
for item in self.raw_data:
|
||||
if item.get('id') == recipe_id:
|
||||
item.update(metadata)
|
||||
break
|
||||
else:
|
||||
return False # Recipe not found
|
||||
|
||||
# Resort to reflect changes
|
||||
await self.resort()
|
||||
return True
|
||||
|
||||
async def add_recipe(self, recipe_data: Dict) -> None:
|
||||
"""Add a new recipe to the cache
|
||||
|
||||
Args:
|
||||
recipe_data: The recipe data to add
|
||||
"""
|
||||
async with self._lock:
|
||||
self.raw_data.append(recipe_data)
|
||||
await self.resort()
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||
"""Remove a recipe from the cache by ID
|
||||
|
||||
Args:
|
||||
recipe_id: The ID of the recipe to remove
|
||||
|
||||
Returns:
|
||||
bool: True if the recipe was found and removed, False otherwise
|
||||
"""
|
||||
# Find the recipe in raw_data
|
||||
recipe_index = next((i for i, recipe in enumerate(self.raw_data)
|
||||
if recipe.get('id') == recipe_id), None)
|
||||
|
||||
if recipe_index is None:
|
||||
return False
|
||||
|
||||
# Remove from raw_data
|
||||
self.raw_data.pop(recipe_index)
|
||||
|
||||
# Resort to update sorted lists
|
||||
await self.resort()
|
||||
|
||||
return True
|
||||
564
py/services/recipe_scanner.py
Normal file
564
py/services/recipe_scanner.py
Normal file
@@ -0,0 +1,564 @@
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
from ..config import config
|
||||
from .recipe_cache import RecipeCache
|
||||
from .lora_scanner import LoraScanner
|
||||
from .civitai_client import CivitaiClient
|
||||
from ..utils.utils import fuzzy_match
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
def __new__(cls, lora_scanner: Optional[LoraScanner] = None):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._lora_scanner = lora_scanner
|
||||
cls._instance._civitai_client = CivitaiClient()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, lora_scanner: Optional[LoraScanner] = None):
|
||||
# Ensure initialization only happens once
|
||||
if not hasattr(self, '_initialized'):
|
||||
self._cache: Optional[RecipeCache] = None
|
||||
self._initialization_lock = asyncio.Lock()
|
||||
self._initialization_task: Optional[asyncio.Task] = None
|
||||
self._is_initializing = False
|
||||
if lora_scanner:
|
||||
self._lora_scanner = lora_scanner
|
||||
self._initialized = True
|
||||
|
||||
# Initialization will be scheduled by LoraManager
|
||||
|
||||
@property
|
||||
def recipes_dir(self) -> str:
|
||||
"""Get path to recipes directory"""
|
||||
if not config.loras_roots:
|
||||
return ""
|
||||
|
||||
# config.loras_roots already sorted case-insensitively, use the first one
|
||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes")
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
return recipes_dir
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||
"""Get cached recipe data, refresh if needed"""
|
||||
# If cache is already initialized and no refresh is needed, return it immediately
|
||||
if self._cache is not None and not force_refresh:
|
||||
return self._cache
|
||||
|
||||
# If another initialization is already in progress, wait for it to complete
|
||||
if self._is_initializing and not force_refresh:
|
||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||
|
||||
# Try to acquire the lock with a timeout to prevent deadlocks
|
||||
try:
|
||||
async with self._initialization_lock:
|
||||
# Check again after acquiring the lock
|
||||
if self._cache is not None and not force_refresh:
|
||||
return self._cache
|
||||
|
||||
# Mark as initializing to prevent concurrent initializations
|
||||
self._is_initializing = True
|
||||
|
||||
try:
|
||||
# Remove dependency on lora scanner initialization
|
||||
# Scan for recipe data directly
|
||||
raw_data = await self.scan_all_recipes()
|
||||
|
||||
# Update cache
|
||||
self._cache = RecipeCache(
|
||||
raw_data=raw_data,
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
|
||||
# Resort cache
|
||||
await self._cache.resort()
|
||||
|
||||
return self._cache
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
|
||||
# Create empty cache on error
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
)
|
||||
return self._cache
|
||||
finally:
|
||||
# Mark initialization as complete
|
||||
self._is_initializing = False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||
|
||||
async def scan_all_recipes(self) -> List[Dict]:
|
||||
"""Scan all recipe JSON files and return metadata"""
|
||||
recipes = []
|
||||
recipes_dir = self.recipes_dir
|
||||
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
||||
return recipes
|
||||
|
||||
# Get all recipe JSON files in the recipes directory
|
||||
recipe_files = []
|
||||
for root, _, files in os.walk(recipes_dir):
|
||||
recipe_count = sum(1 for f in files if f.lower().endswith('.recipe.json'))
|
||||
if recipe_count > 0:
|
||||
for file in files:
|
||||
if file.lower().endswith('.recipe.json'):
|
||||
recipe_files.append(os.path.join(root, file))
|
||||
|
||||
# Process each recipe file
|
||||
for recipe_path in recipe_files:
|
||||
recipe_data = await self._load_recipe_file(recipe_path)
|
||||
if recipe_data:
|
||||
recipes.append(recipe_data)
|
||||
|
||||
return recipes
|
||||
|
||||
async def _load_recipe_file(self, recipe_path: str) -> Optional[Dict]:
|
||||
"""Load recipe data from a JSON file"""
|
||||
try:
|
||||
with open(recipe_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Validate recipe data
|
||||
if not recipe_data or not isinstance(recipe_data, dict):
|
||||
logger.warning(f"Invalid recipe data in {recipe_path}")
|
||||
return None
|
||||
|
||||
# Ensure required fields exist
|
||||
required_fields = ['id', 'file_path', 'title']
|
||||
for field in required_fields:
|
||||
if field not in recipe_data:
|
||||
logger.warning(f"Missing required field '{field}' in {recipe_path}")
|
||||
return None
|
||||
|
||||
# Ensure the image file exists
|
||||
image_path = recipe_data.get('file_path')
|
||||
if not os.path.exists(image_path):
|
||||
logger.warning(f"Recipe image not found: {image_path}")
|
||||
# Try to find the image in the same directory as the recipe
|
||||
recipe_dir = os.path.dirname(recipe_path)
|
||||
image_filename = os.path.basename(image_path)
|
||||
alternative_path = os.path.join(recipe_dir, image_filename)
|
||||
if os.path.exists(alternative_path):
|
||||
recipe_data['file_path'] = alternative_path
|
||||
else:
|
||||
logger.warning(f"Could not find alternative image path for {image_path}")
|
||||
|
||||
# Ensure loras array exists
|
||||
if 'loras' not in recipe_data:
|
||||
recipe_data['loras'] = []
|
||||
|
||||
# Ensure gen_params exists
|
||||
if 'gen_params' not in recipe_data:
|
||||
recipe_data['gen_params'] = {}
|
||||
|
||||
# Update lora information with local paths and availability
|
||||
await self._update_lora_information(recipe_data)
|
||||
|
||||
return recipe_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading recipe file {recipe_path}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return None
|
||||
|
||||
async def _update_lora_information(self, recipe_data: Dict) -> bool:
|
||||
"""Update LoRA information with hash and file_name
|
||||
|
||||
Returns:
|
||||
bool: True if metadata was updated
|
||||
"""
|
||||
if not recipe_data.get('loras'):
|
||||
return False
|
||||
|
||||
metadata_updated = False
|
||||
|
||||
for lora in recipe_data['loras']:
|
||||
# Skip if already has complete information
|
||||
if 'hash' in lora and 'file_name' in lora and lora['file_name']:
|
||||
continue
|
||||
|
||||
# If has modelVersionId but no hash, look in lora cache first, then fetch from Civitai
|
||||
if 'modelVersionId' in lora and not lora.get('hash'):
|
||||
model_version_id = lora['modelVersionId']
|
||||
|
||||
# Try to find in lora cache first
|
||||
hash_from_cache = await self._find_hash_in_lora_cache(model_version_id)
|
||||
if hash_from_cache:
|
||||
lora['hash'] = hash_from_cache
|
||||
metadata_updated = True
|
||||
else:
|
||||
# If not in cache, fetch from Civitai
|
||||
hash_from_civitai = await self._get_hash_from_civitai(model_version_id)
|
||||
if hash_from_civitai:
|
||||
lora['hash'] = hash_from_civitai
|
||||
metadata_updated = True
|
||||
else:
|
||||
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
|
||||
|
||||
# If has hash but no file_name, look up in lora library
|
||||
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
|
||||
hash_value = lora['hash']
|
||||
|
||||
if self._lora_scanner.has_lora_hash(hash_value):
|
||||
lora_path = self._lora_scanner.get_lora_path_by_hash(hash_value)
|
||||
if lora_path:
|
||||
file_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||
lora['file_name'] = file_name
|
||||
metadata_updated = True
|
||||
else:
|
||||
# Lora not in library
|
||||
lora['file_name'] = ''
|
||||
metadata_updated = True
|
||||
|
||||
return metadata_updated
|
||||
|
||||
async def _find_hash_in_lora_cache(self, model_version_id: str) -> Optional[str]:
|
||||
"""Find hash in lora cache based on modelVersionId"""
|
||||
try:
|
||||
# Get all loras from cache
|
||||
if not self._lora_scanner:
|
||||
return None
|
||||
|
||||
cache = await self._lora_scanner.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return None
|
||||
|
||||
# Find lora with matching civitai.id
|
||||
for lora in cache.raw_data:
|
||||
civitai_data = lora.get('civitai', {})
|
||||
if civitai_data and str(civitai_data.get('id', '')) == str(model_version_id):
|
||||
return lora.get('sha256')
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding hash in lora cache: {e}")
|
||||
return None
|
||||
|
||||
async def _get_hash_from_civitai(self, model_version_id: str) -> Optional[str]:
|
||||
"""Get hash from Civitai API"""
|
||||
try:
|
||||
if not self._civitai_client:
|
||||
return None
|
||||
|
||||
version_info = await self._civitai_client.get_model_version_info(model_version_id)
|
||||
|
||||
if not version_info or not version_info.get('files'):
|
||||
logger.debug(f"No files found in version info for ID: {model_version_id}")
|
||||
return None
|
||||
|
||||
# Get hash from the first file
|
||||
for file_info in version_info.get('files', []):
|
||||
if file_info.get('hashes', {}).get('SHA256'):
|
||||
return file_info['hashes']['SHA256']
|
||||
|
||||
logger.debug(f"No SHA256 hash found in version info for ID: {model_version_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting hash from Civitai: {e}")
|
||||
return None
|
||||
|
||||
async def _get_model_version_name(self, model_version_id: str) -> Optional[str]:
|
||||
"""Get model version name from Civitai API"""
|
||||
try:
|
||||
if not self._civitai_client:
|
||||
return None
|
||||
|
||||
version_info = await self._civitai_client.get_model_version_info(model_version_id)
|
||||
|
||||
if version_info and 'name' in version_info:
|
||||
return version_info['name']
|
||||
|
||||
logger.debug(f"No version name found for modelVersionId {model_version_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model version name from Civitai: {e}")
|
||||
return None
|
||||
|
||||
async def _determine_base_model(self, loras: List[Dict]) -> Optional[str]:
|
||||
"""Determine the most common base model among LoRAs"""
|
||||
base_models = {}
|
||||
|
||||
# Count occurrences of each base model
|
||||
for lora in loras:
|
||||
if 'hash' in lora:
|
||||
lora_path = self._lora_scanner.get_lora_path_by_hash(lora['hash'])
|
||||
if lora_path:
|
||||
base_model = await self._get_base_model_for_lora(lora_path)
|
||||
if base_model:
|
||||
base_models[base_model] = base_models.get(base_model, 0) + 1
|
||||
|
||||
# Return the most common base model
|
||||
if base_models:
|
||||
return max(base_models.items(), key=lambda x: x[1])[0]
|
||||
return None
|
||||
|
||||
async def _get_base_model_for_lora(self, lora_path: str) -> Optional[str]:
|
||||
"""Get base model for a LoRA from cache"""
|
||||
try:
|
||||
if not self._lora_scanner:
|
||||
return None
|
||||
|
||||
cache = await self._lora_scanner.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return None
|
||||
|
||||
# Find matching lora in cache
|
||||
for lora in cache.raw_data:
|
||||
if lora.get('file_path') == lora_path:
|
||||
return lora.get('base_model')
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting base model for lora: {e}")
|
||||
return None
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None):
|
||||
"""Get paginated and filtered recipe data
|
||||
|
||||
Args:
|
||||
page: Current page number (1-based)
|
||||
page_size: Number of items per page
|
||||
sort_by: Sort method ('name' or 'date')
|
||||
search: Search term
|
||||
filters: Dictionary of filters to apply
|
||||
search_options: Dictionary of search options to apply
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Get base dataset
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
# Default search options if none provided
|
||||
if not search_options:
|
||||
search_options = {
|
||||
'title': True,
|
||||
'tags': True,
|
||||
'lora_name': True,
|
||||
'lora_model': True
|
||||
}
|
||||
|
||||
# Build the search predicate based on search options
|
||||
def matches_search(item):
|
||||
# Search in title if enabled
|
||||
if search_options.get('title', True):
|
||||
if fuzzy_match(str(item.get('title', '')), search):
|
||||
return True
|
||||
|
||||
# Search in tags if enabled
|
||||
if search_options.get('tags', True) and 'tags' in item:
|
||||
for tag in item['tags']:
|
||||
if fuzzy_match(tag, search):
|
||||
return True
|
||||
|
||||
# Search in lora file names if enabled
|
||||
if search_options.get('lora_name', True) and 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if fuzzy_match(str(lora.get('file_name', '')), search):
|
||||
return True
|
||||
|
||||
# Search in lora model names if enabled
|
||||
if search_options.get('lora_model', True) and 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if fuzzy_match(str(lora.get('modelName', '')), search):
|
||||
return True
|
||||
|
||||
# No match found
|
||||
return False
|
||||
|
||||
# Filter the data using the search predicate
|
||||
filtered_data = [item for item in filtered_data if matches_search(item)]
|
||||
|
||||
# Apply additional filters
|
||||
if filters:
|
||||
# Filter by base model
|
||||
if 'base_model' in filters and filters['base_model']:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item.get('base_model', '') in filters['base_model']
|
||||
]
|
||||
|
||||
# Filter by tags
|
||||
if 'tags' in filters and filters['tags']:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in item.get('tags', []) for tag in filters['tags'])
|
||||
]
|
||||
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
# Get paginated items
|
||||
paginated_items = filtered_data[start_idx:end_idx]
|
||||
|
||||
# Add inLibrary information for each lora
|
||||
for item in paginated_items:
|
||||
if 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if 'hash' in lora and lora['hash']:
|
||||
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower())
|
||||
lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora['hash'].lower())
|
||||
lora['localPath'] = self._lora_scanner.get_lora_path_by_hash(lora['hash'].lower())
|
||||
|
||||
result = {
|
||||
'items': paginated_items,
|
||||
'total': total_items,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total_items + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
|
||||
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||
|
||||
Args:
|
||||
recipe_id: The ID of the recipe to update
|
||||
metadata: Dictionary containing metadata fields to update (title, tags, etc.)
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
# First, find the recipe JSON file path
|
||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
|
||||
if not os.path.exists(recipe_json_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Load existing recipe data
|
||||
with open(recipe_json_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Update fields
|
||||
for key, value in metadata.items():
|
||||
recipe_data[key] = value
|
||||
|
||||
# Save updated recipe
|
||||
with open(recipe_json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
# Update the cache if it exists
|
||||
if self._cache is not None:
|
||||
await self._cache.update_recipe_metadata(recipe_id, metadata)
|
||||
|
||||
# If the recipe has an image, update its EXIF metadata
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
image_path = recipe_data.get('file_path')
|
||||
if image_path and os.path.exists(image_path):
|
||||
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating recipe metadata: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def update_lora_filename_by_hash(self, hash_value: str, new_file_name: str) -> Tuple[int, int]:
|
||||
"""Update file_name in all recipes that contain a LoRA with the specified hash.
|
||||
|
||||
Args:
|
||||
hash_value: The SHA256 hash value of the LoRA
|
||||
new_file_name: The new file_name to set
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache)
|
||||
"""
|
||||
if not hash_value or not new_file_name:
|
||||
return 0, 0
|
||||
|
||||
# Always use lowercase hash for consistency
|
||||
hash_value = hash_value.lower()
|
||||
|
||||
# Get recipes directory
|
||||
recipes_dir = self.recipes_dir
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
||||
return 0, 0
|
||||
|
||||
# Check if cache is initialized
|
||||
cache_initialized = self._cache is not None
|
||||
cache_updated_count = 0
|
||||
file_updated_count = 0
|
||||
|
||||
# Get all recipe JSON files in the recipes directory
|
||||
recipe_files = []
|
||||
for root, _, files in os.walk(recipes_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith('.recipe.json'):
|
||||
recipe_files.append(os.path.join(root, file))
|
||||
|
||||
# Process each recipe file
|
||||
for recipe_path in recipe_files:
|
||||
try:
|
||||
# Load the recipe data
|
||||
with open(recipe_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Skip if no loras or invalid structure
|
||||
if not recipe_data or not isinstance(recipe_data, dict) or 'loras' not in recipe_data:
|
||||
continue
|
||||
|
||||
# Check if any lora has matching hash
|
||||
file_updated = False
|
||||
for lora in recipe_data.get('loras', []):
|
||||
if 'hash' in lora and lora['hash'].lower() == hash_value:
|
||||
# Update file_name
|
||||
old_file_name = lora.get('file_name', '')
|
||||
lora['file_name'] = new_file_name
|
||||
file_updated = True
|
||||
logger.info(f"Updated file_name in recipe {recipe_path}: {old_file_name} -> {new_file_name}")
|
||||
|
||||
# If updated, save the file
|
||||
if file_updated:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
file_updated_count += 1
|
||||
|
||||
# Also update in cache if it exists
|
||||
if cache_initialized:
|
||||
recipe_id = recipe_data.get('id')
|
||||
if recipe_id:
|
||||
for cache_item in self._cache.raw_data:
|
||||
if cache_item.get('id') == recipe_id:
|
||||
# Replace loras array with updated version
|
||||
cache_item['loras'] = recipe_data['loras']
|
||||
cache_updated_count += 1
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe file {recipe_path}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
|
||||
# Resort cache if updates were made
|
||||
if cache_initialized and cache_updated_count > 0:
|
||||
await self._cache.resort()
|
||||
logger.info(f"Resorted recipe cache after updating {cache_updated_count} items")
|
||||
|
||||
return file_updated_count, cache_updated_count
|
||||
@@ -37,7 +37,8 @@ class SettingsManager:
|
||||
def _get_default_settings(self) -> Dict[str, Any]:
|
||||
"""Return default settings"""
|
||||
return {
|
||||
"civitai_api_key": ""
|
||||
"civitai_api_key": "",
|
||||
"show_only_sfw": False
|
||||
}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
|
||||
8
py/utils/constants.py
Normal file
8
py/utils/constants.py
Normal file
@@ -0,0 +1,8 @@
|
||||
NSFW_LEVELS = {
|
||||
"PG": 1,
|
||||
"PG13": 2,
|
||||
"R": 4,
|
||||
"X": 8,
|
||||
"XXX": 16,
|
||||
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
|
||||
}
|
||||
315
py/utils/exif_utils.py
Normal file
315
py/utils/exif_utils.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import piexif
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from io import BytesIO
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExifUtils:
|
||||
"""Utility functions for working with EXIF data in images"""
|
||||
|
||||
@staticmethod
|
||||
def extract_image_metadata(image_path: str) -> Optional[str]:
|
||||
"""Extract metadata from image including UserComment or parameters field
|
||||
|
||||
Args:
|
||||
image_path (str): Path to the image file
|
||||
|
||||
Returns:
|
||||
Optional[str]: Extracted metadata or None if not found
|
||||
"""
|
||||
try:
|
||||
# First try to open the image
|
||||
with Image.open(image_path) as img:
|
||||
# Method 1: Check for parameters in image info
|
||||
if hasattr(img, 'info') and 'parameters' in img.info:
|
||||
return img.info['parameters']
|
||||
|
||||
# Method 2: Check EXIF UserComment field
|
||||
if img.format not in ['JPEG', 'TIFF', 'WEBP']:
|
||||
# For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
|
||||
exif = img._getexif()
|
||||
if exif and piexif.ExifIFD.UserComment in exif:
|
||||
user_comment = exif[piexif.ExifIFD.UserComment]
|
||||
if isinstance(user_comment, bytes):
|
||||
if user_comment.startswith(b'UNICODE\0'):
|
||||
return user_comment[8:].decode('utf-16be')
|
||||
return user_comment.decode('utf-8', errors='ignore')
|
||||
return user_comment
|
||||
|
||||
# For JPEG/TIFF/WEBP, use piexif
|
||||
try:
|
||||
exif_dict = piexif.load(image_path)
|
||||
|
||||
if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}):
|
||||
user_comment = exif_dict['Exif'][piexif.ExifIFD.UserComment]
|
||||
if isinstance(user_comment, bytes):
|
||||
if user_comment.startswith(b'UNICODE\0'):
|
||||
user_comment = user_comment[8:].decode('utf-16be')
|
||||
else:
|
||||
user_comment = user_comment.decode('utf-8', errors='ignore')
|
||||
return user_comment
|
||||
except Exception as e:
|
||||
logger.debug(f"Error loading EXIF data: {e}")
|
||||
|
||||
# Method 3: Check PNG metadata for workflow info (for ComfyUI images)
|
||||
if img.format == 'PNG':
|
||||
# Look for workflow or prompt metadata in PNG chunks
|
||||
for key in img.info:
|
||||
if key in ['workflow', 'prompt', 'parameters']:
|
||||
return img.info[key]
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting image metadata: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def update_image_metadata(image_path: str, metadata: str) -> str:
|
||||
"""Update metadata in image's EXIF data or parameters fields
|
||||
|
||||
Args:
|
||||
image_path (str): Path to the image file
|
||||
metadata (str): Metadata string to save
|
||||
|
||||
Returns:
|
||||
str: Path to the updated image
|
||||
"""
|
||||
try:
|
||||
# Load the image and check its format
|
||||
with Image.open(image_path) as img:
|
||||
img_format = img.format
|
||||
|
||||
# For PNG, try to update parameters directly
|
||||
if img_format == 'PNG':
|
||||
# We'll save with parameters in the PNG info
|
||||
info_dict = {'parameters': metadata}
|
||||
img.save(image_path, format='PNG', pnginfo=info_dict)
|
||||
return image_path
|
||||
|
||||
# For WebP format, use PIL's exif parameter directly
|
||||
elif img_format == 'WEBP':
|
||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
|
||||
# Save with the exif data
|
||||
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
|
||||
return image_path
|
||||
|
||||
# For other formats, use standard EXIF approach
|
||||
else:
|
||||
try:
|
||||
exif_dict = piexif.load(img.info.get('exif', b''))
|
||||
except:
|
||||
exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}}
|
||||
|
||||
# If no Exif dictionary exists, create one
|
||||
if 'Exif' not in exif_dict:
|
||||
exif_dict['Exif'] = {}
|
||||
|
||||
# Update the UserComment field - use UNICODE format
|
||||
unicode_bytes = metadata.encode('utf-16be')
|
||||
metadata_bytes = b'UNICODE\0' + unicode_bytes
|
||||
|
||||
exif_dict['Exif'][piexif.ExifIFD.UserComment] = metadata_bytes
|
||||
|
||||
# Convert EXIF dict back to bytes
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
|
||||
# Save the image with updated EXIF data
|
||||
img.save(image_path, exif=exif_bytes)
|
||||
|
||||
return image_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating metadata in {image_path}: {e}")
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data"""
|
||||
try:
|
||||
# First, extract existing metadata
|
||||
metadata = ExifUtils.extract_image_metadata(image_path)
|
||||
|
||||
# Check if there's already recipe metadata
|
||||
if metadata:
|
||||
# Remove any existing recipe metadata
|
||||
metadata = ExifUtils.remove_recipe_metadata(metadata)
|
||||
|
||||
# Prepare simplified loras data
|
||||
simplified_loras = []
|
||||
for lora in recipe_data.get("loras", []):
|
||||
simplified_lora = {
|
||||
"file_name": lora.get("file_name", ""),
|
||||
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
|
||||
"strength": float(lora.get("strength", 1.0)),
|
||||
"modelVersionId": lora.get("modelVersionId", ""),
|
||||
"modelName": lora.get("modelName", ""),
|
||||
"modelVersionName": lora.get("modelVersionName", ""),
|
||||
}
|
||||
simplified_loras.append(simplified_lora)
|
||||
|
||||
# Create recipe metadata JSON
|
||||
recipe_metadata = {
|
||||
'title': recipe_data.get('title', ''),
|
||||
'base_model': recipe_data.get('base_model', ''),
|
||||
'loras': simplified_loras,
|
||||
'gen_params': recipe_data.get('gen_params', {}),
|
||||
'tags': recipe_data.get('tags', [])
|
||||
}
|
||||
|
||||
# Convert to JSON string
|
||||
recipe_metadata_json = json.dumps(recipe_metadata)
|
||||
|
||||
# Create the recipe metadata marker
|
||||
recipe_metadata_marker = f"Recipe metadata: {recipe_metadata_json}"
|
||||
|
||||
# Append to existing metadata or create new one
|
||||
new_metadata = f"{metadata} \n {recipe_metadata_marker}" if metadata else recipe_metadata_marker
|
||||
|
||||
# Write back to the image
|
||||
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def remove_recipe_metadata(user_comment):
|
||||
"""Remove recipe metadata from user comment"""
|
||||
if not user_comment:
|
||||
return ""
|
||||
|
||||
# Find the recipe metadata marker
|
||||
recipe_marker_index = user_comment.find("Recipe metadata: ")
|
||||
if recipe_marker_index == -1:
|
||||
return user_comment
|
||||
|
||||
# If recipe metadata is not at the start, remove the preceding ", "
|
||||
if recipe_marker_index >= 2 and user_comment[recipe_marker_index-2:recipe_marker_index] == ", ":
|
||||
recipe_marker_index -= 2
|
||||
|
||||
# Remove the recipe metadata part
|
||||
# First, find where the metadata ends (next line or end of string)
|
||||
next_line_index = user_comment.find("\n", recipe_marker_index)
|
||||
if next_line_index == -1:
|
||||
# Metadata is at the end of the string
|
||||
return user_comment[:recipe_marker_index].rstrip()
|
||||
else:
|
||||
# Metadata is in the middle of the string
|
||||
return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
|
||||
|
||||
@staticmethod
|
||||
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=True):
|
||||
"""
|
||||
Optimize an image by resizing and converting to WebP format
|
||||
|
||||
Args:
|
||||
image_data: Binary image data or path to image file
|
||||
target_width: Width to resize the image to (preserves aspect ratio)
|
||||
format: Output format (default: webp)
|
||||
quality: Output quality (0-100)
|
||||
preserve_metadata: Whether to preserve EXIF metadata
|
||||
|
||||
Returns:
|
||||
Tuple of (optimized_image_data, extension)
|
||||
"""
|
||||
try:
|
||||
# Extract metadata if needed
|
||||
metadata = None
|
||||
if preserve_metadata:
|
||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||
# It's a file path
|
||||
metadata = ExifUtils.extract_image_metadata(image_data)
|
||||
img = Image.open(image_data)
|
||||
else:
|
||||
# It's binary data
|
||||
temp_img = BytesIO(image_data)
|
||||
img = Image.open(temp_img)
|
||||
# Save to a temporary file to extract metadata
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
temp_file.write(image_data)
|
||||
metadata = ExifUtils.extract_image_metadata(temp_path)
|
||||
os.unlink(temp_path)
|
||||
else:
|
||||
# Just open the image without extracting metadata
|
||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||
img = Image.open(image_data)
|
||||
else:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
|
||||
# Calculate new height to maintain aspect ratio
|
||||
width, height = img.size
|
||||
new_height = int(height * (target_width / width))
|
||||
|
||||
# Resize the image
|
||||
resized_img = img.resize((target_width, new_height), Image.LANCZOS)
|
||||
|
||||
# Save to BytesIO in the specified format
|
||||
output = BytesIO()
|
||||
|
||||
# WebP format
|
||||
if format.lower() == 'webp':
|
||||
resized_img.save(output, format='WEBP', quality=quality)
|
||||
extension = '.webp'
|
||||
# JPEG format
|
||||
elif format.lower() in ('jpg', 'jpeg'):
|
||||
resized_img.save(output, format='JPEG', quality=quality)
|
||||
extension = '.jpg'
|
||||
# PNG format
|
||||
elif format.lower() == 'png':
|
||||
resized_img.save(output, format='PNG', optimize=True)
|
||||
extension = '.png'
|
||||
else:
|
||||
# Default to WebP
|
||||
resized_img.save(output, format='WEBP', quality=quality)
|
||||
extension = '.webp'
|
||||
|
||||
# Get the optimized image data
|
||||
optimized_data = output.getvalue()
|
||||
|
||||
# If we need to preserve metadata, write it to a temporary file
|
||||
if preserve_metadata and metadata:
|
||||
# For WebP format, we'll directly save with metadata
|
||||
if format.lower() == 'webp':
|
||||
# Create a new BytesIO with metadata
|
||||
output_with_metadata = BytesIO()
|
||||
|
||||
# Create EXIF data with user comment
|
||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
|
||||
# Save with metadata
|
||||
resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality)
|
||||
optimized_data = output_with_metadata.getvalue()
|
||||
else:
|
||||
# For other formats, use the temporary file approach
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
temp_file.write(optimized_data)
|
||||
|
||||
# Add the metadata back
|
||||
ExifUtils.update_image_metadata(temp_path, metadata)
|
||||
|
||||
# Read the file with metadata
|
||||
with open(temp_path, 'rb') as f:
|
||||
optimized_data = f.read()
|
||||
|
||||
# Clean up
|
||||
os.unlink(temp_path)
|
||||
|
||||
return optimized_data, extension
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error optimizing image: {e}", exc_info=True)
|
||||
# Return original data if optimization fails
|
||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||
with open(image_data, 'rb') as f:
|
||||
return f.read(), os.path.splitext(image_data)[1]
|
||||
return image_data, '.jpg'
|
||||
@@ -4,6 +4,8 @@ import hashlib
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .model_utils import determine_base_model
|
||||
|
||||
from .lora_metadata import extract_lora_metadata
|
||||
from .models import LoraMetadata
|
||||
|
||||
@@ -17,7 +19,7 @@ async def calculate_sha256(file_path: str) -> str:
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
def _find_preview_file(base_name: str, dir_path: str) -> str:
|
||||
def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||
"""Find preview file for given base name in directory"""
|
||||
preview_patterns = [
|
||||
f"{base_name}.preview.png",
|
||||
@@ -54,21 +56,40 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
preview_url = _find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
|
||||
# Check if a .json file exists with SHA256 hash to avoid recalculation
|
||||
json_path = f"{os.path.splitext(file_path)[0]}.json"
|
||||
sha256 = None
|
||||
if os.path.exists(json_path):
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
if 'sha256' in json_data:
|
||||
sha256 = json_data['sha256'].lower()
|
||||
logger.debug(f"Using SHA256 from .json file for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading .json file for {file_path}: {e}")
|
||||
|
||||
try:
|
||||
# If we didn't get SHA256 from the .json file, calculate it
|
||||
if not sha256:
|
||||
sha256 = await calculate_sha256(real_path)
|
||||
|
||||
metadata = LoraMetadata(
|
||||
file_name=base_name,
|
||||
model_name=base_name,
|
||||
file_path=normalize_path(file_path),
|
||||
size=os.path.getsize(real_path),
|
||||
modified=os.path.getmtime(real_path),
|
||||
sha256=await calculate_sha256(real_path),
|
||||
sha256=sha256,
|
||||
base_model="Unknown", # Will be updated later
|
||||
usage_tips="",
|
||||
notes="",
|
||||
from_civitai=True,
|
||||
preview_url=normalize_path(preview_url),
|
||||
tags=[],
|
||||
modelDescription=""
|
||||
)
|
||||
|
||||
# create metadata file
|
||||
@@ -103,21 +124,43 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
|
||||
data = json.load(f)
|
||||
|
||||
needs_update = False
|
||||
|
||||
# Check and normalize base model name
|
||||
normalized_base_model = determine_base_model(data['base_model'])
|
||||
if data['base_model'] != normalized_base_model:
|
||||
data['base_model'] = normalized_base_model
|
||||
needs_update = True
|
||||
|
||||
if data['file_path'] != normalize_path(data['file_path']):
|
||||
data['file_path'] = normalize_path(data['file_path'])
|
||||
# Compare paths without extensions
|
||||
stored_path_base = os.path.splitext(data['file_path'])[0]
|
||||
current_path_base = os.path.splitext(normalize_path(file_path))[0]
|
||||
if stored_path_base != current_path_base:
|
||||
data['file_path'] = normalize_path(file_path)
|
||||
needs_update = True
|
||||
|
||||
preview_url = data.get('preview_url', '')
|
||||
if not preview_url or not os.path.exists(preview_url):
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
new_preview_url = normalize_path(_find_preview_file(base_name, dir_path))
|
||||
new_preview_url = normalize_path(find_preview_file(base_name, dir_path))
|
||||
if new_preview_url != preview_url:
|
||||
data['preview_url'] = new_preview_url
|
||||
needs_update = True
|
||||
elif preview_url != normalize_path(preview_url):
|
||||
data['preview_url'] = normalize_path(preview_url)
|
||||
else:
|
||||
# Compare preview paths without extensions
|
||||
stored_preview_base = os.path.splitext(preview_url)[0]
|
||||
current_preview_base = os.path.splitext(normalize_path(preview_url))[0]
|
||||
if stored_preview_base != current_preview_base:
|
||||
data['preview_url'] = normalize_path(preview_url)
|
||||
needs_update = True
|
||||
|
||||
# Ensure all fields are present
|
||||
if 'tags' not in data:
|
||||
data['tags'] = []
|
||||
needs_update = True
|
||||
|
||||
if 'modelDescription' not in data:
|
||||
data['modelDescription'] = ""
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
|
||||
@@ -2,14 +2,17 @@ from typing import Optional
|
||||
|
||||
# Base model mapping based on version string
|
||||
BASE_MODEL_MAPPING = {
|
||||
"sd-v1-5": "SD1.5",
|
||||
"sd-v2-1": "SD2.1",
|
||||
"sdxl": "SDXL",
|
||||
"sd-v2": "SD2.0",
|
||||
"sd_1.5": "SD 1.5",
|
||||
"sd-v1-5": "SD 1.5",
|
||||
"sd-v2-1": "SD 2.1",
|
||||
"sdxl": "SDXL 1.0",
|
||||
"sd-v2": "SD 2.0",
|
||||
"flux1": "Flux.1 D",
|
||||
"flux.1 d": "Flux.1 D",
|
||||
"illustrious": "IL",
|
||||
"pony": "Pony"
|
||||
"illustrious": "Illustrious",
|
||||
"il": "Illustrious",
|
||||
"pony": "Pony",
|
||||
"Hunyuan Video": "Hunyuan Video"
|
||||
}
|
||||
|
||||
def determine_base_model(version_string: Optional[str]) -> str:
|
||||
@@ -22,4 +25,5 @@ def determine_base_model(version_string: Optional[str]) -> str:
|
||||
if key in version_lower:
|
||||
return value
|
||||
|
||||
return "Unknown"
|
||||
# TODO: Add more base model mappings
|
||||
return version_string
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime
|
||||
import os
|
||||
from .model_utils import determine_base_model
|
||||
@@ -15,10 +15,18 @@ class LoraMetadata:
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
usage_tips: str = "{}" # Usage tips for the model, json string
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether the lora is from Civitai
|
||||
from_civitai: bool = True # Whether the lora is from Civitai
|
||||
civitai: Optional[Dict] = None # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize empty lists to avoid mutable default parameter issue
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'LoraMetadata':
|
||||
@@ -39,9 +47,10 @@ class LoraMetadata:
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=file_info['hashes'].get('SHA256', ''),
|
||||
sha256=file_info['hashes'].get('SHA256', '').lower(),
|
||||
base_model=base_model,
|
||||
preview_url=None, # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download, it is decided by the nsfw level of the preview image
|
||||
from_civitai=True,
|
||||
civitai=version_info
|
||||
)
|
||||
@@ -66,3 +75,31 @@ class LoraMetadata:
|
||||
self.modified = os.path.getmtime(file_path)
|
||||
self.file_path = file_path.replace(os.sep, '/')
|
||||
|
||||
@dataclass
|
||||
class CheckpointMetadata:
|
||||
"""Represents the metadata structure for a Checkpoint model"""
|
||||
file_name: str # The filename without extension
|
||||
model_name: str # The checkpoint's name defined by the creator
|
||||
file_path: str # Full path to the model file
|
||||
size: int # File size in bytes
|
||||
modified: float # Last modified timestamp
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
model_type: str = "checkpoint" # Model type (checkpoint, inpainting, etc.)
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Optional[Dict] = None # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
|
||||
# Additional checkpoint-specific fields
|
||||
resolution: Optional[str] = None # Native resolution (e.g., 512x512, 1024x1024)
|
||||
vae_included: bool = False # Whether VAE is included in the checkpoint
|
||||
architecture: str = "" # Model architecture (if known)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
|
||||
1083
py/utils/recipe_parsers.py
Normal file
1083
py/utils/recipe_parsers.py
Normal file
File diff suppressed because it is too large
Load Diff
116
py/utils/utils.py
Normal file
116
py/utils/utils.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from difflib import SequenceMatcher
|
||||
import requests
|
||||
import tempfile
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
def download_twitter_image(url):
|
||||
"""Download image from a URL containing twitter:image meta tag
|
||||
|
||||
Args:
|
||||
url (str): The URL to download image from
|
||||
|
||||
Returns:
|
||||
str: Path to downloaded temporary image file
|
||||
"""
|
||||
try:
|
||||
# Download page content
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Find twitter:image meta tag
|
||||
meta_tag = soup.find('meta', attrs={'property': 'twitter:image'})
|
||||
if not meta_tag:
|
||||
return None
|
||||
|
||||
image_url = meta_tag['content']
|
||||
|
||||
# Download image
|
||||
image_response = requests.get(image_url)
|
||||
image_response.raise_for_status()
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||
temp_file.write(image_response.content)
|
||||
return temp_file.name
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading twitter image: {e}")
|
||||
return None
|
||||
|
||||
def download_civitai_image(url):
|
||||
"""Download image from a URL containing avatar image with specific class and style attributes
|
||||
|
||||
Args:
|
||||
url (str): The URL to download image from
|
||||
|
||||
Returns:
|
||||
str: Path to downloaded temporary image file
|
||||
"""
|
||||
try:
|
||||
# Download page content
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Find image with specific class and style attributes
|
||||
image = soup.select_one('img.EdgeImage_image__iH4_q.max-h-full.w-auto.max-w-full')
|
||||
|
||||
if not image or 'src' not in image.attrs:
|
||||
return None
|
||||
|
||||
image_url = image['src']
|
||||
|
||||
# Download image
|
||||
image_response = requests.get(image_url)
|
||||
image_response.raise_for_status()
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||
temp_file.write(image_response.content)
|
||||
return temp_file.name
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading civitai avatar: {e}")
|
||||
return None
|
||||
|
||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool:
|
||||
"""
|
||||
Check if text matches pattern using fuzzy matching.
|
||||
Returns True if similarity ratio is above threshold.
|
||||
"""
|
||||
if not pattern or not text:
|
||||
return False
|
||||
|
||||
# Convert both to lowercase for case-insensitive matching
|
||||
text = text.lower()
|
||||
pattern = pattern.lower()
|
||||
|
||||
# Split pattern into words
|
||||
search_words = pattern.split()
|
||||
|
||||
# Check each word
|
||||
for word in search_words:
|
||||
# First check if word is a substring (faster)
|
||||
if word in text:
|
||||
continue
|
||||
|
||||
# If not found as substring, try fuzzy matching
|
||||
# Check if any part of the text matches this word
|
||||
found_match = False
|
||||
for text_part in text.split():
|
||||
ratio = SequenceMatcher(None, text_part, word).ratio()
|
||||
if ratio >= threshold:
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
return False
|
||||
|
||||
# All words found either as substrings or fuzzy matches
|
||||
return True
|
||||
3
py/workflow/__init__.py
Normal file
3
py/workflow/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
ComfyUI workflow parsing module to extract generation parameters
|
||||
"""
|
||||
58
py/workflow/cli.py
Normal file
58
py/workflow/cli.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Command-line interface for the ComfyUI workflow parser
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
from .parser import parse_workflow
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def main():
|
||||
"""Entry point for the CLI"""
|
||||
parser = argparse.ArgumentParser(description='Parse ComfyUI workflow files')
|
||||
parser.add_argument('input', help='Input workflow JSON file path')
|
||||
parser.add_argument('-o', '--output', help='Output JSON file path')
|
||||
parser.add_argument('-p', '--pretty', action='store_true', help='Pretty print JSON output')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set logging level
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# Validate input file
|
||||
if not os.path.isfile(args.input):
|
||||
logger.error(f"Input file not found: {args.input}")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse workflow
|
||||
try:
|
||||
result = parse_workflow(args.input, args.output)
|
||||
|
||||
# Print result to console if output file not specified
|
||||
if not args.output:
|
||||
if args.pretty:
|
||||
print(json.dumps(result, indent=4))
|
||||
else:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
logger.info(f"Output saved to: {args.output}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing workflow: {e}")
|
||||
if args.debug:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
py/workflow/ext/__init__.py
Normal file
3
py/workflow/ext/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Extension directory for custom node mappers
|
||||
"""
|
||||
285
py/workflow/ext/comfyui_core.py
Normal file
285
py/workflow/ext/comfyui_core.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
ComfyUI Core nodes mappers extension for workflow parsing
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Transform Functions
|
||||
# =============================================================================
|
||||
|
||||
def transform_random_noise(inputs: Dict) -> Dict:
|
||||
"""Transform function for RandomNoise node"""
|
||||
return {"seed": str(inputs.get("noise_seed", ""))}
|
||||
|
||||
def transform_ksampler_select(inputs: Dict) -> Dict:
|
||||
"""Transform function for KSamplerSelect node"""
|
||||
return {"sampler": inputs.get("sampler_name", "")}
|
||||
|
||||
def transform_basic_scheduler(inputs: Dict) -> Dict:
|
||||
"""Transform function for BasicScheduler node"""
|
||||
result = {
|
||||
"scheduler": inputs.get("scheduler", ""),
|
||||
"denoise": str(inputs.get("denoise", "1.0"))
|
||||
}
|
||||
|
||||
# Get steps from inputs or steps input
|
||||
if "steps" in inputs:
|
||||
if isinstance(inputs["steps"], str):
|
||||
result["steps"] = inputs["steps"]
|
||||
elif isinstance(inputs["steps"], dict) and "value" in inputs["steps"]:
|
||||
result["steps"] = str(inputs["steps"]["value"])
|
||||
else:
|
||||
result["steps"] = str(inputs["steps"])
|
||||
|
||||
return result
|
||||
|
||||
def transform_basic_guider(inputs: Dict) -> Dict:
|
||||
"""Transform function for BasicGuider node"""
|
||||
result = {}
|
||||
|
||||
# Process conditioning
|
||||
if "conditioning" in inputs:
|
||||
if isinstance(inputs["conditioning"], str):
|
||||
result["prompt"] = inputs["conditioning"]
|
||||
elif isinstance(inputs["conditioning"], dict):
|
||||
result["conditioning"] = inputs["conditioning"]
|
||||
|
||||
# Get model information if needed
|
||||
if "model" in inputs and isinstance(inputs["model"], dict):
|
||||
result["model"] = inputs["model"]
|
||||
|
||||
return result
|
||||
|
||||
def transform_model_sampling_flux(inputs: Dict) -> Dict:
|
||||
"""Transform function for ModelSamplingFlux - mostly a pass-through node"""
|
||||
# This node is primarily used for routing, so we mostly pass through values
|
||||
|
||||
return inputs["model"]
|
||||
|
||||
def transform_sampler_custom_advanced(inputs: Dict) -> Dict:
|
||||
"""Transform function for SamplerCustomAdvanced node"""
|
||||
result = {}
|
||||
|
||||
# Extract seed from noise
|
||||
if "noise" in inputs and isinstance(inputs["noise"], dict):
|
||||
result["seed"] = str(inputs["noise"].get("seed", ""))
|
||||
|
||||
# Extract sampler info
|
||||
if "sampler" in inputs and isinstance(inputs["sampler"], dict):
|
||||
sampler = inputs["sampler"].get("sampler", "")
|
||||
if sampler:
|
||||
result["sampler"] = sampler
|
||||
|
||||
# Extract scheduler, steps, denoise from sigmas
|
||||
if "sigmas" in inputs and isinstance(inputs["sigmas"], dict):
|
||||
sigmas = inputs["sigmas"]
|
||||
result["scheduler"] = sigmas.get("scheduler", "")
|
||||
result["steps"] = str(sigmas.get("steps", ""))
|
||||
result["denoise"] = str(sigmas.get("denoise", "1.0"))
|
||||
|
||||
# Extract prompt and guidance from guider
|
||||
if "guider" in inputs and isinstance(inputs["guider"], dict):
|
||||
guider = inputs["guider"]
|
||||
|
||||
# Get prompt from conditioning
|
||||
if "conditioning" in guider and isinstance(guider["conditioning"], str):
|
||||
result["prompt"] = guider["conditioning"]
|
||||
elif "conditioning" in guider and isinstance(guider["conditioning"], dict):
|
||||
result["guidance"] = guider["conditioning"].get("guidance", "")
|
||||
result["prompt"] = guider["conditioning"].get("prompt", "")
|
||||
|
||||
if "model" in guider and isinstance(guider["model"], dict):
|
||||
result["checkpoint"] = guider["model"].get("checkpoint", "")
|
||||
result["loras"] = guider["model"].get("loras", "")
|
||||
result["clip_skip"] = str(int(guider["model"].get("clip_skip", "-1")) * -1)
|
||||
|
||||
# Extract dimensions from latent_image
|
||||
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
|
||||
latent = inputs["latent_image"]
|
||||
width = latent.get("width", 0)
|
||||
height = latent.get("height", 0)
|
||||
if width and height:
|
||||
result["width"] = width
|
||||
result["height"] = height
|
||||
result["size"] = f"{width}x{height}"
|
||||
|
||||
return result
|
||||
|
||||
def transform_ksampler(inputs: Dict) -> Dict:
|
||||
"""Transform function for KSampler nodes"""
|
||||
result = {
|
||||
"seed": str(inputs.get("seed", "")),
|
||||
"steps": str(inputs.get("steps", "")),
|
||||
"cfg": str(inputs.get("cfg", "")),
|
||||
"sampler": inputs.get("sampler_name", ""),
|
||||
"scheduler": inputs.get("scheduler", ""),
|
||||
}
|
||||
|
||||
# Process positive prompt
|
||||
if "positive" in inputs:
|
||||
result["prompt"] = inputs["positive"]
|
||||
|
||||
# Process negative prompt
|
||||
if "negative" in inputs:
|
||||
result["negative_prompt"] = inputs["negative"]
|
||||
|
||||
# Get dimensions from latent image
|
||||
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
|
||||
width = inputs["latent_image"].get("width", 0)
|
||||
height = inputs["latent_image"].get("height", 0)
|
||||
if width and height:
|
||||
result["size"] = f"{width}x{height}"
|
||||
|
||||
# Add clip_skip if present
|
||||
if "clip_skip" in inputs:
|
||||
result["clip_skip"] = str(inputs.get("clip_skip", ""))
|
||||
|
||||
# Add guidance if present
|
||||
if "guidance" in inputs:
|
||||
result["guidance"] = str(inputs.get("guidance", ""))
|
||||
|
||||
# Add model if present
|
||||
if "model" in inputs:
|
||||
result["checkpoint"] = inputs.get("model", {}).get("checkpoint", "")
|
||||
result["loras"] = inputs.get("model", {}).get("loras", "")
|
||||
result["clip_skip"] = str(inputs.get("model", {}).get("clip_skip", -1) * -1)
|
||||
|
||||
return result
|
||||
|
||||
def transform_empty_latent(inputs: Dict) -> Dict:
|
||||
"""Transform function for EmptyLatentImage nodes"""
|
||||
width = inputs.get("width", 0)
|
||||
height = inputs.get("height", 0)
|
||||
return {"width": width, "height": height, "size": f"{width}x{height}"}
|
||||
|
||||
def transform_clip_text(inputs: Dict) -> Any:
|
||||
"""Transform function for CLIPTextEncode nodes"""
|
||||
return inputs.get("text", "")
|
||||
|
||||
def transform_flux_guidance(inputs: Dict) -> Dict:
|
||||
"""Transform function for FluxGuidance nodes"""
|
||||
result = {}
|
||||
|
||||
if "guidance" in inputs:
|
||||
result["guidance"] = inputs["guidance"]
|
||||
|
||||
if "conditioning" in inputs:
|
||||
conditioning = inputs["conditioning"]
|
||||
if isinstance(conditioning, str):
|
||||
result["prompt"] = conditioning
|
||||
else:
|
||||
result["prompt"] = "Unknown prompt"
|
||||
|
||||
return result
|
||||
|
||||
def transform_unet_loader(inputs: Dict) -> Dict:
|
||||
"""Transform function for UNETLoader node"""
|
||||
unet_name = inputs.get("unet_name", "")
|
||||
return {"checkpoint": unet_name} if unet_name else {}
|
||||
|
||||
def transform_checkpoint_loader(inputs: Dict) -> Dict:
|
||||
"""Transform function for CheckpointLoaderSimple node"""
|
||||
ckpt_name = inputs.get("ckpt_name", "")
|
||||
return {"checkpoint": ckpt_name} if ckpt_name else {}
|
||||
|
||||
def transform_latent_upscale_by(inputs: Dict) -> Dict:
|
||||
"""Transform function for LatentUpscaleBy node"""
|
||||
result = {}
|
||||
|
||||
width = inputs["samples"].get("width", 0) * inputs["scale_by"]
|
||||
height = inputs["samples"].get("height", 0) * inputs["scale_by"]
|
||||
result["width"] = width
|
||||
result["height"] = height
|
||||
result["size"] = f"{width}x{height}"
|
||||
|
||||
return result
|
||||
|
||||
def transform_clip_set_last_layer(inputs: Dict) -> Dict:
|
||||
"""Transform function for CLIPSetLastLayer node"""
|
||||
result = {}
|
||||
|
||||
if "stop_at_clip_layer" in inputs:
|
||||
result["clip_skip"] = inputs["stop_at_clip_layer"]
|
||||
|
||||
return result
|
||||
|
||||
# =============================================================================
|
||||
# Node Mapper Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Define the mappers for ComfyUI core nodes not in main mapper
|
||||
NODE_MAPPERS_EXT = {
|
||||
# KSamplers
|
||||
"SamplerCustomAdvanced": {
|
||||
"inputs_to_track": ["noise", "guider", "sampler", "sigmas", "latent_image"],
|
||||
"transform_func": transform_sampler_custom_advanced
|
||||
},
|
||||
"KSampler": {
|
||||
"inputs_to_track": [
|
||||
"seed", "steps", "cfg", "sampler_name", "scheduler",
|
||||
"denoise", "positive", "negative", "latent_image",
|
||||
"model", "clip_skip"
|
||||
],
|
||||
"transform_func": transform_ksampler
|
||||
},
|
||||
# ComfyUI core nodes
|
||||
"EmptyLatentImage": {
|
||||
"inputs_to_track": ["width", "height", "batch_size"],
|
||||
"transform_func": transform_empty_latent
|
||||
},
|
||||
"EmptySD3LatentImage": {
|
||||
"inputs_to_track": ["width", "height", "batch_size"],
|
||||
"transform_func": transform_empty_latent
|
||||
},
|
||||
"CLIPTextEncode": {
|
||||
"inputs_to_track": ["text", "clip"],
|
||||
"transform_func": transform_clip_text
|
||||
},
|
||||
"FluxGuidance": {
|
||||
"inputs_to_track": ["guidance", "conditioning"],
|
||||
"transform_func": transform_flux_guidance
|
||||
},
|
||||
"RandomNoise": {
|
||||
"inputs_to_track": ["noise_seed"],
|
||||
"transform_func": transform_random_noise
|
||||
},
|
||||
"KSamplerSelect": {
|
||||
"inputs_to_track": ["sampler_name"],
|
||||
"transform_func": transform_ksampler_select
|
||||
},
|
||||
"BasicScheduler": {
|
||||
"inputs_to_track": ["scheduler", "steps", "denoise", "model"],
|
||||
"transform_func": transform_basic_scheduler
|
||||
},
|
||||
"BasicGuider": {
|
||||
"inputs_to_track": ["model", "conditioning"],
|
||||
"transform_func": transform_basic_guider
|
||||
},
|
||||
"ModelSamplingFlux": {
|
||||
"inputs_to_track": ["max_shift", "base_shift", "width", "height", "model"],
|
||||
"transform_func": transform_model_sampling_flux
|
||||
},
|
||||
"UNETLoader": {
|
||||
"inputs_to_track": ["unet_name"],
|
||||
"transform_func": transform_unet_loader
|
||||
},
|
||||
"CheckpointLoaderSimple": {
|
||||
"inputs_to_track": ["ckpt_name"],
|
||||
"transform_func": transform_checkpoint_loader
|
||||
},
|
||||
"LatentUpscale": {
|
||||
"inputs_to_track": ["width", "height"],
|
||||
"transform_func": transform_empty_latent
|
||||
},
|
||||
"LatentUpscaleBy": {
|
||||
"inputs_to_track": ["samples", "scale_by"],
|
||||
"transform_func": transform_latent_upscale_by
|
||||
},
|
||||
"CLIPSetLastLayer": {
|
||||
"inputs_to_track": ["clip", "stop_at_clip_layer"],
|
||||
"transform_func": transform_clip_set_last_layer
|
||||
}
|
||||
}
|
||||
74
py/workflow/ext/kjnodes.py
Normal file
74
py/workflow/ext/kjnodes.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
KJNodes mappers extension for ComfyUI workflow parsing
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Transform Functions
|
||||
# =============================================================================
|
||||
|
||||
def transform_join_strings(inputs: Dict) -> str:
|
||||
"""Transform function for JoinStrings nodes"""
|
||||
string1 = inputs.get("string1", "")
|
||||
string2 = inputs.get("string2", "")
|
||||
delimiter = inputs.get("delimiter", "")
|
||||
return f"{string1}{delimiter}{string2}"
|
||||
|
||||
def transform_string_constant(inputs: Dict) -> str:
|
||||
"""Transform function for StringConstant nodes"""
|
||||
return inputs.get("string", "")
|
||||
|
||||
def transform_empty_latent_presets(inputs: Dict) -> Dict:
|
||||
"""Transform function for EmptyLatentImagePresets nodes"""
|
||||
dimensions = inputs.get("dimensions", "")
|
||||
invert = inputs.get("invert", False)
|
||||
|
||||
# Extract width and height from dimensions string
|
||||
# Expected format: "width x height (ratio)" or similar
|
||||
width = 0
|
||||
height = 0
|
||||
|
||||
if dimensions:
|
||||
# Try to extract dimensions using regex
|
||||
match = re.search(r'(\d+)\s*x\s*(\d+)', dimensions)
|
||||
if match:
|
||||
width = int(match.group(1))
|
||||
height = int(match.group(2))
|
||||
|
||||
# If invert is True, swap width and height
|
||||
if invert and width and height:
|
||||
width, height = height, width
|
||||
|
||||
return {"width": width, "height": height, "size": f"{width}x{height}"}
|
||||
|
||||
def transform_int_constant(inputs: Dict) -> int:
|
||||
"""Transform function for INTConstant nodes"""
|
||||
return inputs.get("value", 0)
|
||||
|
||||
# =============================================================================
|
||||
# Node Mapper Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Define the mappers for KJNodes
|
||||
NODE_MAPPERS_EXT = {
|
||||
"JoinStrings": {
|
||||
"inputs_to_track": ["string1", "string2", "delimiter"],
|
||||
"transform_func": transform_join_strings
|
||||
},
|
||||
"StringConstantMultiline": {
|
||||
"inputs_to_track": ["string"],
|
||||
"transform_func": transform_string_constant
|
||||
},
|
||||
"EmptyLatentImagePresets": {
|
||||
"inputs_to_track": ["dimensions", "invert", "batch_size"],
|
||||
"transform_func": transform_empty_latent_presets
|
||||
},
|
||||
"INTConstant": {
|
||||
"inputs_to_track": ["value"],
|
||||
"transform_func": transform_int_constant
|
||||
}
|
||||
}
|
||||
37
py/workflow/main.py
Normal file
37
py/workflow/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Main entry point for the workflow parser module
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
# Add the parent directory to sys.path to enable imports
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..'))
|
||||
sys.path.insert(0, os.path.dirname(SCRIPT_DIR))
|
||||
|
||||
from .parser import parse_workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_comfyui_workflow(
|
||||
workflow_path: str,
|
||||
output_path: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Parse a ComfyUI workflow file and extract generation parameters
|
||||
|
||||
Args:
|
||||
workflow_path: Path to the workflow JSON file
|
||||
output_path: Optional path to save the output JSON
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted parameters
|
||||
"""
|
||||
return parse_workflow(workflow_path, output_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# If run directly, use the CLI
|
||||
from .cli import main
|
||||
main()
|
||||
282
py/workflow/mappers.py
Normal file
282
py/workflow/mappers.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Node mappers for ComfyUI workflow parsing
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import importlib.util
|
||||
import inspect
|
||||
from typing import Dict, List, Any, Optional, Union, Type, Callable, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global mapper registry
|
||||
_MAPPER_REGISTRY: Dict[str, Dict] = {}
|
||||
|
||||
# =============================================================================
|
||||
# Mapper Definition Functions
|
||||
# =============================================================================
|
||||
|
||||
def create_mapper(
|
||||
node_type: str,
|
||||
inputs_to_track: List[str],
|
||||
transform_func: Callable[[Dict], Any] = None
|
||||
) -> Dict:
|
||||
"""Create a mapper definition for a node type"""
|
||||
mapper = {
|
||||
"node_type": node_type,
|
||||
"inputs_to_track": inputs_to_track,
|
||||
"transform": transform_func or (lambda inputs: inputs)
|
||||
}
|
||||
return mapper
|
||||
|
||||
def register_mapper(mapper: Dict) -> None:
|
||||
"""Register a node mapper in the global registry"""
|
||||
_MAPPER_REGISTRY[mapper["node_type"]] = mapper
|
||||
logger.debug(f"Registered mapper for node type: {mapper['node_type']}")
|
||||
|
||||
def get_mapper(node_type: str) -> Optional[Dict]:
|
||||
"""Get a mapper for the specified node type"""
|
||||
return _MAPPER_REGISTRY.get(node_type)
|
||||
|
||||
def get_all_mappers() -> Dict[str, Dict]:
|
||||
"""Get all registered mappers"""
|
||||
return _MAPPER_REGISTRY.copy()
|
||||
|
||||
# =============================================================================
|
||||
# Node Processing Function
|
||||
# =============================================================================
|
||||
|
||||
def process_node(node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore
|
||||
"""Process a node using its mapper and extract relevant information"""
|
||||
node_type = node_data.get("class_type")
|
||||
mapper = get_mapper(node_type)
|
||||
|
||||
if not mapper:
|
||||
logger.warning(f"No mapper found for node type: {node_type}")
|
||||
return None
|
||||
|
||||
result = {}
|
||||
|
||||
# Extract inputs based on the mapper's tracked inputs
|
||||
for input_name in mapper["inputs_to_track"]:
|
||||
if input_name in node_data.get("inputs", {}):
|
||||
input_value = node_data["inputs"][input_name]
|
||||
|
||||
# Check if input is a reference to another node's output
|
||||
if isinstance(input_value, list) and len(input_value) == 2:
|
||||
try:
|
||||
# Format is [node_id, output_slot]
|
||||
ref_node_id, output_slot = input_value
|
||||
# Convert node_id to string if it's an integer
|
||||
if isinstance(ref_node_id, int):
|
||||
ref_node_id = str(ref_node_id)
|
||||
|
||||
# Recursively process the referenced node
|
||||
ref_value = parser.process_node(ref_node_id, workflow)
|
||||
|
||||
if ref_value is not None:
|
||||
result[input_name] = ref_value
|
||||
else:
|
||||
# If we couldn't get a value from the reference, store the raw value
|
||||
result[input_name] = input_value
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing reference in node {node_id}, input {input_name}: {e}")
|
||||
result[input_name] = input_value
|
||||
else:
|
||||
# Direct value
|
||||
result[input_name] = input_value
|
||||
|
||||
# Apply the transform function
|
||||
try:
|
||||
return mapper["transform"](result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in transform function for node {node_id} of type {node_type}: {e}")
|
||||
return result
|
||||
|
||||
# =============================================================================
|
||||
# Transform Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
def transform_lora_loader(inputs: Dict) -> Dict:
|
||||
"""Transform function for LoraLoader nodes"""
|
||||
loras_data = inputs.get("loras", [])
|
||||
lora_stack = inputs.get("lora_stack", {}).get("lora_stack", [])
|
||||
|
||||
lora_texts = []
|
||||
|
||||
# Process loras array
|
||||
if isinstance(loras_data, dict) and "__value__" in loras_data:
|
||||
loras_list = loras_data["__value__"]
|
||||
elif isinstance(loras_data, list):
|
||||
loras_list = loras_data
|
||||
else:
|
||||
loras_list = []
|
||||
|
||||
# Process each active lora entry
|
||||
for lora in loras_list:
|
||||
if isinstance(lora, dict) and lora.get("active", False):
|
||||
lora_name = lora.get("name", "")
|
||||
strength = lora.get("strength", 1.0)
|
||||
lora_texts.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
# Process lora_stack if valid
|
||||
if lora_stack and isinstance(lora_stack, list):
|
||||
if not (len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int)):
|
||||
for stack_entry in lora_stack:
|
||||
lora_name = stack_entry[0]
|
||||
strength = stack_entry[1]
|
||||
lora_texts.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
result = {
|
||||
"checkpoint": inputs.get("model", {}).get("checkpoint", ""),
|
||||
"loras": " ".join(lora_texts)
|
||||
}
|
||||
|
||||
if "clip" in inputs and isinstance(inputs["clip"], dict):
|
||||
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
|
||||
|
||||
return result
|
||||
|
||||
def transform_lora_stacker(inputs: Dict) -> Dict:
|
||||
"""Transform function for LoraStacker nodes"""
|
||||
loras_data = inputs.get("loras", [])
|
||||
result_stack = []
|
||||
|
||||
# Handle existing stack entries
|
||||
existing_stack = []
|
||||
lora_stack_input = inputs.get("lora_stack", [])
|
||||
|
||||
if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input:
|
||||
existing_stack = lora_stack_input["lora_stack"]
|
||||
elif isinstance(lora_stack_input, list):
|
||||
if not (len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and
|
||||
isinstance(lora_stack_input[1], int)):
|
||||
existing_stack = lora_stack_input
|
||||
|
||||
# Add existing entries
|
||||
if existing_stack:
|
||||
result_stack.extend(existing_stack)
|
||||
|
||||
# Process new loras
|
||||
if isinstance(loras_data, dict) and "__value__" in loras_data:
|
||||
loras_list = loras_data["__value__"]
|
||||
elif isinstance(loras_data, list):
|
||||
loras_list = loras_data
|
||||
else:
|
||||
loras_list = []
|
||||
|
||||
for lora in loras_list:
|
||||
if isinstance(lora, dict) and lora.get("active", False):
|
||||
lora_name = lora.get("name", "")
|
||||
strength = float(lora.get("strength", 1.0))
|
||||
result_stack.append((lora_name, strength))
|
||||
|
||||
return {"lora_stack": result_stack}
|
||||
|
||||
def transform_trigger_word_toggle(inputs: Dict) -> str:
|
||||
"""Transform function for TriggerWordToggle nodes"""
|
||||
toggle_data = inputs.get("toggle_trigger_words", [])
|
||||
|
||||
if isinstance(toggle_data, dict) and "__value__" in toggle_data:
|
||||
toggle_words = toggle_data["__value__"]
|
||||
elif isinstance(toggle_data, list):
|
||||
toggle_words = toggle_data
|
||||
else:
|
||||
toggle_words = []
|
||||
|
||||
# Filter active trigger words
|
||||
active_words = []
|
||||
for item in toggle_words:
|
||||
if isinstance(item, dict) and item.get("active", False):
|
||||
word = item.get("text", "")
|
||||
if word and not word.startswith("__dummy"):
|
||||
active_words.append(word)
|
||||
|
||||
return ", ".join(active_words)
|
||||
|
||||
# =============================================================================
|
||||
# Node Mapper Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Central definition of all supported node types and their configurations
|
||||
NODE_MAPPERS = {
|
||||
|
||||
# LoraManager nodes
|
||||
"Lora Loader (LoraManager)": {
|
||||
"inputs_to_track": ["model", "clip", "loras", "lora_stack"],
|
||||
"transform_func": transform_lora_loader
|
||||
},
|
||||
"Lora Stacker (LoraManager)": {
|
||||
"inputs_to_track": ["loras", "lora_stack"],
|
||||
"transform_func": transform_lora_stacker
|
||||
},
|
||||
"TriggerWord Toggle (LoraManager)": {
|
||||
"inputs_to_track": ["toggle_trigger_words"],
|
||||
"transform_func": transform_trigger_word_toggle
|
||||
}
|
||||
}
|
||||
|
||||
def register_all_mappers() -> None:
|
||||
"""Register all mappers from the NODE_MAPPERS dictionary"""
|
||||
for node_type, config in NODE_MAPPERS.items():
|
||||
mapper = create_mapper(
|
||||
node_type=node_type,
|
||||
inputs_to_track=config["inputs_to_track"],
|
||||
transform_func=config["transform_func"]
|
||||
)
|
||||
register_mapper(mapper)
|
||||
logger.info(f"Registered {len(NODE_MAPPERS)} node mappers")
|
||||
|
||||
# =============================================================================
|
||||
# Extension Loading
|
||||
# =============================================================================
|
||||
|
||||
def load_extensions(ext_dir: str = None) -> None:
|
||||
"""
|
||||
Load mapper extensions from the specified directory
|
||||
|
||||
Extension files should define a NODE_MAPPERS_EXT dictionary containing mapper configurations.
|
||||
These will be added to the global NODE_MAPPERS dictionary and registered automatically.
|
||||
"""
|
||||
# Use default path if none provided
|
||||
if ext_dir is None:
|
||||
# Get the directory of this file
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
ext_dir = os.path.join(current_dir, 'ext')
|
||||
|
||||
# Ensure the extension directory exists
|
||||
if not os.path.exists(ext_dir):
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
logger.info(f"Created extension directory: {ext_dir}")
|
||||
return
|
||||
|
||||
# Load each Python file in the extension directory
|
||||
for filename in os.listdir(ext_dir):
|
||||
if filename.endswith('.py') and not filename.startswith('_'):
|
||||
module_path = os.path.join(ext_dir, filename)
|
||||
module_name = f"workflow.ext.{filename[:-3]}" # Remove .py
|
||||
|
||||
try:
|
||||
# Load the module
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Check if the module defines NODE_MAPPERS_EXT
|
||||
if hasattr(module, 'NODE_MAPPERS_EXT'):
|
||||
# Add the extension mappers to the global NODE_MAPPERS dictionary
|
||||
NODE_MAPPERS.update(module.NODE_MAPPERS_EXT)
|
||||
logger.info(f"Added {len(module.NODE_MAPPERS_EXT)} mappers from extension: {filename}")
|
||||
else:
|
||||
logger.warning(f"Extension {filename} does not define NODE_MAPPERS_EXT dictionary")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading extension {filename}: {e}")
|
||||
|
||||
# Re-register all mappers after loading extensions
|
||||
register_all_mappers()
|
||||
|
||||
# Initialize the registry with default mappers
|
||||
# register_default_mappers()
|
||||
181
py/workflow/parser.py
Normal file
181
py/workflow/parser.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Main workflow parser implementation for ComfyUI
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Union, Set
|
||||
from .mappers import get_mapper, get_all_mappers, load_extensions, process_node
|
||||
from .utils import (
|
||||
load_workflow, save_output, find_node_by_type,
|
||||
trace_model_path
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WorkflowParser:
|
||||
"""Parser for ComfyUI workflows"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the parser with mappers"""
|
||||
self.processed_nodes: Set[str] = set() # Track processed nodes to avoid cycles
|
||||
self.node_results_cache: Dict[str, Any] = {} # Cache for processed node results
|
||||
|
||||
# Load extensions
|
||||
load_extensions()
|
||||
|
||||
def process_node(self, node_id: str, workflow: Dict) -> Any:
|
||||
"""Process a single node and extract relevant information"""
|
||||
# Return cached result if available
|
||||
if node_id in self.node_results_cache:
|
||||
return self.node_results_cache[node_id]
|
||||
|
||||
# Check if we're in a cycle
|
||||
if node_id in self.processed_nodes:
|
||||
return None
|
||||
|
||||
# Mark this node as being processed (to detect cycles)
|
||||
self.processed_nodes.add(node_id)
|
||||
|
||||
if node_id not in workflow:
|
||||
self.processed_nodes.remove(node_id)
|
||||
return None
|
||||
|
||||
node_data = workflow[node_id]
|
||||
node_type = node_data.get("class_type")
|
||||
|
||||
result = None
|
||||
if get_mapper(node_type):
|
||||
try:
|
||||
result = process_node(node_id, node_data, workflow, self)
|
||||
# Cache the result
|
||||
self.node_results_cache[node_id] = result
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing node {node_id} of type {node_type}: {e}", exc_info=True)
|
||||
# Return a partial result or None depending on how we want to handle errors
|
||||
result = {}
|
||||
|
||||
# Remove node from processed set to allow it to be processed again in a different context
|
||||
self.processed_nodes.remove(node_id)
|
||||
return result
|
||||
|
||||
def find_primary_sampler_node(self, workflow: Dict) -> Optional[str]:
|
||||
"""
|
||||
Find the primary sampler node in the workflow.
|
||||
|
||||
Priority:
|
||||
1. First try to find a SamplerCustomAdvanced node
|
||||
2. If not found, look for KSampler nodes with denoise=1.0
|
||||
3. If still not found, use the first KSampler node
|
||||
|
||||
Args:
|
||||
workflow: The workflow data as a dictionary
|
||||
|
||||
Returns:
|
||||
The node ID of the primary sampler node, or None if not found
|
||||
"""
|
||||
# First check for SamplerCustomAdvanced nodes
|
||||
sampler_advanced_nodes = []
|
||||
ksampler_nodes = []
|
||||
|
||||
# Scan workflow for sampler nodes
|
||||
for node_id, node_data in workflow.items():
|
||||
node_type = node_data.get("class_type")
|
||||
|
||||
if node_type == "SamplerCustomAdvanced":
|
||||
sampler_advanced_nodes.append(node_id)
|
||||
elif node_type == "KSampler":
|
||||
ksampler_nodes.append(node_id)
|
||||
|
||||
# If we found SamplerCustomAdvanced nodes, return the first one
|
||||
if sampler_advanced_nodes:
|
||||
logger.debug(f"Found SamplerCustomAdvanced node: {sampler_advanced_nodes[0]}")
|
||||
return sampler_advanced_nodes[0]
|
||||
|
||||
# If we have KSampler nodes, look for one with denoise=1.0
|
||||
if ksampler_nodes:
|
||||
for node_id in ksampler_nodes:
|
||||
node_data = workflow[node_id]
|
||||
inputs = node_data.get("inputs", {})
|
||||
denoise = inputs.get("denoise", 0)
|
||||
|
||||
# Check if denoise is 1.0 (allowing for small floating point differences)
|
||||
if abs(float(denoise) - 1.0) < 0.001:
|
||||
logger.debug(f"Found KSampler node with denoise=1.0: {node_id}")
|
||||
return node_id
|
||||
|
||||
# If no KSampler with denoise=1.0 found, use the first one
|
||||
logger.debug(f"No KSampler with denoise=1.0 found, using first KSampler: {ksampler_nodes[0]}")
|
||||
return ksampler_nodes[0]
|
||||
|
||||
# No sampler nodes found
|
||||
logger.warning("No sampler nodes found in workflow")
|
||||
return None
|
||||
|
||||
def parse_workflow(self, workflow_data: Union[str, Dict], output_path: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Parse the workflow and extract generation parameters
|
||||
|
||||
Args:
|
||||
workflow_data: The workflow data as a dictionary or a file path
|
||||
output_path: Optional path to save the output JSON
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted parameters
|
||||
"""
|
||||
# Load workflow from file if needed
|
||||
if isinstance(workflow_data, str):
|
||||
workflow = load_workflow(workflow_data)
|
||||
else:
|
||||
workflow = workflow_data
|
||||
|
||||
# Reset the processed nodes tracker and cache
|
||||
self.processed_nodes = set()
|
||||
self.node_results_cache = {}
|
||||
|
||||
# Find the primary sampler node
|
||||
sampler_node_id = self.find_primary_sampler_node(workflow)
|
||||
if not sampler_node_id:
|
||||
logger.warning("No suitable sampler node found in workflow")
|
||||
return {}
|
||||
|
||||
# Process sampler node to extract parameters
|
||||
sampler_result = self.process_node(sampler_node_id, workflow)
|
||||
if not sampler_result:
|
||||
return {}
|
||||
|
||||
# Return the sampler result directly - it's already in the format we need
|
||||
# This simplifies the structure and makes it easier to use in recipe_routes.py
|
||||
|
||||
# Handle standard ComfyUI names vs our output format
|
||||
if "cfg" in sampler_result:
|
||||
sampler_result["cfg_scale"] = sampler_result.pop("cfg")
|
||||
|
||||
# Add clip_skip = 1 to match reference output if not already present
|
||||
if "clip_skip" not in sampler_result:
|
||||
sampler_result["clip_skip"] = "1"
|
||||
|
||||
# Ensure the prompt is a string and not a nested dictionary
|
||||
if "prompt" in sampler_result and isinstance(sampler_result["prompt"], dict):
|
||||
if "prompt" in sampler_result["prompt"]:
|
||||
sampler_result["prompt"] = sampler_result["prompt"]["prompt"]
|
||||
|
||||
# Save the result if requested
|
||||
if output_path:
|
||||
save_output(sampler_result, output_path)
|
||||
|
||||
return sampler_result
|
||||
|
||||
|
||||
def parse_workflow(workflow_path: str, output_path: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Parse a ComfyUI workflow file and extract generation parameters
|
||||
|
||||
Args:
|
||||
workflow_path: Path to the workflow JSON file
|
||||
output_path: Optional path to save the output JSON
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted parameters
|
||||
"""
|
||||
parser = WorkflowParser()
|
||||
return parser.parse_workflow(workflow_path, output_path)
|
||||
63
py/workflow/test.py
Normal file
63
py/workflow/test.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Test script for the ComfyUI workflow parser
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from .parser import parse_workflow
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure paths
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..'))
|
||||
REFS_DIR = os.path.join(ROOT_DIR, 'refs')
|
||||
OUTPUT_DIR = os.path.join(ROOT_DIR, 'output')
|
||||
|
||||
def test_parse_flux_workflow():
|
||||
"""Test parsing the flux example workflow"""
|
||||
# Ensure output directory exists
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Define input and output paths
|
||||
input_path = os.path.join(REFS_DIR, 'flux_prompt.json')
|
||||
output_path = os.path.join(OUTPUT_DIR, 'parsed_flux_output.json')
|
||||
|
||||
# Parse workflow
|
||||
logger.info(f"Parsing workflow: {input_path}")
|
||||
result = parse_workflow(input_path, output_path)
|
||||
|
||||
# Print result summary
|
||||
logger.info(f"Output saved to: {output_path}")
|
||||
logger.info(f"Parsing completed. Result summary:")
|
||||
logger.info(f" LoRAs: {result.get('loras', '')}")
|
||||
|
||||
gen_params = result.get('gen_params', {})
|
||||
logger.info(f" Prompt: {gen_params.get('prompt', '')[:50]}...")
|
||||
logger.info(f" Steps: {gen_params.get('steps', '')}")
|
||||
logger.info(f" Sampler: {gen_params.get('sampler', '')}")
|
||||
logger.info(f" Size: {gen_params.get('size', '')}")
|
||||
|
||||
# Compare with reference output
|
||||
ref_output_path = os.path.join(REFS_DIR, 'flux_output.json')
|
||||
try:
|
||||
with open(ref_output_path, 'r') as f:
|
||||
ref_output = json.load(f)
|
||||
|
||||
# Simple validation
|
||||
loras_match = result.get('loras', '') == ref_output.get('loras', '')
|
||||
prompt_match = gen_params.get('prompt', '') == ref_output.get('gen_params', {}).get('prompt', '')
|
||||
|
||||
logger.info(f"Validation against reference:")
|
||||
logger.info(f" LoRAs match: {loras_match}")
|
||||
logger.info(f" Prompt match: {prompt_match}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compare with reference output: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_parse_flux_workflow()
|
||||
120
py/workflow/utils.py
Normal file
120
py/workflow/utils.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Utility functions for ComfyUI workflow parsing
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Union, Set, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def load_workflow(workflow_path: str) -> Dict:
|
||||
"""Load a workflow from a JSON file"""
|
||||
try:
|
||||
with open(workflow_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading workflow from {workflow_path}: {e}")
|
||||
raise
|
||||
|
||||
def save_output(output: Dict, output_path: str) -> None:
|
||||
"""Save the parsed output to a JSON file"""
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output, f, indent=4)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving output to {output_path}: {e}")
|
||||
raise
|
||||
|
||||
def find_node_by_type(workflow: Dict, node_type: str) -> Optional[str]:
|
||||
"""Find a node of the specified type in the workflow"""
|
||||
for node_id, node_data in workflow.items():
|
||||
if node_data.get("class_type") == node_type:
|
||||
return node_id
|
||||
return None
|
||||
|
||||
def find_nodes_by_type(workflow: Dict, node_type: str) -> List[str]:
|
||||
"""Find all nodes of the specified type in the workflow"""
|
||||
return [node_id for node_id, node_data in workflow.items()
|
||||
if node_data.get("class_type") == node_type]
|
||||
|
||||
def get_input_node_ids(workflow: Dict, node_id: str) -> Dict[str, Tuple[str, int]]:
|
||||
"""
|
||||
Get the node IDs for all inputs of the given node
|
||||
|
||||
Returns a dictionary mapping input names to (node_id, output_slot) tuples
|
||||
"""
|
||||
result = {}
|
||||
if node_id not in workflow:
|
||||
return result
|
||||
|
||||
node_data = workflow[node_id]
|
||||
for input_name, input_value in node_data.get("inputs", {}).items():
|
||||
# Check if this input is connected to another node
|
||||
if isinstance(input_value, list) and len(input_value) == 2:
|
||||
# Input is connected to another node's output
|
||||
# Format: [node_id, output_slot]
|
||||
ref_node_id, output_slot = input_value
|
||||
result[input_name] = (str(ref_node_id), output_slot)
|
||||
|
||||
return result
|
||||
|
||||
def trace_model_path(workflow: Dict, start_node_id: str) -> List[str]:
|
||||
"""
|
||||
Trace the model path backward from KSampler to find all LoRA nodes
|
||||
|
||||
Args:
|
||||
workflow: The workflow data
|
||||
start_node_id: The starting node ID (usually KSampler)
|
||||
|
||||
Returns:
|
||||
List of node IDs in the model path
|
||||
"""
|
||||
model_path_nodes = []
|
||||
|
||||
# Get the model input from the start node
|
||||
if start_node_id not in workflow:
|
||||
return model_path_nodes
|
||||
|
||||
# Track visited nodes to avoid cycles
|
||||
visited = set()
|
||||
|
||||
# Stack for depth-first search
|
||||
stack = []
|
||||
|
||||
# Get model input reference if available
|
||||
start_node = workflow[start_node_id]
|
||||
if "inputs" in start_node and "model" in start_node["inputs"] and isinstance(start_node["inputs"]["model"], list):
|
||||
model_ref = start_node["inputs"]["model"]
|
||||
stack.append(str(model_ref[0]))
|
||||
|
||||
# Perform depth-first search
|
||||
while stack:
|
||||
node_id = stack.pop()
|
||||
|
||||
# Skip if already visited
|
||||
if node_id in visited:
|
||||
continue
|
||||
|
||||
# Mark as visited
|
||||
visited.add(node_id)
|
||||
|
||||
# Skip if node doesn't exist
|
||||
if node_id not in workflow:
|
||||
continue
|
||||
|
||||
node = workflow[node_id]
|
||||
node_type = node.get("class_type", "")
|
||||
|
||||
# Add current node to result list if it's a LoRA node
|
||||
if "Lora" in node_type:
|
||||
model_path_nodes.append(node_id)
|
||||
|
||||
# Add all input nodes that have a "model" or "lora_stack" output to the stack
|
||||
if "inputs" in node:
|
||||
for input_name, input_value in node["inputs"].items():
|
||||
if input_name in ["model", "lora_stack"] and isinstance(input_value, list) and len(input_value) == 2:
|
||||
stack.append(str(input_value[0]))
|
||||
|
||||
return model_path_nodes
|
||||
@@ -1,13 +1,17 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||
version = "0.7.34"
|
||||
version = "0.8.4"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
"jinja2",
|
||||
"safetensors",
|
||||
"watchdog"
|
||||
"watchdog",
|
||||
"beautifulsoup4",
|
||||
"piexif",
|
||||
"Pillow",
|
||||
"requests"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
100
refs/civitai_api_model_by_versionId.json
Normal file
100
refs/civitai_api_model_by_versionId.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"id": 1387174,
|
||||
"modelId": 1231067,
|
||||
"name": "v1.0",
|
||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
||||
"updatedAt": "2025-02-08T11:29:04.526Z",
|
||||
"status": "Published",
|
||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
||||
"trainedWords": [
|
||||
"ppstorybook"
|
||||
],
|
||||
"trainingStatus": null,
|
||||
"trainingDetails": null,
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": null,
|
||||
"earlyAccessEndsAt": null,
|
||||
"earlyAccessConfig": null,
|
||||
"description": null,
|
||||
"uploadType": "Created",
|
||||
"usageControl": "Download",
|
||||
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
|
||||
"stats": {
|
||||
"downloadCount": 1436,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 316
|
||||
},
|
||||
"model": {
|
||||
"name": "Vivid Impressions Storybook Style",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"poi": false
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1289799,
|
||||
"sizeKB": 18829.1484375,
|
||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor",
|
||||
"size": null,
|
||||
"fp": null
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "F414C813",
|
||||
"AutoV2": "9753338AB6",
|
||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
||||
"CRC32": "A65AE7B3",
|
||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
||||
"AutoV3": "34A22376739D"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"type": "image",
|
||||
"metadata": {
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"size": 1361590,
|
||||
"width": 832,
|
||||
"height": 1216
|
||||
},
|
||||
"meta": {
|
||||
"Size": "832x1216",
|
||||
"seed": 1116375220995209,
|
||||
"Model": "flux_dev_fp8",
|
||||
"steps": 23,
|
||||
"hashes": {
|
||||
"model": ""
|
||||
},
|
||||
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
|
||||
"Version": "ComfyUI",
|
||||
"sampler": "DPM++ 2M",
|
||||
"cfgScale": 3.5,
|
||||
"clipSkip": 1,
|
||||
"resources": [],
|
||||
"Model hash": ""
|
||||
},
|
||||
"availability": "Public",
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
153
refs/civitai_comfy_metadata.json
Normal file
153
refs/civitai_comfy_metadata.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"resource-stack": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
|
||||
},
|
||||
"resource-stack-1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
|
||||
"strength_model": 1,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack", 0],
|
||||
"clip": ["resource-stack", 1]
|
||||
}
|
||||
},
|
||||
"resource-stack-2": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
|
||||
"strength_model": 1,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack-1", 0],
|
||||
"clip": ["resource-stack-1", 1]
|
||||
}
|
||||
},
|
||||
"resource-stack-3": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
|
||||
"strength_model": 1.55,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack-2", 0],
|
||||
"clip": ["resource-stack-2", 1]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
|
||||
"parser": "comfy",
|
||||
"text_g": "",
|
||||
"text_l": "",
|
||||
"ascore": 2.5,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"crop_w": 0,
|
||||
"crop_h": 0,
|
||||
"target_width": 0,
|
||||
"target_height": 0,
|
||||
"smZ_steps": 1,
|
||||
"mean_normalization": true,
|
||||
"multi_conditioning": true,
|
||||
"use_old_emphasis_implementation": false,
|
||||
"with_SDXL": false,
|
||||
"clip": ["resource-stack-3", 1]
|
||||
},
|
||||
"_meta": { "title": "Positive" }
|
||||
},
|
||||
"7": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "bad quality,worst quality,worst detail,sketch,censor",
|
||||
"parser": "comfy",
|
||||
"text_g": "",
|
||||
"text_l": "",
|
||||
"ascore": 2.5,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"crop_w": 0,
|
||||
"crop_h": 0,
|
||||
"target_width": 0,
|
||||
"target_height": 0,
|
||||
"smZ_steps": 1,
|
||||
"mean_normalization": true,
|
||||
"multi_conditioning": true,
|
||||
"use_old_emphasis_implementation": false,
|
||||
"with_SDXL": false,
|
||||
"clip": ["resource-stack-3", 1]
|
||||
},
|
||||
"_meta": { "title": "Negative" }
|
||||
},
|
||||
"20": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
|
||||
"_meta": { "title": "Load Upscale Model" }
|
||||
},
|
||||
"17": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
|
||||
"upload": "image"
|
||||
},
|
||||
"_meta": { "title": "Image Load" }
|
||||
},
|
||||
"19": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
|
||||
"_meta": { "title": "Upscale Image (using Model)" }
|
||||
},
|
||||
"23": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"crop": "disabled",
|
||||
"width": 1280,
|
||||
"height": 1856,
|
||||
"image": ["19", 0]
|
||||
},
|
||||
"_meta": { "title": "Upscale Image" }
|
||||
},
|
||||
"21": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
|
||||
"_meta": { "title": "VAE Encode" }
|
||||
},
|
||||
"11": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"seed": 2088370631,
|
||||
"steps": 47,
|
||||
"cfg": 6.5,
|
||||
"denoise": 0.3,
|
||||
"model": ["resource-stack-3", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["21", 0]
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"12": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"extra": {
|
||||
"airs": [
|
||||
"urn:air:other:upscaler:civitai:147759@164821",
|
||||
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
|
||||
"urn:air:sdxl:lora:civitai:1107767@1253442",
|
||||
"urn:air:sdxl:lora:civitai:1342708@1516344",
|
||||
"urn:air:sdxl:lora:civitai:122359@135867"
|
||||
]
|
||||
},
|
||||
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
|
||||
}
|
||||
|
||||
15
refs/flux_output.json
Normal file
15
refs/flux_output.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"loras": "<lora:pp-enchanted-whimsy:0.9> <lora:ral-frctlgmtry_flux:1> <lora:pp-storybook_rank2_bf16:0.8>",
|
||||
"gen_params": {
|
||||
"prompt": "in the style of ppWhimsy, ral-frctlgmtry, ppstorybook,Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
|
||||
"negative_prompt": "",
|
||||
"steps": "25",
|
||||
"sampler": "dpmpp_2m",
|
||||
"scheduler": "beta",
|
||||
"cfg": "1",
|
||||
"seed": "48",
|
||||
"guidance": 3.5,
|
||||
"size": "896x1152",
|
||||
"clip_skip": "2"
|
||||
}
|
||||
}
|
||||
314
refs/flux_prompt.json
Normal file
314
refs/flux_prompt.json
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": [
|
||||
"46",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"58",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Positive Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"31",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"39",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"27": {
|
||||
"inputs": {
|
||||
"width": 896,
|
||||
"height": 1152,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": {
|
||||
"title": "EmptySD3LatentImage"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"seed": 44,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "beta",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"58",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"35",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"33",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"27",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"58",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Negative Prompt)"
|
||||
}
|
||||
},
|
||||
"35": {
|
||||
"inputs": {
|
||||
"guidance": 3.5,
|
||||
"conditioning": [
|
||||
"6",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FluxGuidance",
|
||||
"_meta": {
|
||||
"title": "FluxGuidance"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
|
||||
"clip_name2": "clip_l.safetensors",
|
||||
"type": "flux",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "DualCLIPLoader",
|
||||
"_meta": {
|
||||
"title": "DualCLIPLoader"
|
||||
}
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"vae_name": "flux1\\ae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"46": {
|
||||
"inputs": {
|
||||
"string1": [
|
||||
"59",
|
||||
0
|
||||
],
|
||||
"string2": [
|
||||
"51",
|
||||
0
|
||||
],
|
||||
"delimiter": ","
|
||||
},
|
||||
"class_type": "JoinStrings",
|
||||
"_meta": {
|
||||
"title": "Join Strings"
|
||||
}
|
||||
},
|
||||
"50": {
|
||||
"inputs": {
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PreviewImage",
|
||||
"_meta": {
|
||||
"title": "Preview Image"
|
||||
}
|
||||
},
|
||||
"51": {
|
||||
"inputs": {
|
||||
"string": "Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
|
||||
"strip_newlines": true
|
||||
},
|
||||
"class_type": "StringConstantMultiline",
|
||||
"_meta": {
|
||||
"title": "positive"
|
||||
}
|
||||
},
|
||||
"58": {
|
||||
"inputs": {
|
||||
"text": "<lora:pp-enchanted-whimsy:0.9><lora:ral-frctlgmtry_flux:1><lora:pp-storybook_rank2_bf16:0.8>",
|
||||
"loras": [
|
||||
{
|
||||
"name": "pp-enchanted-whimsy",
|
||||
"strength": "0.90",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"name": "ral-frctlgmtry_flux",
|
||||
"strength": "0.85",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"name": "pp-storybook_rank2_bf16",
|
||||
"strength": 0.8,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item1__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item2__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"model": [
|
||||
"37",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"38",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "Lora Loader (LoraManager)"
|
||||
}
|
||||
},
|
||||
"59": {
|
||||
"inputs": {
|
||||
"group_mode": "",
|
||||
"toggle_trigger_words": [
|
||||
{
|
||||
"text": "ppstorybook",
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"text": "__dummy_item__",
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"text": "__dummy_item__",
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"orinalMessage": "ppstorybook",
|
||||
"trigger_words": [
|
||||
"58",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "TriggerWord Toggle (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "TriggerWord Toggle (LoraManager)"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"inputs": {
|
||||
"add_noise": "enable",
|
||||
"noise_seed": 1111423448930884,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"start_at_step": 0,
|
||||
"end_at_step": 10000,
|
||||
"return_with_leftover_noise": "disable"
|
||||
},
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"_meta": {
|
||||
"title": "KSampler (Advanced)"
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"inputs": {
|
||||
"sigmas": [
|
||||
"63",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"63": {
|
||||
"inputs": {
|
||||
"scheduler": "normal",
|
||||
"steps": 20,
|
||||
"denoise": 1
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"64": {
|
||||
"inputs": {
|
||||
"seed": 1089899258710474,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"65": {
|
||||
"inputs": {
|
||||
"text": ",Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
|
||||
"anything": [
|
||||
"46",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy showAnything",
|
||||
"_meta": {
|
||||
"title": "Show Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
refs/jpeg_civitai_exif_userComment_example
Normal file
18
refs/jpeg_civitai_exif_userComment_example
Normal file
@@ -0,0 +1,18 @@
|
||||
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
|
||||
Negative prompt:
|
||||
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
|
||||
|
||||
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
|
||||
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
|
||||
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
|
||||
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
|
||||
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
|
||||
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
|
||||
|
||||
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
|
||||
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
|
||||
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
|
||||
|
||||
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds – granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
|
||||
Negative prompt:
|
||||
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
|
||||
3
refs/meta_format.txt
Normal file
3
refs/meta_format.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
|
||||
Negative prompt:
|
||||
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
|
||||
11
refs/output.json
Normal file
11
refs/output.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"loras": "<lora:ck-neon-retrowave-IL-000012:0.8> <lora:aorunIllstrious:1> <lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
|
||||
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
|
||||
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
|
||||
"steps": "20",
|
||||
"sampler": "euler_ancestral",
|
||||
"cfg_scale": "8",
|
||||
"seed": "241",
|
||||
"size": "832x1216",
|
||||
"clip_skip": "2"
|
||||
}
|
||||
401
refs/prompt.json
Normal file
401
refs/prompt.json
Normal file
@@ -0,0 +1,401 @@
|
||||
{
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": [
|
||||
"301",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"299",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"13",
|
||||
1
|
||||
],
|
||||
"vae": [
|
||||
"10",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"vae_name": "flux1\\ae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"inputs": {
|
||||
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
|
||||
"clip_name2": "ViT-L-14-TEXT-detail-improved-hiT-GmP-TE-only-HF.safetensors",
|
||||
"type": "flux",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "DualCLIPLoader",
|
||||
"_meta": {
|
||||
"title": "DualCLIPLoader"
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"147",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"22",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"16",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"17",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"48",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"sampler_name": "dpmpp_2m"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"inputs": {
|
||||
"scheduler": "beta",
|
||||
"steps": [
|
||||
"246",
|
||||
0
|
||||
],
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"28",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"28",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"29",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"28": {
|
||||
"inputs": {
|
||||
"max_shift": 1.1500000000000001,
|
||||
"base_shift": 0.5,
|
||||
"width": [
|
||||
"48",
|
||||
1
|
||||
],
|
||||
"height": [
|
||||
"48",
|
||||
2
|
||||
],
|
||||
"model": [
|
||||
"299",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ModelSamplingFlux",
|
||||
"_meta": {
|
||||
"title": "ModelSamplingFlux"
|
||||
}
|
||||
},
|
||||
"29": {
|
||||
"inputs": {
|
||||
"guidance": 3.5,
|
||||
"conditioning": [
|
||||
"6",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FluxGuidance",
|
||||
"_meta": {
|
||||
"title": "FluxGuidance"
|
||||
}
|
||||
},
|
||||
"48": {
|
||||
"inputs": {
|
||||
"resolution": "832x1216 (0.68)",
|
||||
"batch_size": 1,
|
||||
"width_override": 0,
|
||||
"height_override": 0
|
||||
},
|
||||
"class_type": "SDXLEmptyLatentSizePicker+",
|
||||
"_meta": {
|
||||
"title": "🔧 SDXL Empty Latent Size Picker"
|
||||
}
|
||||
},
|
||||
"65": {
|
||||
"inputs": {
|
||||
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn_fast"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"147": {
|
||||
"inputs": {
|
||||
"noise_seed": 651532572596956
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"148": {
|
||||
"inputs": {
|
||||
"wildcard_text": "__some-prompts__",
|
||||
"populated_text": "A surreal digital artwork showcases a forward-thinking inventor captivated by his intricate mechanical creation through a large magnifying glass. Viewed from an unconventional perspective, the scene reveals an eccentric assembly of gears, springs, and brass instruments within his workshop. Soft, ethereal light radiates from the invention, casting enigmatic shadows on the walls as time appears to bend around its metallic form, invoking a sense of curiosity, wonder, and exhilaration in discovery.",
|
||||
"mode": "fixed",
|
||||
"seed": 553084268162351,
|
||||
"Select to add Wildcard": "Select the Wildcard to add to the text"
|
||||
},
|
||||
"class_type": "ImpactWildcardProcessor",
|
||||
"_meta": {
|
||||
"title": "ImpactWildcardProcessor"
|
||||
}
|
||||
},
|
||||
"151": {
|
||||
"inputs": {
|
||||
"text": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura."
|
||||
},
|
||||
"class_type": "Text Multiline",
|
||||
"_meta": {
|
||||
"title": "Text Multiline"
|
||||
}
|
||||
},
|
||||
"191": {
|
||||
"inputs": {
|
||||
"text": "A cinematic, oil painting masterpiece captures the essence of impressionistic surrealism, inspired by Claude Monet. A mysterious woman in a flowing crimson dress stands at the edge of a tranquil lake, where lily pads shimmer under an ethereal, golden twilight. The water’s surface reflects a dreamlike sky, its swirling hues of violet and sapphire melting together like liquid light. The thick, expressive brushstrokes lend depth to the scene, evoking a sense of nostalgia and quiet longing, as if the world itself is caught between reality and a fleeting dream. \nA mesmerizing oil painting masterpiece inspired by Salvador Dalí, blending surrealism with post-impressionist texture. A lone violinist plays atop a melting clock tower, his form distorted by the passage of time. The sky is a cascade of swirling, liquid oranges and deep blues, where floating staircases spiral endlessly into the horizon. The impasto technique gives depth and movement to the surreal elements, making time itself feel fluid, as if the world is dissolving into a dream. \nA stunning impressionistic oil painting evokes the spirit of Edvard Munch, capturing a solitary figure standing on a rain-soaked street, illuminated by the glow of flickering gas lamps. The swirling, chaotic strokes of deep blues and fiery reds reflect the turbulence of emotion, while the blurred reflections in the wet cobblestone suggest a merging of past and present. The faceless figure, draped in a dark overcoat, seems lost in thought, embodying the ephemeral nature of memory and time. \nA breathtaking oil painting masterpiece, inspired by Gustav Klimt, presents a celestial ballroom where faceless dancers swirl in an eternal waltz beneath a gilded, star-speckled sky. Their golden garments shimmer with intricate patterns, blending into the opulent mosaic floor that seems to stretch into infinity. The dreamlike composition, rich in warm amber and deep sapphire hues, captures an otherworldly elegance, as if the dancers are suspended in a moment that transcends time. \nA visionary oil painting inspired by Marc Chagall depicts a dreamlike cityscape where gravity ceases to exist. A couple floats above a crimson-tinted town, their forms dissolving into the swirling strokes of a vast, cerulean sky. The buildings below twist and bend in rhythmic motion, their windows glowing like tiny stars. The thick, textured brushwork conveys a sense of weightlessness and wonder, as if love itself has defied the laws of the universe. \nAn impressionistic oil painting in the style of J.M.W. Turner, depicting a ghostly ship sailing through a sea of swirling golden mist. The waves crash and dissolve into abstract, fiery strokes of orange and deep indigo, blurring the line between ocean and sky. The ship appears almost ethereal, as if drifting between worlds, lost in the ever-changing tides of memory and myth. The dynamic brushstrokes capture the relentless power of nature and the fleeting essence of time. \nA captivating oil painting masterpiece, infused with surrealist impressionism, portrays a grand library where books float midair, their pages unraveling into ribbons of light. The towering shelves twist into the heavens, vanishing into an infinite, starry void. A lone scholar, illuminated by the glow of a suspended lantern, reaches for a book that seems to pulse with life. The scene pulses with mystery, where the impasto textures bring depth to the interplay between knowledge and dreams. \nA luminous impressionistic oil painting captures the melancholic beauty of an abandoned carnival, its faded carousel horses frozen mid-gallop beneath a sky of swirling lavender and gold. The wind carries fragments of forgotten laughter through the empty fairground, where scattered ticket stubs and crumbling banners whisper tales of joy long past. The thick, textured brushstrokes blend nostalgia with an eerie dreamlike quality, as if the carnival exists only in the echoes of memory. \nA surreal oil painting in the spirit of René Magritte, featuring a towering lighthouse that emits not light, but cascading waterfalls from its peak. The swirling sky, painted in deep midnight blues, is punctuated by glowing, crescent moons that defy gravity. A lone figure stands at the water’s edge, gazing up in quiet contemplation, as if caught between wonder and the unknown. The painting’s rich textures and luminous colors create an enigmatic, dreamlike landscape. \nA striking impressionistic oil painting, reminiscent of Van Gogh, portrays a lone traveler on a winding cobblestone path, their silhouette bathed in the golden glow of lantern-lit cherry blossoms. The petals swirl through the night air like glowing embers, blending with the deep, rhythmic strokes of a star-filled indigo sky. The scene captures a feeling of wistful solitude, as if the traveler is walking not only through the city, but through the fleeting nature of time itself."
|
||||
},
|
||||
"class_type": "Text Multiline",
|
||||
"_meta": {
|
||||
"title": "Text Multiline"
|
||||
}
|
||||
},
|
||||
"203": {
|
||||
"inputs": {
|
||||
"string1": [
|
||||
"289",
|
||||
0
|
||||
],
|
||||
"string2": [
|
||||
"293",
|
||||
0
|
||||
],
|
||||
"delimiter": ", "
|
||||
},
|
||||
"class_type": "JoinStrings",
|
||||
"_meta": {
|
||||
"title": "Join Strings"
|
||||
}
|
||||
},
|
||||
"208": {
|
||||
"inputs": {
|
||||
"file_path": "",
|
||||
"dictionary_name": "[filename]",
|
||||
"label": "TextBatch",
|
||||
"mode": "automatic",
|
||||
"index": 0,
|
||||
"multiline_text": [
|
||||
"191",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Text Load Line From File",
|
||||
"_meta": {
|
||||
"title": "Text Load Line From File"
|
||||
}
|
||||
},
|
||||
"226": {
|
||||
"inputs": {
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PreviewImage",
|
||||
"_meta": {
|
||||
"title": "Preview Image"
|
||||
}
|
||||
},
|
||||
"246": {
|
||||
"inputs": {
|
||||
"value": 25
|
||||
},
|
||||
"class_type": "INTConstant",
|
||||
"_meta": {
|
||||
"title": "Steps"
|
||||
}
|
||||
},
|
||||
"289": {
|
||||
"inputs": {
|
||||
"group_mode": true,
|
||||
"toggle_trigger_words": [
|
||||
{
|
||||
"text": "bo-exposure",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "__dummy_item__",
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"text": "__dummy_item__",
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"orinalMessage": "bo-exposure",
|
||||
"trigger_words": [
|
||||
"299",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "TriggerWord Toggle (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "TriggerWord Toggle (LoraManager)"
|
||||
}
|
||||
},
|
||||
"293": {
|
||||
"inputs": {
|
||||
"input": 1,
|
||||
"text1": [
|
||||
"208",
|
||||
0
|
||||
],
|
||||
"text2": [
|
||||
"151",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy textSwitch",
|
||||
"_meta": {
|
||||
"title": "Text Switch"
|
||||
}
|
||||
},
|
||||
"297": {
|
||||
"inputs": {
|
||||
"text": ""
|
||||
},
|
||||
"class_type": "Lora Stacker (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "Lora Stacker (LoraManager)"
|
||||
}
|
||||
},
|
||||
"298": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"297",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy showAnything",
|
||||
"_meta": {
|
||||
"title": "Show Any"
|
||||
}
|
||||
},
|
||||
"299": {
|
||||
"inputs": {
|
||||
"text": "<lora:boFLUX Double Exposure Magic v2:0.8> <lora:FluxDFaeTasticDetails:0.65>",
|
||||
"loras": [
|
||||
{
|
||||
"name": "boFLUX Double Exposure Magic v2",
|
||||
"strength": 0.8,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "FluxDFaeTasticDetails",
|
||||
"strength": 0.65,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item1__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item2__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"model": [
|
||||
"65",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"11",
|
||||
0
|
||||
],
|
||||
"lora_stack": [
|
||||
"297",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "Lora Loader (LoraManager)"
|
||||
}
|
||||
},
|
||||
"301": {
|
||||
"inputs": {
|
||||
"string": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura.",
|
||||
"strip_newlines": true
|
||||
},
|
||||
"class_type": "StringConstantMultiline",
|
||||
"_meta": {
|
||||
"title": "String Constant Multiline"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
refs/recipe.json
Normal file
82
refs/recipe.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc",
|
||||
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg",
|
||||
"title": "a mysterious, steampunk-inspired character standing in a dramatic pose",
|
||||
"modified": 1741837612.3931093,
|
||||
"created_date": 1741492786.5581934,
|
||||
"base_model": "Flux.1 D",
|
||||
"loras": [
|
||||
{
|
||||
"file_name": "ChronoDivinitiesFlux_r1",
|
||||
"hash": "ddbc5abd00db46ad464f5e3ca85f8f7121bc14b594d6785f441d9b002fffe66a",
|
||||
"strength": 0.8,
|
||||
"modelVersionId": 1438879,
|
||||
"modelName": "Chrono Divinities - By HailoKnight",
|
||||
"modelVersionName": "Flux"
|
||||
},
|
||||
{
|
||||
"file_name": "flux.1_lora_flyway_ink-dynamic",
|
||||
"hash": "4b4f3b469a0d5d3a04a46886abfa33daa37a905db070ccfbd10b345c6fb00eff",
|
||||
"strength": 0.2,
|
||||
"modelVersionId": 914935,
|
||||
"modelName": "Ink-style",
|
||||
"modelVersionName": "ink-dynamic"
|
||||
},
|
||||
{
|
||||
"file_name": "ck-painterly-fantasy-000017",
|
||||
"hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420",
|
||||
"strength": 0.2,
|
||||
"modelVersionId": 1189379,
|
||||
"modelName": "Painterly Fantasy by ChronoKnight - [FLUX & IL]",
|
||||
"modelVersionName": "FLUX"
|
||||
},
|
||||
{
|
||||
"file_name": "RetroAnimeFluxV1",
|
||||
"hash": "8f43c31b6c3238ac44195c970d511d759c5893bddd00f59f42b8fe51e8e76fa0",
|
||||
"strength": 0.8,
|
||||
"modelVersionId": 806265,
|
||||
"modelName": "Retro Anime Flux - Style",
|
||||
"modelVersionName": "v1.0"
|
||||
},
|
||||
{
|
||||
"file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar",
|
||||
"hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e",
|
||||
"strength": 0.2,
|
||||
"modelVersionId": 757030,
|
||||
"modelName": "Mezzotint Artstyle for Flux - by Ethanar",
|
||||
"modelVersionName": "V1"
|
||||
},
|
||||
{
|
||||
"file_name": "FluxMythG0thicL1nes",
|
||||
"hash": "ecb03595de62bd6183a0dd2b38bea35669fd4d509f4bbae5aa0572cfb7ef4279",
|
||||
"strength": 0.4,
|
||||
"modelVersionId": 1202162,
|
||||
"modelName": "Velvet's Mythic Fantasy Styles | Flux + Pony + illustrious",
|
||||
"modelVersionName": "Flux Gothic Lines"
|
||||
},
|
||||
{
|
||||
"file_name": "Elden_Ring_-_Yoshitaka_Amano",
|
||||
"hash": "c660c4c55320be7206cb6a917c59d8da3953cc07169fe10bda833a54ec0024f9",
|
||||
"strength": 0.75,
|
||||
"modelVersionId": 746484,
|
||||
"modelName": "Elden Ring - Yoshitaka Amano",
|
||||
"modelVersionName": "V1"
|
||||
}
|
||||
],
|
||||
"gen_params": {
|
||||
"prompt": "a mysterious, steampunk-inspired character standing in a dramatic pose. The character is dressed in a long, intricately detailed dark coat with ornate patterns, a wide-brimmed hat, and leather boots. The face is partially obscured by the hat's shadow, adding to the enigmatic aura. The background showcases a large, antique clock with Roman numerals, surrounded by dynamic lightning and ethereal white birds, enhancing the fantastical atmosphere. The color palette is dominated by dark tones with striking contrasts of white and blue lightning, creating a sense of tension and energy. The overall composition is vertical, with the character centrally positioned, exuding a sense of power and mystery. hkchrono",
|
||||
"negative_prompt": "",
|
||||
"checkpoint": {
|
||||
"type": "checkpoint",
|
||||
"modelVersionId": 691639,
|
||||
"modelName": "FLUX",
|
||||
"modelVersionName": "Dev"
|
||||
},
|
||||
"steps": "30",
|
||||
"sampler": "Undefined",
|
||||
"cfg_scale": "3.5",
|
||||
"seed": "1472903449",
|
||||
"size": "832x1216",
|
||||
"clip_skip": "2"
|
||||
}
|
||||
}
|
||||
294
refs/test_output.txt
Normal file
294
refs/test_output.txt
Normal file
@@ -0,0 +1,294 @@
|
||||
Loading workflow from D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\refs\prompt.json
|
||||
Expected output from D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\refs\output.json
|
||||
|
||||
Expected output:
|
||||
{
|
||||
"loras": "<lora:ck-neon-retrowave-IL-000012:0.8> <lora:aorunIllstrious:1> <lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
|
||||
"gen_params": {
|
||||
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
|
||||
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
|
||||
"steps": "20",
|
||||
"sampler": "euler_ancestral",
|
||||
"cfg_scale": "8",
|
||||
"seed": "241",
|
||||
"size": "832x1216",
|
||||
"clip_skip": "2"
|
||||
}
|
||||
}
|
||||
|
||||
Sampler node:
|
||||
{
|
||||
"inputs": {
|
||||
"seed": 241,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "karras",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"56",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
}
|
||||
|
||||
Extracted parameters:
|
||||
seed: 241
|
||||
steps: 20
|
||||
cfg_scale: 8
|
||||
|
||||
Positive node (6):
|
||||
{
|
||||
"inputs": {
|
||||
"text": [
|
||||
"22",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"56",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
}
|
||||
|
||||
Text node (22):
|
||||
{
|
||||
"inputs": {
|
||||
"string1": [
|
||||
"55",
|
||||
0
|
||||
],
|
||||
"string2": [
|
||||
"21",
|
||||
0
|
||||
],
|
||||
"delimiter": ", "
|
||||
},
|
||||
"class_type": "JoinStrings",
|
||||
"_meta": {
|
||||
"title": "Join Strings"
|
||||
}
|
||||
}
|
||||
|
||||
String1 node (55):
|
||||
{
|
||||
"inputs": {
|
||||
"group_mode": true,
|
||||
"toggle_trigger_words": [
|
||||
{
|
||||
"text": "in the style of ck-rw",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "aorun, scales, makeup, bare shoulders, pointy ears",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "dress",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "claws",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "in the style of cksc",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "artist:moriimee",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "in the style of cknc",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"text": "__dummy_item__",
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"text": "__dummy_item__",
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"orinalMessage": "in the style of ck-rw,, aorun, scales, makeup, bare shoulders, pointy ears,, dress,, claws,, in the style of cksc,, artist:moriimee,, in the style of cknc",
|
||||
"trigger_words": [
|
||||
"56",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "TriggerWord Toggle (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "TriggerWord Toggle (LoraManager)"
|
||||
}
|
||||
}
|
||||
|
||||
String2 node (21):
|
||||
{
|
||||
"inputs": {
|
||||
"string": "masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
|
||||
"strip_newlines": false
|
||||
},
|
||||
"class_type": "StringConstantMultiline",
|
||||
"_meta": {
|
||||
"title": "positive"
|
||||
}
|
||||
}
|
||||
|
||||
Negative node (7):
|
||||
{
|
||||
"inputs": {
|
||||
"text": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
|
||||
"clip": [
|
||||
"56",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
}
|
||||
|
||||
LoRA nodes (3):
|
||||
|
||||
LoRA node 56:
|
||||
{
|
||||
"inputs": {
|
||||
"text": "<lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
|
||||
"loras": [
|
||||
{
|
||||
"name": "ck-shadow-circuit-IL-000012",
|
||||
"strength": 0.78,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "MoriiMee_Gothic_Niji_Style_Illustrious_r1",
|
||||
"strength": 0.45,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "ck-nc-cyberpunk-IL-000011",
|
||||
"strength": 0.4,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item1__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item2__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"model": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
],
|
||||
"lora_stack": [
|
||||
"57",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "Lora Loader (LoraManager)"
|
||||
}
|
||||
}
|
||||
|
||||
LoRA node 57:
|
||||
{
|
||||
"inputs": {
|
||||
"text": "<lora:aorunIllstrious:1>",
|
||||
"loras": [
|
||||
{
|
||||
"name": "aorunIllstrious",
|
||||
"strength": "0.90",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item1__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item2__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
],
|
||||
"lora_stack": [
|
||||
"59",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Lora Stacker (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "Lora Stacker (LoraManager)"
|
||||
}
|
||||
}
|
||||
|
||||
LoRA node 59:
|
||||
{
|
||||
"inputs": {
|
||||
"text": "<lora:ck-neon-retrowave-IL-000012:0.8>",
|
||||
"loras": [
|
||||
{
|
||||
"name": "ck-neon-retrowave-IL-000012",
|
||||
"strength": 0.8,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item1__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
},
|
||||
{
|
||||
"name": "__dummy_item2__",
|
||||
"strength": 0,
|
||||
"active": false,
|
||||
"_isDummy": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"class_type": "Lora Stacker (LoraManager)",
|
||||
"_meta": {
|
||||
"title": "Lora Stacker (LoraManager)"
|
||||
}
|
||||
}
|
||||
|
||||
Test completed.
|
||||
@@ -1,4 +1,8 @@
|
||||
aiohttp
|
||||
jinja2
|
||||
safetensors
|
||||
watchdog
|
||||
watchdog
|
||||
beautifulsoup4
|
||||
piexif
|
||||
Pillow
|
||||
requests
|
||||
@@ -1,6 +1,8 @@
|
||||
/* 强制显示滚动条,防止页面跳动 */
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden; /* Disable default scrolling */
|
||||
}
|
||||
|
||||
/* 针对Firefox */
|
||||
@@ -16,6 +18,7 @@ html {
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@@ -35,6 +38,7 @@ html {
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(95% 0.02 256);
|
||||
--lora-error: oklch(75% 0.32 29);
|
||||
--lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: calc(8px * 1);
|
||||
@@ -43,6 +47,7 @@ html {
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-base: 10;
|
||||
--z-header: 100;
|
||||
--z-modal: 1000;
|
||||
--z-overlay: 2000;
|
||||
|
||||
@@ -64,11 +69,14 @@ html {
|
||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(98% 0.02 256);
|
||||
--lora-warning: oklch(75% 0.25 80); /* Add warning color for dark theme too */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 0; /* Remove the padding-top */
|
||||
}
|
||||
|
||||
360
static/css/components/bulk.css
Normal file
360
static/css/components/bulk.css
Normal file
@@ -0,0 +1,360 @@
|
||||
/* 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: 300px;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Style for selected cards */
|
||||
.lora-card.selected {
|
||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lora-card.selected::after {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Update bulk operations button to match others when active */
|
||||
#bulkOperationsBtn.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bulk-operations-panel {
|
||||
width: calc(100% - 40px);
|
||||
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;
|
||||
bottom: 80px; /* Position above the bulk operations panel */
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
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 {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.thumbnails-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px; /* Space for scrollbar */
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.selected-thumbnail {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
min-width: 80px; /* Prevent shrinking */
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
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);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.strip-close-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Style the selectedCount to indicate it's clickable */
|
||||
.selectable-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.selectable-count:hover {
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* NSFW Level Selector */
|
||||
.nsfw-level-selector {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: var(--z-modal);
|
||||
width: 300px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nsfw-level-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nsfw-level-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-nsfw-selector {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.close-nsfw-selector:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.current-level {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nsfw-level-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nsfw-level-btn {
|
||||
flex: 1 0 calc(33% - 8px);
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nsfw-level-btn:hover {
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
.nsfw-level-btn.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: 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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
|
||||
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||
max-width: 1400px; /* Container width control */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -18,6 +20,9 @@
|
||||
aspect-ratio: 896/1152;
|
||||
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
||||
margin: 0 auto;
|
||||
cursor: pointer; /* Added from recipe-card */
|
||||
display: flex; /* Added from recipe-card */
|
||||
flex-direction: column; /* Added from recipe-card */
|
||||
}
|
||||
|
||||
.lora-card:hover {
|
||||
@@ -55,6 +60,97 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top; /* Align the top of the image with the top of the container */
|
||||
}
|
||||
|
||||
/* NSFW Content Blur */
|
||||
.card-preview.blurred img,
|
||||
.card-preview.blurred video {
|
||||
filter: blur(25px);
|
||||
}
|
||||
|
||||
.nsfw-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nsfw-warning {
|
||||
text-align: center;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--border-radius-base);
|
||||
backdrop-filter: blur(4px);
|
||||
max-width: 80%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nsfw-warning p {
|
||||
margin: 0 0 var(--space-1);
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toggle-blur-btn {
|
||||
position: absolute;
|
||||
left: var(--space-1);
|
||||
top: var(--space-1);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-blur-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.toggle-blur-btn i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.show-content-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px var(--space-1);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.show-content-btn:hover {
|
||||
background: oklch(58% 0.28 256);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Adjust base model label positioning when toggle button is present */
|
||||
.base-model-label.with-toggle {
|
||||
margin-left: 28px; /* Make room for the toggle button */
|
||||
}
|
||||
|
||||
/* Ensure card actions remain clickable */
|
||||
.card-header .card-actions {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@@ -181,4 +277,55 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
backdrop-filter: blur(2px);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Recipe specific elements - migrated from recipe-card.css */
|
||||
.recipe-indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--lora-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.base-model-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 32px; /* For accommodating the recipe indicator */
|
||||
}
|
||||
|
||||
.lora-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lora-count.ready {
|
||||
background: rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.lora-count.missing {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--lora-surface-alt);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
205
static/css/components/download-modal.css
Normal file
205
static/css/components/download-modal.css
Normal file
@@ -0,0 +1,205 @@
|
||||
/* Download Modal Styles */
|
||||
.download-step {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Version List Styles */
|
||||
.version-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-color);
|
||||
margin: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.version-item.selected {
|
||||
border: 2px solid var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.05);
|
||||
}
|
||||
|
||||
.version-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.version-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.version-content h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row !important;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.version-info .base-model {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Folder Browser Styles */
|
||||
.folder-browser {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border: 1px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Path Preview Styles */
|
||||
.path-preview {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.path-preview label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
padding: var(--space-1);
|
||||
color: var(--text-color);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
opacity: 0.85;
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .version-item {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .local-path {
|
||||
background: var(--lora-surface);
|
||||
border-color: var(--lora-border);
|
||||
}
|
||||
|
||||
/* Add disabled button styles */
|
||||
.primary-btn.disabled {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Enhance the local badge to make it more noticeable */
|
||||
.version-item.exists-locally {
|
||||
background: oklch(var(--lora-accent) / 0.05);
|
||||
border-left: 4px solid var(--lora-accent);
|
||||
}
|
||||
177
static/css/components/header.css
Normal file
177
static/css/components/header.css
Normal file
@@ -0,0 +1,177 @@
|
||||
.app-header {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
height: 48px; /* Reduced height */
|
||||
width: 100%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Logo and title styling */
|
||||
.header-branding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Navigation styling */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Header search */
|
||||
.header-search {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
/* Header controls (formerly corner controls) */
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-controls > div {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-controls > div:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: relative; /* Ensure relative positioning for the container */
|
||||
}
|
||||
|
||||
.theme-toggle .light-icon,
|
||||
.theme-toggle .dark-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%); /* Center perfectly */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle .light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle .dark-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.app-title {
|
||||
display: none; /* Hide text title on mobile */
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-controls > div {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
max-width: none;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* For very small screens */
|
||||
@media (max-width: 600px) {
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: none; /* Hide navigation on very small screens */
|
||||
}
|
||||
|
||||
.header-search {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
735
static/css/components/import-modal.css
Normal file
735
static/css/components/import-modal.css
Normal file
@@ -0,0 +1,735 @@
|
||||
/* Import Modal Styles */
|
||||
.import-step {
|
||||
margin: var(--space-2) 0;
|
||||
transition: none !important; /* Disable any transitions that might affect display */
|
||||
}
|
||||
|
||||
/* Import Mode Toggle */
|
||||
.import-mode-toggle {
|
||||
display: flex;
|
||||
margin-bottom: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:first-child {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
}
|
||||
|
||||
.toggle-btn:hover:not(.active) {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.import-section {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* File Input Styles */
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.file-input-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-input-button:hover {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
}
|
||||
|
||||
.file-input-wrapper:hover .file-input-button {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
}
|
||||
|
||||
/* Recipe Details Layout */
|
||||
.recipe-details-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.recipe-image-container {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recipe-image img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.recipe-form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Tags Input Styles */
|
||||
.tag-input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.tag-input-container input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: var(--space-1);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.recipe-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.recipe-tag i {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.recipe-tag i:hover {
|
||||
opacity: 1;
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.empty-tags {
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* LoRAs List Styles */
|
||||
.loras-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.lora-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-color);
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.lora-item.exists-locally {
|
||||
background: oklch(var(--lora-accent) / 0.05);
|
||||
border-left: 4px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
.lora-item.missing-locally {
|
||||
border-left: 4px solid var(--lora-error);
|
||||
}
|
||||
|
||||
.lora-item.is-deleted {
|
||||
background: oklch(var(--lora-warning) / 0.05);
|
||||
border-left: 4px solid var(--lora-warning);
|
||||
}
|
||||
|
||||
.lora-item.is-early-access {
|
||||
background: rgba(0, 184, 122, 0.05);
|
||||
border-left: 4px solid #00B87A;
|
||||
}
|
||||
|
||||
.lora-item.missing-locally {
|
||||
border-left: 4px solid var(--lora-error);
|
||||
}
|
||||
|
||||
.lora-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.lora-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.lora-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lora-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lora-content h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lora-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.lora-info .base-model {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.lora-version {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.weight-badge {
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Missing LoRAs List */
|
||||
.missing-loras-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.missing-lora-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.missing-lora-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.missing-lora-item.is-early-access {
|
||||
background: rgba(0, 184, 122, 0.05);
|
||||
border-left: 3px solid #00B87A;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.missing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.missing-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.lora-count-info {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
font-weight: normal;
|
||||
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 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input-with-button button {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 8px 16px;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.input-with-button button:hover {
|
||||
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 */
|
||||
[data-theme="dark"] .lora-item {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recipe-tag {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipe-details-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.recipe-image-container {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Size badge for LoRA items */
|
||||
.size-badge {
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Improved Missing LoRAs summary section */
|
||||
.missing-loras-summary {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.summary-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.lora-count-badge {
|
||||
font-size: 0.9em;
|
||||
font-weight: normal;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.total-size-badge {
|
||||
font-size: 0.85em;
|
||||
font-weight: normal;
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.toggle-list-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.toggle-list-btn:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.missing-loras-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
transition: max-height 0.3s ease, margin-top 0.3s ease, padding-top 0.3s ease;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.missing-loras-list.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.missing-loras-list:not(.collapsed) {
|
||||
margin-top: var(--space-1);
|
||||
padding-top: var(--space-1);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.missing-lora-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.missing-lora-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.missing-lora-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.missing-lora-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lora-base-model {
|
||||
font-size: 0.85em;
|
||||
color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.missing-lora-size {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Recipe name input select-all behavior */
|
||||
#recipeName:focus {
|
||||
outline: 2px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Prevent layout shift with scrollbar */
|
||||
.modal-content {
|
||||
overflow-y: scroll; /* Always show scrollbar */
|
||||
scrollbar-gutter: stable; /* Reserve space for scrollbar */
|
||||
}
|
||||
|
||||
/* For browsers that don't support scrollbar-gutter */
|
||||
@supports not (scrollbar-gutter: stable) {
|
||||
.modal-content {
|
||||
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */
|
||||
}
|
||||
}
|
||||
|
||||
/* Deleted LoRA styles - Fix layout issues */
|
||||
.lora-item.is-deleted {
|
||||
background: oklch(var(--lora-warning) / 0.05);
|
||||
border-left: 4px solid var(--lora-warning);
|
||||
}
|
||||
|
||||
.deleted-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deleted-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.exclude-lora-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Deleted LoRAs warning - redesigned to not interfere with modal buttons */
|
||||
.deleted-loras-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: oklch(var(--lora-warning) / 0.1);
|
||||
border: 1px solid var(--lora-warning);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--lora-warning);
|
||||
font-size: 1.2em;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Remove the old warning-message styles that were causing layout issues */
|
||||
.warning-message {
|
||||
display: none; /* Hide the old style */
|
||||
}
|
||||
|
||||
/* Update deleted badge to be more prominent */
|
||||
.deleted-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deleted-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Error message styling */
|
||||
.error-message {
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
margin-top: 8px;
|
||||
min-height: 20px; /* Ensure there's always space for the error message */
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.early-access-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 184, 122, 0.1);
|
||||
border: 1px solid #00B87A;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Add special styling for early access badge in the missing loras list */
|
||||
.missing-lora-item .early-access-badge {
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75em;
|
||||
margin-top: 4px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Specific styling for the early access warning container in import modal */
|
||||
.early-access-warning .warning-icon {
|
||||
color: #00B87A;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.early-access-warning .warning-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.early-access-warning .warning-text {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -56,6 +56,53 @@
|
||||
transition: width 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Enhanced progress display */
|
||||
.progress-details-container {
|
||||
margin-top: var(--space-3);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.overall-progress-label {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--space-1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.current-item-progress {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.current-item-label {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--space-1);
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-item-bar-container {
|
||||
height: 8px;
|
||||
background-color: var(--lora-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.current-item-bar {
|
||||
height: 100%;
|
||||
background-color: var(--lora-accent);
|
||||
transition: width 200ms ease-out;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.current-item-percent {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary, var(--text-color));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
@@ -63,7 +110,8 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lora-card,
|
||||
.progress-bar {
|
||||
.progress-bar,
|
||||
.current-item-bar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
1283
static/css/components/lora-modal.css
Normal file
1283
static/css/components/lora-modal.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,13 @@
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: 48px; /* Start below the header */
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
height: calc(100% - 48px); /* Adjust height to exclude header */
|
||||
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
|
||||
z-index: var(--z-modal);
|
||||
overflow: hidden; /* 改为 hidden,防止双滚动条 */
|
||||
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||
}
|
||||
|
||||
/* 当模态窗口打开时,禁止body滚动 */
|
||||
@@ -23,13 +23,16 @@ body.modal-open {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
height: auto;
|
||||
max-height: 90vh;
|
||||
margin: 2rem auto;
|
||||
max-height: calc(90vh - 48px); /* Adjust to account for header height */
|
||||
margin: 1rem auto; /* Keep reduced top margin */
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--lora-border);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.05);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* 防止水平滚动条 */
|
||||
}
|
||||
@@ -154,71 +157,6 @@ body.modal-open {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
/* Download Modal Styles */
|
||||
.download-step {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.input-group input,
|
||||
.input-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-color);
|
||||
margin: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.version-item.selected {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
@@ -236,108 +174,6 @@ body.modal-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 1px; /* Add padding to prevent border clipping */
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-color); /* Add background color */
|
||||
margin: 1px; /* Add margin to ensure hover effect visibility */
|
||||
position: relative; /* Add position context */
|
||||
}
|
||||
|
||||
.version-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* Replace transform with shadow */
|
||||
z-index: 1; /* Ensure hover state appears above other items */
|
||||
}
|
||||
|
||||
.version-item.selected {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
}
|
||||
|
||||
.version-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.version-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.version-content h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.version-info .base-model {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folder-browser {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Settings styles */
|
||||
.settings-toggle {
|
||||
width: 36px;
|
||||
@@ -462,514 +298,260 @@ body.modal-open {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border: 1px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Path Preview Styles */
|
||||
/* 统一各个 section 的样式 */
|
||||
.support-section,
|
||||
.changelog-section,
|
||||
.update-info,
|
||||
.info-item,
|
||||
.path-preview {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
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);
|
||||
}
|
||||
|
||||
.path-text {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.path-preview {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
/* 深色主题统一样式 */
|
||||
[data-theme="dark"] .modal-content {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.path-preview label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
[data-theme="dark"] .support-section,
|
||||
[data-theme="dark"] .changelog-section,
|
||||
[data-theme="dark"] .update-info,
|
||||
[data-theme="dark"] .info-item,
|
||||
[data-theme="dark"] .path-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.path-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
/* Settings Styles */
|
||||
.settings-section {
|
||||
margin-top: var(--space-3);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.path-display i {
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
.settings-section h3 {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Lora Modal Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.info-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info-item.usage-tips,
|
||||
.info-item.notes {
|
||||
grid-column: 1 / -1 !important; /* Make notes section full width */
|
||||
}
|
||||
|
||||
/* Add specific styles for notes content */
|
||||
.info-item.notes .editable-field [contenteditable] {
|
||||
min-height: 60px; /* Increase height for multiple lines */
|
||||
max-height: 150px; /* Limit maximum height */
|
||||
overflow-y: auto; /* Add scrolling for long content */
|
||||
white-space: pre-wrap; /* Preserve line breaks */
|
||||
line-height: 1.5; /* Improve readability */
|
||||
padding: 8px 12px; /* Slightly increase padding */
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
line-height: 1.5;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Showcase Section */
|
||||
.showcase-section {
|
||||
position: relative;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel.collapsed {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
.setting-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.media-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--lora-surface);
|
||||
margin-bottom: var(--space-2);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.media-wrapper:last-child {
|
||||
margin-bottom: 0;
|
||||
.setting-item:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.media-wrapper img,
|
||||
.media-wrapper video {
|
||||
[data-theme="dark"] .setting-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
margin-bottom: var(--space-1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-info label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* Select Control Styles */
|
||||
.select-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-control select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Fix dark theme select dropdown text color */
|
||||
[data-theme="dark"] .select-control select {
|
||||
background-color: rgba(30, 30, 30, 0.9);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .select-control select option {
|
||||
background-color: #2d2d2d;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.select-control select:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: .3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
/* Scroll Indicator */
|
||||
.scroll-indicator {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.scroll-indicator:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scroll-indicator span {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.lazy {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.lazy[src] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Update Trigger Words styles */
|
||||
.info-item.trigger-words {
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.trigger-words-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Update Trigger Words styles */
|
||||
.trigger-word-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Update trigger word content color to use theme accent */
|
||||
.trigger-word-content {
|
||||
color: var(--lora-accent) !important; /* Override general span color */
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Keep the hover effect using accent color */
|
||||
.trigger-word-tag:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.trigger-word-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
/* Editable Fields */
|
||||
.editable-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.editable-field [contenteditable] {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
color: var(--text-color);
|
||||
transition: border-color 0.2s;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.editable-field [contenteditable]:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.editable-field [contenteditable]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 4px 8px;
|
||||
background: var(--lora-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.save-btn i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.info-item.usage-tips,
|
||||
.info-item.notes {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
|
||||
.modal-content .back-to-top {
|
||||
position: sticky; /* 改用 sticky 定位 */
|
||||
float: right; /* 使用 float 确保按钮在右侧 */
|
||||
bottom: 20px; /* 距离底部的距离 */
|
||||
margin-right: 20px; /* 右侧间距 */
|
||||
margin-top: -56px; /* 负边距确保不占用额外空间 */
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
margin-left: 60px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* Add small animation for the toggle */
|
||||
.toggle-slider:active:before {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
/* Update input help styles */
|
||||
.input-help {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-content .back-to-top.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
/* Blur effect for NSFW content */
|
||||
.nsfw-blur {
|
||||
filter: blur(12px);
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content .back-to-top:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
.nsfw-blur:hover {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Update Preset Controls styles */
|
||||
.preset-controls {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
/* Add styles for delete preview image */
|
||||
.delete-preview {
|
||||
max-width: 150px;
|
||||
margin: 0 auto var(--space-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preset-controls select,
|
||||
.preset-controls input {
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
.delete-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 150px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.preset-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
.delete-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preset-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: calc(var(--space-1) * 0.5) var(--space-1);
|
||||
gap: var(--space-1);
|
||||
transition: all 0.2s ease;
|
||||
.delete-info h3 {
|
||||
margin-bottom: var(--space-1);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preset-tag span {
|
||||
color: var(--lora-accent);
|
||||
.delete-info p {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.preset-tag i {
|
||||
.delete-note {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
margin-top: var(--space-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preset-tag:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
/* Add styles for markdown elements in changelog */
|
||||
.changelog-item ul {
|
||||
padding-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.preset-tag i:hover {
|
||||
color: var(--lora-error);
|
||||
opacity: 1;
|
||||
.changelog-item li {
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.add-preset-btn {
|
||||
padding: calc(var(--space-1) * 0.5) var(--space-2);
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
.changelog-item strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-preset-btn:hover {
|
||||
opacity: 0.9;
|
||||
.changelog-item em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* File name copy styles */
|
||||
.file-name-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-name-wrapper:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
}
|
||||
|
||||
.file-name-wrapper i {
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.file-name-wrapper:hover i {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Location and Size combined styles */
|
||||
.info-item.location-size {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Base Model and Size combined styles */
|
||||
.info-item.base-size {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.base-wrapper {
|
||||
flex: 2; /* 分配更多空间给base model */
|
||||
}
|
||||
|
||||
.size-wrapper {
|
||||
flex: 1;
|
||||
border-left: 1px solid var(--lora-border);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.base-wrapper label,
|
||||
.size-wrapper label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.size-wrapper span {
|
||||
.changelog-item code {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .changelog-item code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.changelog-item a {
|
||||
color: var(--lora-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.changelog-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
184
static/css/components/recipe-card.css
Normal file
184
static/css/components/recipe-card.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.recipe-tag-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recipe-tag {
|
||||
background: var(--lora-surface-hover);
|
||||
color: var(--lora-text-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-tag:hover, .recipe-tag.active {
|
||||
background: var(--lora-primary);
|
||||
color: var(--lora-text-on-primary);
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
position: relative;
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
aspect-ratio: 896/1152;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.recipe-card:focus-visible {
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.recipe-indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--lora-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--lora-surface-alt);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
|
||||
backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.base-model-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-actions i {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.card-actions i:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
|
||||
backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
min-height: 32px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.lora-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lora-count.ready {
|
||||
background: rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.lora-count.missing {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1400px) {
|
||||
.recipe-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recipe-grid {
|
||||
grid-template-columns: minmax(260px, 1fr);
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
832
static/css/components/recipe-modal.css
Normal file
832
static/css/components/recipe-modal.css
Normal file
@@ -0,0 +1,832 @@
|
||||
.recipe-modal-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
font-size: 1.4em; /* Reduced from default h2 size */
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
max-height: 2.6em; /* Limit to 2 lines */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
/* Editable content styles */
|
||||
.editable-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.editable-content.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editable-content .content-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
margin-left: 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editable-content:hover .edit-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.edit-icon:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
/* Content editor styles */
|
||||
.content-editor {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.content-editor.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content-editor input {
|
||||
flex: 1;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 6px 8px;
|
||||
font-size: 1em;
|
||||
color: var(--text-color);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-editor.tags-editor input {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* 删除不再需要的按钮样式 */
|
||||
.editor-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Special styling for tags content */
|
||||
.tags-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tags-display {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-tags {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recipe Tags styles */
|
||||
.recipe-tags-container {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recipe-tags-compact {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipe-tag-compact {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .recipe-tag-compact {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.recipe-tag-more {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recipe-tags-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 10px 14px;
|
||||
max-width: 400px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recipe-tags-tooltip.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tooltip-tag {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tooltip-tag {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
/* Top Section: Preview and Gen Params */
|
||||
.recipe-top-section {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Recipe Preview */
|
||||
.recipe-preview-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.recipe-preview-media {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Generation Parameters */
|
||||
.recipe-gen-params {
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color);
|
||||
padding-bottom: var(--space-1);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gen-params-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.param-header label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.param-content {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-2);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Other Parameters */
|
||||
.other-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.param-tag {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 8px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.param-tag .param-name {
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Bottom Section: Resources */
|
||||
.recipe-bottom-section {
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
padding-bottom: var(--space-1);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recipe-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.85em;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-status.ready {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-status.missing {
|
||||
background: oklch(var(--lora-error) / 0.1);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.recipe-status i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.recipe-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
#recipeLorasCount {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#recipeLorasCount i {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* LoRAs List */
|
||||
.recipe-loras-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-lora-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: 10px var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-color);
|
||||
/* Add will-change to create a new stacking context and force hardware acceleration */
|
||||
will-change: transform;
|
||||
/* Create a new containing block for absolutely positioned descendants */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recipe-lora-item.exists-locally {
|
||||
background: oklch(var(--lora-accent) / 0.05);
|
||||
border-left: 4px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-lora-item.missing-locally {
|
||||
border-left: 4px solid var(--lora-error);
|
||||
}
|
||||
|
||||
.recipe-lora-item.is-deleted {
|
||||
background: rgba(127, 127, 127, 0.05);
|
||||
border-left: 4px solid #777;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.recipe-lora-thumbnail {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recipe-lora-thumbnail img,
|
||||
.recipe-lora-thumbnail video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recipe-lora-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipe-lora-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
position: relative;
|
||||
min-height: 28px;
|
||||
/* Ensure badges don't move during scroll in Chrome */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recipe-lora-content h4 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
max-width: calc(100% - 120px); /* Make room for the badge */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* Limit to 2 lines */
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.recipe-lora-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 0.85em;
|
||||
margin-top: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.recipe-lora-info .base-model {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.recipe-lora-version {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recipe-lora-weight {
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.local-badge,
|
||||
.missing-badge {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
/* Force hardware acceleration for Chrome */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Specific styles for recipe modal badges - update z-index */
|
||||
.recipe-lora-header .local-badge,
|
||||
.recipe-lora-header .missing-badge {
|
||||
z-index: 2; /* Ensure the badge is above other elements */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Ensure local-path tooltip is properly positioned and won't move during scroll */
|
||||
.recipe-lora-header .local-badge .local-path {
|
||||
z-index: 3;
|
||||
top: calc(100% + 4px); /* Position tooltip below the badge */
|
||||
right: -4px; /* Align with the badge */
|
||||
max-width: 250px;
|
||||
/* Force hardware acceleration for Chrome */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.missing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.missing-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Deleted badge with reconnect functionality */
|
||||
.deleted-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #777;
|
||||
color: white;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deleted-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Add reconnect functionality styles */
|
||||
.deleted-badge.reconnectable {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.deleted-badge.reconnectable:hover {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.deleted-badge .reconnect-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
margin-left: -100px;
|
||||
}
|
||||
|
||||
.deleted-badge.reconnectable:hover .reconnect-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* LoRA reconnect container */
|
||||
.lora-reconnect-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lora-reconnect-container.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reconnect-instructions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.reconnect-instructions p {
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.reconnect-instructions small {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.reconnect-instructions code {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .reconnect-instructions code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.reconnect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reconnect-input {
|
||||
width: calc(100% - 20px);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.reconnect-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn,
|
||||
.reconnect-confirm-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.reconnect-confirm-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reconnect-cancel-btn:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.reconnect-confirm-btn:hover {
|
||||
background: color-mix(in oklch, var(--lora-accent), black 10%);
|
||||
}
|
||||
|
||||
/* Recipe status partial state */
|
||||
.recipe-status.partial {
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* 标题输入框特定的样式 */
|
||||
.title-input {
|
||||
font-size: 1.2em !important; /* 调整为更合适的大小 */
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
min-width: 110px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Update the local-badge and missing-badge to be positioned within the badge-container */
|
||||
.badge-container .local-badge,
|
||||
.badge-container .missing-badge,
|
||||
.badge-container .deleted-badge {
|
||||
position: static; /* Override absolute positioning */
|
||||
transform: none; /* Remove the transform */
|
||||
}
|
||||
|
||||
/* Ensure the tooltip is still properly positioned */
|
||||
.badge-container .local-badge .local-path {
|
||||
position: fixed; /* Keep as fixed for Chrome */
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Add styles for missing LoRAs download feature */
|
||||
.recipe-status.missing {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-status.missing:hover {
|
||||
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
|
||||
}
|
||||
|
||||
.recipe-status.missing .missing-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
margin-left: -100px;
|
||||
margin-top: -65px;
|
||||
}
|
||||
|
||||
.recipe-status.missing:hover .missing-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.recipe-status.clickable {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.recipe-status.clickable:hover {
|
||||
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
|
||||
}
|
||||
520
static/css/components/search-filter.css
Normal file
520
static/css/components/search-filter.css
Normal file
@@ -0,0 +1,520 @@
|
||||
/* Search Container Styles */
|
||||
.search-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 调整搜索框样式以匹配其他控件 */
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 6px 35px 6px 12px; /* Reduced right padding */
|
||||
border: 1px solid oklch(65% 0.02 256);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 80px; /* Adjusted to make space for both toggle buttons */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: oklch(var(--text-color) / 0.5);
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 修改清空按钮样式 */
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 105px; /* Adjusted further left to avoid overlapping */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: oklch(var(--text-color) / 0.5);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 8px;
|
||||
display: none;
|
||||
line-height: 1;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-clear.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-mode-toggle {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid oklch(65% 0.02 256);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-mode-toggle:hover {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-mode-toggle.active {
|
||||
background-color: oklch(95% 0.05 256); /* Lighter background that's more consistent */
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-mode-toggle i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.search-filter-toggle {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid oklch(65% 0.02 256);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-filter-toggle:hover {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-filter-toggle.active {
|
||||
background-color: oklch(95% 0.05 256); /* Lighter background that's more consistent */
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-filter-toggle i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Filter Panel Styles */
|
||||
.filter-panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50px; /* Position below header */
|
||||
width: 320px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 16px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
transform-origin: top right;
|
||||
max-height: calc(100vh - 70px); /* Adjusted for header height */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-panel.hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.close-filter-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-filter-btn:hover {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none; /* Prevent text selection */
|
||||
-webkit-user-select: none; /* For Safari */
|
||||
-moz-user-select: none; /* For Firefox */
|
||||
-ms-user-select: none; /* For IE/Edge */
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
background-color: var(--lora-surface-hover);
|
||||
}
|
||||
|
||||
.filter-tag.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Tag filter styles */
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tag-count {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tag-filter.active .tag-count {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tags-loading, .tags-error, .no-tags {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tags-error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Filter actions */
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background-color: var(--lora-surface-hover);
|
||||
}
|
||||
|
||||
/* Mobile Responsive Styles */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
width: 100%;
|
||||
order: -1;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
width: calc(100% - 40px);
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 160px; /* Adjusted for mobile layout */
|
||||
}
|
||||
}
|
||||
|
||||
/* Search Options Toggle */
|
||||
.search-options-toggle {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid oklch(65% 0.02 256);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-options-toggle:hover {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-options-toggle.active {
|
||||
background-color: oklch(95% 0.05 256);
|
||||
color: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-options-toggle i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Search Options Panel */
|
||||
.search-options-panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50px; /* Position below header */
|
||||
width: 280px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 16px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
transform-origin: top right;
|
||||
display: block; /* Ensure it's block by default */
|
||||
}
|
||||
|
||||
.search-options-panel.hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.options-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.options-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.close-options-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-options-btn:hover {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.options-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.options-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-option-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px; /* Increased gap for better spacing */
|
||||
}
|
||||
|
||||
.search-option-tag {
|
||||
padding: 6px 8px; /* Adjusted padding for better text display */
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-size: 13px; /* Slightly smaller font size */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap; /* Prevent text wrapping */
|
||||
min-width: 80px; /* Ensure minimum width for each tag */
|
||||
display: inline-flex; /* Better control over layout */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-option-tag:hover {
|
||||
background-color: var(--lora-surface-hover);
|
||||
}
|
||||
|
||||
.search-option-tag.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.search-options-panel,
|
||||
.filter-panel {
|
||||
width: calc(100% - 40px);
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 160px; /* Adjusted for mobile layout */
|
||||
}
|
||||
}
|
||||
111
static/css/components/shared.css
Normal file
111
static/css/components/shared.css
Normal file
@@ -0,0 +1,111 @@
|
||||
/* Local Version Badge */
|
||||
.local-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
/* Force hardware acceleration to prevent Chrome scroll issues */
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.local-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Early Access Badge */
|
||||
.early-access-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #00B87A; /* Green for early access */
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
/* Force hardware acceleration to prevent Chrome scroll issues */
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.early-access-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.early-access-info {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid #00B87A;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
margin-top: 4px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100; /* Higher z-index to ensure it's above other elements */
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
/* Create a separate layer with hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
/* Use a fixed position to ensure it's in a separate layer from scrollable content */
|
||||
position: fixed;
|
||||
pointer-events: none; /* Don't block mouse events */
|
||||
}
|
||||
|
||||
.early-access-badge:hover .early-access-info {
|
||||
display: block;
|
||||
pointer-events: auto; /* Allow interaction with the tooltip when visible */
|
||||
}
|
||||
|
||||
.local-path {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
margin-top: 4px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100; /* Higher z-index to ensure it's above other elements */
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
/* Create a separate layer with hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
/* Use a fixed position to ensure it's in a separate layer from scrollable content */
|
||||
position: fixed;
|
||||
pointer-events: none; /* Don't block mouse events */
|
||||
}
|
||||
|
||||
.local-badge:hover .local-path {
|
||||
display: block;
|
||||
pointer-events: auto; /* Allow interaction with the tooltip when visible */
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
184
static/css/components/support-modal.css
Normal file
184
static/css/components/support-modal.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/* Support Modal Styles */
|
||||
.support-modal {
|
||||
max-width: 570px;
|
||||
}
|
||||
|
||||
.support-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.support-icon {
|
||||
font-size: 1.8em;
|
||||
color: var(--lora-error);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.support-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.support-content > p {
|
||||
font-size: 1.1em;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.support-section {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.support-section h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 1.1em;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.support-section h3 i {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.support-section p {
|
||||
margin-top: 4px;
|
||||
margin-bottom: var(--space-1);
|
||||
color: var(--text-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.support-links {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.kofi-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
background: #FF5E5B;
|
||||
color: white;
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.kofi-button:hover {
|
||||
background: #E04946;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
text-align: center;
|
||||
margin-top: var(--space-1);
|
||||
font-style: italic;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Add support toggle button style */
|
||||
.support-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--lora-error);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.support-toggle:hover {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-error) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.support-toggle i {
|
||||
font-size: 1.1em;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
left: -0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.support-links {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Civitai link styles */
|
||||
.civitai-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.civitai-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1b98e4; /* Civitai brand blue color */
|
||||
}
|
||||
|
||||
.social-link:hover .civitai-icon {
|
||||
color: white; /* Icon color changes to white on hover */
|
||||
}
|
||||
|
||||
/* 增强hover状态的视觉反馈 */
|
||||
.social-link:hover,
|
||||
.update-link:hover,
|
||||
.folder-item:hover {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -120,4 +120,63 @@
|
||||
|
||||
.tooltip:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Toast Container for stacked notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
pointer-events: none; /* Allow clicking through the container */
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure each toast has pointer events */
|
||||
.toast-container .toast {
|
||||
pointer-events: auto;
|
||||
position: relative; /* Override fixed positioning */
|
||||
top: 0 !important; /* Let the container handle positioning */
|
||||
right: 0 !important;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Add missing warning toast style */
|
||||
.toast-warning {
|
||||
border-left: 4px solid var(--lora-warning);
|
||||
}
|
||||
|
||||
.toast-warning::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff9800'%3E%3Cpath d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Improve toast animation */
|
||||
.toast {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.toast-container {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
195
static/css/components/update-modal.css
Normal file
195
static/css/components/update-modal.css
Normal file
@@ -0,0 +1,195 @@
|
||||
/* Update Modal Styles */
|
||||
.update-modal {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.update-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
font-size: 1.8em;
|
||||
color: var(--lora-accent);
|
||||
animation: bounce 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.update-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.update-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
|
||||
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.current-version, .new-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-version .version-number {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.update-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.update-link:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.changelog-section {
|
||||
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
|
||||
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.changelog-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--lora-accent);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.changelog-content {
|
||||
max-height: 300px; /* Increased height since we removed instructions */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.changelog-item {
|
||||
margin-bottom: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.changelog-item:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.changelog-item h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.changelog-item ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.changelog-item li {
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.update-info {
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Update preferences section */
|
||||
.update-preferences {
|
||||
border-top: 1px solid var(--lora-border);
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Override toggle switch styles for update preferences */
|
||||
.update-preferences .toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-preferences .toggle-slider {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.update-preferences .toggle-label {
|
||||
margin-left: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.update-preferences {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.update-preferences .toggle-label {
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
.page-content {
|
||||
height: calc(100vh - 48px); /* Full height minus header */
|
||||
margin-top: 48px; /* Push down below header */
|
||||
overflow-y: auto; /* Enable scrolling here */
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 20px auto;
|
||||
padding: 0 15px;
|
||||
position: relative;
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -14,113 +25,24 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
width: 250px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0; /* 防止搜索框被压缩 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* 调整搜索框样式以匹配其他控件 */
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 6px 40px 6px 12px; /* 减小右侧padding */
|
||||
border: 1px solid oklch(65% 0.02 256); /* 更深的边框颜色,提高对比度 */
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
height: 32px;
|
||||
box-sizing: border-box; /* 确保padding不会增加总宽度 */
|
||||
/* Ensure hidden class works properly */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 40px; /* 调整到toggle按钮左侧 */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: oklch(var(--text-color) / 0.5);
|
||||
pointer-events: none;
|
||||
line-height: 1; /* 防止图标影响容器高度 */
|
||||
}
|
||||
|
||||
/* 修改清空按钮样式 */
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 65px; /* 放到search-icon左侧 */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: oklch(var(--text-color) / 0.5);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 8px; /* 增加点击区域 */
|
||||
display: none; /* 默认隐藏 */
|
||||
line-height: 1;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.search-clear.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-mode-toggle {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid oklch(65% 0.02 256);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-mode-toggle:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-mode-toggle.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-mode-toggle i {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.corner-controls {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
|
||||
/* Folder Tags Container */
|
||||
.folder-tags-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -128,11 +50,14 @@
|
||||
}
|
||||
|
||||
.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 */
|
||||
padding-right: 40px; /* Make space for the toggle button */
|
||||
margin-bottom: 5px; /* Add margin below the tags */
|
||||
}
|
||||
|
||||
@@ -141,13 +66,15 @@
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-folders-container {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Toggle Folders Button */
|
||||
.toggle-folders-btn {
|
||||
position: absolute;
|
||||
bottom: 0; /* 固定在容器底部 */
|
||||
right: 0; /* 固定在容器右侧 */
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
@@ -159,7 +86,6 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toggle-folders-btn:hover {
|
||||
@@ -172,25 +98,18 @@
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 折叠状态样式 */
|
||||
.folder-tags.collapsed + .toggle-folders-btn {
|
||||
position: static;
|
||||
margin-right: auto; /* 确保按钮在左侧 */
|
||||
transform: translateY(0);
|
||||
/* Icon-only button style */
|
||||
.icon-only {
|
||||
min-width: unset !important;
|
||||
width: 36px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.folder-tags.collapsed + .toggle-folders-btn i {
|
||||
/* Rotate icon when folders are collapsed */
|
||||
.folder-tags.collapsed ~ .actions .toggle-folders-btn i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 文件夹标签样式 */
|
||||
.folder-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Add custom scrollbar for better visibility */
|
||||
.folder-tags::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -260,99 +179,43 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-toggle img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.theme-toggle .theme-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle .light-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.theme-toggle .dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle .light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-toggle .dark-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
|
||||
.action-buttons {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
order: -1;
|
||||
.toggle-folders-container {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.folder-tags-container {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.toggle-folders-btn {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transform: none; /* 移除transform,防止hover时的位移 */
|
||||
}
|
||||
|
||||
.toggle-folders-btn:hover {
|
||||
transform: none; /* 移动端下禁用hover效果 */
|
||||
}
|
||||
|
||||
.folder-tags.collapsed + .toggle-folders-btn {
|
||||
position: relative;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.corner-controls {
|
||||
/* Keep the fixed positioning even on mobile */
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Standardize button widths in controls */
|
||||
.control-group button {
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,19 @@
|
||||
@import 'layout.css';
|
||||
|
||||
/* Import Components */
|
||||
@import 'components/header.css';
|
||||
@import 'components/card.css';
|
||||
@import 'components/modal.css';
|
||||
@import 'components/download-modal.css';
|
||||
@import 'components/toast.css';
|
||||
@import 'components/loading.css';
|
||||
@import 'components/menu.css';
|
||||
@import 'components/update-modal.css';
|
||||
@import 'components/lora-modal.css';
|
||||
@import 'components/support-modal.css';
|
||||
@import 'components/search-filter.css';
|
||||
@import 'components/bulk.css';
|
||||
@import 'components/shared.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
BIN
static/images/android-chrome-192x192.png
Normal file
BIN
static/images/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
static/images/android-chrome-512x512.png
Normal file
BIN
static/images/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
static/images/screenshot.png
Normal file
BIN
static/images/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{"name":"","short_name":"","icons":[{"src":"/loras_static/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/loras_static/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -1,37 +1,61 @@
|
||||
import { state } from '../state/index.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { createLoraCard } from '../components/LoraCard.js';
|
||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||
import { showDeleteModal } from '../utils/modalUtils.js';
|
||||
import { toggleFolder } from '../utils/uiHelpers.js';
|
||||
|
||||
export async function loadMoreLoras(boolUpdateFolders = false) {
|
||||
if (state.isLoading || !state.hasMore) return;
|
||||
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
state.isLoading = true;
|
||||
if (pageState.isLoading || (!pageState.hasMore && !resetPage)) return;
|
||||
|
||||
pageState.isLoading = true;
|
||||
try {
|
||||
// Reset to first page if requested
|
||||
if (resetPage) {
|
||||
pageState.currentPage = 1;
|
||||
// Clear grid if resetting
|
||||
const grid = document.getElementById('loraGrid');
|
||||
if (grid) grid.innerHTML = '';
|
||||
initializeInfiniteScroll();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: state.currentPage,
|
||||
page: pageState.currentPage,
|
||||
page_size: 20,
|
||||
sort_by: state.sortBy
|
||||
sort_by: pageState.sortBy
|
||||
});
|
||||
|
||||
// 使用 state 中的 searchManager 获取递归搜索状态
|
||||
const isRecursiveSearch = state.searchManager?.isRecursiveSearch ?? false;
|
||||
|
||||
if (state.activeFolder !== null) {
|
||||
params.append('folder', state.activeFolder);
|
||||
params.append('recursive', isRecursiveSearch.toString());
|
||||
if (pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
// Add search parameters if there's a search term
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput && searchInput.value.trim()) {
|
||||
params.append('search', searchInput.value.trim());
|
||||
if (pageState.filters?.search) {
|
||||
params.append('search', pageState.filters.search);
|
||||
params.append('fuzzy', 'true');
|
||||
|
||||
// Add search option parameters if available
|
||||
if (pageState.searchOptions) {
|
||||
params.append('search_filename', pageState.searchOptions.filename.toString());
|
||||
params.append('search_modelname', pageState.searchOptions.modelname.toString());
|
||||
params.append('search_tags', (pageState.searchOptions.tags || false).toString());
|
||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Add filter parameters if active
|
||||
if (pageState.filters) {
|
||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||
// Convert the array of tags to a comma-separated string
|
||||
params.append('tags', pageState.filters.tags.join(','));
|
||||
}
|
||||
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||
// Convert the array of base models to a comma-separated string
|
||||
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Loading loras with params:', params.toString());
|
||||
|
||||
const response = await fetch(`/api/loras?${params}`);
|
||||
if (!response.ok) {
|
||||
@@ -39,15 +63,14 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Received data:', data);
|
||||
|
||||
if (data.items.length === 0 && state.currentPage === 1) {
|
||||
if (data.items.length === 0 && pageState.currentPage === 1) {
|
||||
const grid = document.getElementById('loraGrid');
|
||||
grid.innerHTML = '<div class="no-results">No loras found in this folder</div>';
|
||||
state.hasMore = false;
|
||||
pageState.hasMore = false;
|
||||
} else if (data.items.length > 0) {
|
||||
state.hasMore = state.currentPage < data.total_pages;
|
||||
state.currentPage++;
|
||||
pageState.hasMore = pageState.currentPage < data.total_pages;
|
||||
pageState.currentPage++;
|
||||
appendLoraCards(data.items);
|
||||
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
@@ -55,10 +78,10 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
||||
state.observer.observe(sentinel);
|
||||
}
|
||||
} else {
|
||||
state.hasMore = false;
|
||||
pageState.hasMore = false;
|
||||
}
|
||||
|
||||
if (boolUpdateFolders && data.folders) {
|
||||
if (updateFolders && data.folders) {
|
||||
updateFolderTags(data.folders);
|
||||
}
|
||||
|
||||
@@ -66,7 +89,7 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
||||
console.error('Error loading loras:', error);
|
||||
showToast('Failed to load loras: ' + error.message, 'error');
|
||||
} finally {
|
||||
state.isLoading = false;
|
||||
pageState.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +98,8 @@ function updateFolderTags(folders) {
|
||||
if (!folderTagsContainer) return;
|
||||
|
||||
// Keep track of currently selected folder
|
||||
const currentFolder = state.activeFolder;
|
||||
const pageState = getCurrentPageState();
|
||||
const currentFolder = pageState.activeFolder;
|
||||
|
||||
// Create HTML for folder tags
|
||||
const tagsHTML = folders.map(folder => {
|
||||
@@ -101,7 +125,8 @@ export async function fetchCivitai() {
|
||||
|
||||
await state.loadingManager.showWithProgress(async (loading) => {
|
||||
try {
|
||||
ws = new WebSocket(`ws://${window.location.host}/ws/fetch-progress`);
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||
|
||||
const operationComplete = new Promise((resolve, reject) => {
|
||||
ws.onmessage = (event) => {
|
||||
@@ -247,31 +272,19 @@ export function appendLoraCards(loras) {
|
||||
|
||||
loras.forEach(lora => {
|
||||
const card = createLoraCard(lora);
|
||||
if (sentinel) {
|
||||
grid.insertBefore(card, sentinel);
|
||||
} else {
|
||||
grid.appendChild(card);
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetAndReload(boolUpdateFolders = false) {
|
||||
console.log('Resetting with state:', { ...state });
|
||||
|
||||
state.currentPage = 1;
|
||||
state.hasMore = true;
|
||||
state.isLoading = false;
|
||||
|
||||
const grid = document.getElementById('loraGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
grid.appendChild(sentinel);
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
console.log('Resetting with state:', { ...pageState });
|
||||
|
||||
// Initialize infinite scroll - will reset the observer
|
||||
initializeInfiniteScroll();
|
||||
|
||||
await loadMoreLoras(boolUpdateFolders);
|
||||
// Load more loras with reset flag
|
||||
await loadMoreLoras(true, updateFolders);
|
||||
}
|
||||
|
||||
export async function refreshLoras() {
|
||||
@@ -319,4 +332,19 @@ export async function refreshSingleLoraMetadata(filePath) {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModelDescription(modelId, filePath) {
|
||||
try {
|
||||
const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model description: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching model description:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
36
static/js/checkpoints.js
Normal file
36
static/js/checkpoints.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { appCore } from './core.js';
|
||||
import { state, initPageState } from './state/index.js';
|
||||
|
||||
// Initialize the Checkpoints page
|
||||
class CheckpointsPageManager {
|
||||
constructor() {
|
||||
// Initialize any necessary state
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Initialize page state
|
||||
initPageState('checkpoints');
|
||||
|
||||
// Initialize core application
|
||||
await appCore.initialize();
|
||||
|
||||
// Initialize page-specific components
|
||||
this._initializeWorkInProgress();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
_initializeWorkInProgress() {
|
||||
// Add any work-in-progress specific initialization here
|
||||
console.log('Checkpoints Manager is under development');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const checkpointsPage = new CheckpointsPageManager();
|
||||
await checkpointsPage.initialize();
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export class LoraContextMenu {
|
||||
constructor() {
|
||||
this.menu = document.getElementById('loraContextMenu');
|
||||
this.currentCard = null;
|
||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -58,10 +62,274 @@ export class LoraContextMenu {
|
||||
case 'refresh-metadata':
|
||||
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'set-nsfw':
|
||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||
break;
|
||||
}
|
||||
|
||||
this.hideMenu();
|
||||
});
|
||||
|
||||
// Initialize NSFW Level Selector events
|
||||
this.initNSFWSelector();
|
||||
}
|
||||
|
||||
initNSFWSelector() {
|
||||
// Close button
|
||||
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
});
|
||||
|
||||
// Level buttons
|
||||
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
|
||||
levelButtons.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const level = parseInt(btn.dataset.level);
|
||||
const filePath = this.nsfwSelector.dataset.cardPath;
|
||||
|
||||
if (!filePath) return;
|
||||
|
||||
try {
|
||||
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
||||
|
||||
// Update card data
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
let metaData = {};
|
||||
try {
|
||||
metaData = JSON.parse(card.dataset.meta || '{}');
|
||||
} catch (err) {
|
||||
console.error('Error parsing metadata:', err);
|
||||
}
|
||||
|
||||
metaData.preview_nsfw_level = level;
|
||||
card.dataset.meta = JSON.stringify(metaData);
|
||||
card.dataset.nsfwLevel = level.toString();
|
||||
|
||||
// Apply blur effect immediately
|
||||
this.updateCardBlurEffect(card, level);
|
||||
}
|
||||
|
||||
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
} catch (error) {
|
||||
showToast(`Failed to set content rating: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.nsfwSelector.style.display === 'block' &&
|
||||
!this.nsfwSelector.contains(e.target) &&
|
||||
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/loras/api/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
updateCardBlurEffect(card, level) {
|
||||
// Get user settings for blur threshold
|
||||
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
|
||||
|
||||
// Get card preview container
|
||||
const previewContainer = card.querySelector('.card-preview');
|
||||
if (!previewContainer) return;
|
||||
|
||||
// Get preview media element
|
||||
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
|
||||
if (!previewMedia) return;
|
||||
|
||||
// Check if blur should be applied
|
||||
if (level >= blurThreshold) {
|
||||
// Add blur class to the preview container
|
||||
previewContainer.classList.add('blurred');
|
||||
|
||||
// Get or create the NSFW overlay
|
||||
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
|
||||
if (!nsfwOverlay) {
|
||||
// Create new overlay
|
||||
nsfwOverlay = document.createElement('div');
|
||||
nsfwOverlay.className = 'nsfw-overlay';
|
||||
|
||||
// Create and configure the warning content
|
||||
const warningContent = document.createElement('div');
|
||||
warningContent.className = 'nsfw-warning';
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
if (level >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (level >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (level >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
// Add warning text and show button
|
||||
warningContent.innerHTML = `
|
||||
<p>${nsfwText}</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
`;
|
||||
|
||||
// Add click event to the show button
|
||||
const showBtn = warningContent.querySelector('.show-content-btn');
|
||||
showBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
previewContainer.classList.remove('blurred');
|
||||
nsfwOverlay.style.display = 'none';
|
||||
|
||||
// Update toggle button icon if it exists
|
||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
});
|
||||
|
||||
nsfwOverlay.appendChild(warningContent);
|
||||
previewContainer.appendChild(nsfwOverlay);
|
||||
} else {
|
||||
// Update existing overlay
|
||||
const warningText = nsfwOverlay.querySelector('p');
|
||||
if (warningText) {
|
||||
let nsfwText = "Mature Content";
|
||||
if (level >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (level >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (level >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
warningText.textContent = nsfwText;
|
||||
}
|
||||
nsfwOverlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Get or create the toggle button in the header
|
||||
const cardHeader = previewContainer.querySelector('.card-header');
|
||||
if (cardHeader) {
|
||||
let toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
|
||||
|
||||
if (!toggleBtn) {
|
||||
toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'toggle-blur-btn';
|
||||
toggleBtn.title = 'Toggle blur';
|
||||
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
|
||||
|
||||
// Add click event to toggle button
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isBlurred = previewContainer.classList.toggle('blurred');
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
|
||||
// Update icon and overlay visibility
|
||||
if (isBlurred) {
|
||||
icon.className = 'fas fa-eye';
|
||||
nsfwOverlay.style.display = 'flex';
|
||||
} else {
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
nsfwOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Add to the beginning of header
|
||||
cardHeader.insertBefore(toggleBtn, cardHeader.firstChild);
|
||||
|
||||
// Update base model label class
|
||||
const baseModelLabel = cardHeader.querySelector('.base-model-label');
|
||||
if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) {
|
||||
baseModelLabel.classList.add('with-toggle');
|
||||
}
|
||||
} else {
|
||||
// Update existing toggle button
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove blur
|
||||
previewContainer.classList.remove('blurred');
|
||||
|
||||
// Hide overlay if it exists
|
||||
const overlay = previewContainer.querySelector('.nsfw-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
|
||||
// Update or remove toggle button
|
||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
// We'll leave the button but update the icon
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showNSFWLevelSelector(x, y, card) {
|
||||
const selector = document.getElementById('nsfwLevelSelector');
|
||||
const currentLevelEl = document.getElementById('currentNSFWLevel');
|
||||
|
||||
// Get current NSFW level
|
||||
let currentLevel = 0;
|
||||
try {
|
||||
const metaData = JSON.parse(card.dataset.meta || '{}');
|
||||
currentLevel = metaData.preview_nsfw_level || 0;
|
||||
|
||||
// Update if we have no recorded level but have a dataset attribute
|
||||
if (!currentLevel && card.dataset.nsfwLevel) {
|
||||
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing metadata:', err);
|
||||
}
|
||||
|
||||
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
|
||||
|
||||
// Position the selector
|
||||
if (x && y) {
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const selectorRect = selector.getBoundingClientRect();
|
||||
|
||||
// Center the selector if no coordinates provided
|
||||
let finalX = (viewportWidth - selectorRect.width) / 2;
|
||||
let finalY = (viewportHeight - selectorRect.height) / 2;
|
||||
|
||||
selector.style.left = `${finalX}px`;
|
||||
selector.style.top = `${finalY}px`;
|
||||
}
|
||||
|
||||
// Highlight current level button
|
||||
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
|
||||
if (parseInt(btn.dataset.level) === currentLevel) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Store reference to current card
|
||||
selector.dataset.cardPath = card.dataset.filepath;
|
||||
|
||||
// Show selector
|
||||
selector.style.display = 'block';
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
|
||||
82
static/js/components/Header.js
Normal file
82
static/js/components/Header.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { updateService } from '../managers/UpdateService.js';
|
||||
import { toggleTheme } from '../utils/uiHelpers.js';
|
||||
import { SearchManager } from '../managers/SearchManager.js';
|
||||
import { FilterManager } from '../managers/FilterManager.js';
|
||||
import { initPageState } from '../state/index.js';
|
||||
|
||||
/**
|
||||
* Header.js - Manages the application header behavior across different pages
|
||||
* Handles initialization of appropriate search and filter managers based on current page
|
||||
*/
|
||||
export class HeaderManager {
|
||||
constructor() {
|
||||
this.currentPage = this.detectCurrentPage();
|
||||
initPageState(this.currentPage);
|
||||
this.searchManager = null;
|
||||
this.filterManager = null;
|
||||
|
||||
// Initialize appropriate managers based on current page
|
||||
this.initializeManagers();
|
||||
|
||||
// Set up common header functionality
|
||||
this.initializeCommonElements();
|
||||
}
|
||||
|
||||
detectCurrentPage() {
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/loras/recipes')) return 'recipes';
|
||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||
if (path.includes('/loras')) return 'loras';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
initializeManagers() {
|
||||
// Initialize SearchManager for all page types
|
||||
this.searchManager = new SearchManager({ page: this.currentPage });
|
||||
window.searchManager = this.searchManager;
|
||||
|
||||
// Initialize FilterManager for all page types that have filters
|
||||
if (document.getElementById('filterButton')) {
|
||||
this.filterManager = new FilterManager({ page: this.currentPage });
|
||||
window.filterManager = this.filterManager;
|
||||
}
|
||||
}
|
||||
|
||||
initializeCommonElements() {
|
||||
// Handle theme toggle
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
if (typeof toggleTheme === 'function') {
|
||||
toggleTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle settings toggle
|
||||
const settingsToggle = document.querySelector('.settings-toggle');
|
||||
if (settingsToggle) {
|
||||
settingsToggle.addEventListener('click', () => {
|
||||
if (window.settingsManager) {
|
||||
window.settingsManager.toggleSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle update toggle
|
||||
const updateToggle = document.getElementById('updateToggleBtn');
|
||||
if (updateToggle) {
|
||||
updateToggle.addEventListener('click', () => {
|
||||
updateService.toggleUpdateModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle support toggle
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
supportToggle.addEventListener('click', () => {
|
||||
// Handle support panel logic
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showLoraModal } from './LoraModal.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
|
||||
export function createLoraCard(lora) {
|
||||
const card = document.createElement('div');
|
||||
@@ -17,13 +19,46 @@ export function createLoraCard(lora) {
|
||||
card.dataset.usage_tips = lora.usage_tips;
|
||||
card.dataset.notes = lora.notes;
|
||||
card.dataset.meta = JSON.stringify(lora.civitai || {});
|
||||
|
||||
// Store tags and model description
|
||||
if (lora.tags && Array.isArray(lora.tags)) {
|
||||
card.dataset.tags = JSON.stringify(lora.tags);
|
||||
}
|
||||
if (lora.modelDescription) {
|
||||
card.dataset.modelDescription = lora.modelDescription;
|
||||
}
|
||||
|
||||
// Store NSFW level if available
|
||||
const nsfwLevel = lora.preview_nsfw_level !== undefined ? lora.preview_nsfw_level : 0;
|
||||
card.dataset.nsfwLevel = nsfwLevel;
|
||||
|
||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set
|
||||
if (state.bulkMode && state.selectedLoras.has(lora.file_path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const version = state.previewVersions.get(lora.file_path);
|
||||
const previewUrl = lora.preview_url || '/loras_static/images/no-preview.png';
|
||||
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview">
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${previewUrl.endsWith('.mp4') ?
|
||||
`<video controls autoplay muted loop>
|
||||
<source src="${versionedPreviewUrl}" type="video/mp4">
|
||||
@@ -31,7 +66,11 @@ export function createLoraCard(lora) {
|
||||
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
|
||||
}
|
||||
<div class="card-header">
|
||||
<span class="base-model-label" title="${lora.base_model}">
|
||||
${shouldBlur ?
|
||||
`<button class="toggle-blur-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>` : ''}
|
||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${lora.base_model}">
|
||||
${lora.base_model}
|
||||
</span>
|
||||
<div class="card-actions">
|
||||
@@ -47,6 +86,14 @@ export function createLoraCard(lora) {
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
<div class="nsfw-warning">
|
||||
<p>${nsfwText}</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${lora.model_name}</span>
|
||||
@@ -60,25 +107,89 @@ export function createLoraCard(lora) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Main card click event
|
||||
// Main card click event - modified to handle bulk mode
|
||||
card.addEventListener('click', () => {
|
||||
const loraMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
folder: card.dataset.folder,
|
||||
modified: card.dataset.modified,
|
||||
file_size: card.dataset.file_size,
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
base_model: card.dataset.base_model,
|
||||
usage_tips: card.dataset.usage_tips,
|
||||
notes: card.dataset.notes,
|
||||
civitai: JSON.parse(card.dataset.meta || '{}')
|
||||
};
|
||||
showLoraModal(loraMeta);
|
||||
// Check if we're in bulk mode
|
||||
if (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
bulkManager.toggleCardSelection(card);
|
||||
} else {
|
||||
// Normal behavior - show modal
|
||||
const loraMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
folder: card.dataset.folder,
|
||||
modified: card.dataset.modified,
|
||||
file_size: card.dataset.file_size,
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
base_model: card.dataset.base_model,
|
||||
usage_tips: card.dataset.usage_tips,
|
||||
notes: card.dataset.notes,
|
||||
// Parse civitai metadata from the card's dataset
|
||||
civitai: (() => {
|
||||
try {
|
||||
// Attempt to parse the JSON string
|
||||
return JSON.parse(card.dataset.meta || '{}');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse civitai metadata:', e);
|
||||
return {}; // Return empty object on error
|
||||
}
|
||||
})(),
|
||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||
modelDescription: card.dataset.modelDescription || ''
|
||||
};
|
||||
showLoraModal(loraMeta);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle blur button functionality
|
||||
const toggleBlurBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBlurBtn) {
|
||||
toggleBlurBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const preview = card.querySelector('.card-preview');
|
||||
const isBlurred = preview.classList.toggle('blurred');
|
||||
const icon = toggleBlurBtn.querySelector('i');
|
||||
|
||||
// Update the icon based on blur state
|
||||
if (isBlurred) {
|
||||
icon.className = 'fas fa-eye';
|
||||
} else {
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Toggle the overlay visibility
|
||||
const overlay = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show content button functionality
|
||||
const showContentBtn = card.querySelector('.show-content-btn');
|
||||
if (showContentBtn) {
|
||||
showContentBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const preview = card.querySelector('.card-preview');
|
||||
preview.classList.remove('blurred');
|
||||
|
||||
// Update the toggle button icon
|
||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Hide the overlay
|
||||
const overlay = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy button click event
|
||||
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
@@ -127,477 +238,49 @@ export function createLoraCard(lora) {
|
||||
e.stopPropagation();
|
||||
replacePreview(lora.file_path);
|
||||
});
|
||||
|
||||
// Apply bulk mode styling if currently in bulk mode
|
||||
if (state.bulkMode) {
|
||||
const actions = card.querySelectorAll('.card-actions');
|
||||
actions.forEach(actionGroup => {
|
||||
actionGroup.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export function showLoraModal(lora) {
|
||||
const escapedWords = lora.civitai?.trainedWords?.length ?
|
||||
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
const content = `
|
||||
<div class="modal-content">
|
||||
<button class="close" onclick="modalManager.closeModal('loraModal')">×</button>
|
||||
<header class="modal-header">
|
||||
<h2>${lora.model_name}</h2>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="info-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>Version</label>
|
||||
<span>${lora.civitai.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>File Name</label>
|
||||
<div class="file-name-wrapper" onclick="copyFileName('${lora.file_name}')">
|
||||
<span id="file-name">${lora.file_name || 'N/A'}</span>
|
||||
<i class="fas fa-copy" title="Copy file name"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item location-size">
|
||||
<div class="location-wrapper">
|
||||
<label>Location</label>
|
||||
<span class="file-path">${lora.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item base-size">
|
||||
<div class="base-wrapper">
|
||||
<label>Base Model</label>
|
||||
<span>${lora.base_model || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="size-wrapper">
|
||||
<label>Size</label>
|
||||
<span>${formatFileSize(lora.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item usage-tips">
|
||||
<label>Usage Tips</label>
|
||||
<div class="editable-field">
|
||||
<div class="preset-controls">
|
||||
<select id="preset-selector">
|
||||
<option value="">Add preset parameter...</option>
|
||||
<option value="strength_min">Strength Min</option>
|
||||
<option value="strength_max">Strength Max</option>
|
||||
<option value="strength">Strength</option>
|
||||
<option value="clip_skip">Clip Skip</option>
|
||||
</select>
|
||||
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
|
||||
<button class="add-preset-btn">Add</button>
|
||||
</div>
|
||||
<div class="preset-tags">
|
||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderTriggerWords(escapedWords)}
|
||||
<div class="info-item notes">
|
||||
<label>Additional Notes</label>
|
||||
<div class="editable-field">
|
||||
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
|
||||
<button class="save-btn" onclick="saveNotes('${lora.file_path}')">
|
||||
<i class="fas fa-save"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item full-width">
|
||||
<label>About this version</label>
|
||||
<div class="description-text">${lora.description || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
${renderShowcaseImages(lora.civitai.images)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Add a method to update card appearance based on bulk mode
|
||||
export function updateCardsForBulkMode(isBulkMode) {
|
||||
// Update the state
|
||||
state.bulkMode = isBulkMode;
|
||||
|
||||
modalManager.showModal('loraModal', content);
|
||||
setupEditableFields();
|
||||
setupShowcaseScroll(); // Add this line
|
||||
}
|
||||
|
||||
// 添加复制文件名的函数
|
||||
window.copyFileName = async function(fileName) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileName);
|
||||
showToast('File name copied', 'success');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
function setupEditableFields() {
|
||||
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
|
||||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||||
|
||||
editableFields.forEach(field => {
|
||||
field.addEventListener('focus', function() {
|
||||
if (this.textContent === 'Add your notes here...' ||
|
||||
this.textContent === 'Save usage tips here..') {
|
||||
this.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('blur', function() {
|
||||
if (this.textContent.trim() === '') {
|
||||
this.textContent = this.classList.contains('usage-tips-content')
|
||||
? 'Save usage tips here..'
|
||||
: 'Add your notes here...';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const presetSelector = document.getElementById('preset-selector');
|
||||
const presetValue = document.getElementById('preset-value');
|
||||
const addPresetBtn = document.querySelector('.add-preset-btn');
|
||||
const presetTags = document.querySelector('.preset-tags');
|
||||
|
||||
presetSelector.addEventListener('change', function() {
|
||||
const selected = this.value;
|
||||
if (selected) {
|
||||
presetValue.style.display = 'inline-block';
|
||||
presetValue.min = selected.includes('strength') ? 0 : 1;
|
||||
presetValue.max = selected.includes('strength') ? 1 : 12;
|
||||
presetValue.step = selected.includes('strength') ? 0.01 : 1;
|
||||
if (selected === 'clip_skip') {
|
||||
presetValue.type = 'number';
|
||||
presetValue.step = 1;
|
||||
}
|
||||
// Add auto-focus
|
||||
setTimeout(() => presetValue.focus(), 0);
|
||||
// Get all lora cards
|
||||
const loraCards = document.querySelectorAll('.lora-card');
|
||||
|
||||
loraCards.forEach(card => {
|
||||
// Get all action containers for this card
|
||||
const actions = card.querySelectorAll('.card-actions');
|
||||
|
||||
// Handle display property based on mode
|
||||
if (isBulkMode) {
|
||||
// Hide actions when entering bulk mode
|
||||
actions.forEach(actionGroup => {
|
||||
actionGroup.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
presetValue.style.display = 'none';
|
||||
// Ensure actions are visible when exiting bulk mode
|
||||
actions.forEach(actionGroup => {
|
||||
// We need to reset to default display style which is flex
|
||||
actionGroup.style.display = 'flex';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addPresetBtn.addEventListener('click', async function() {
|
||||
const key = presetSelector.value;
|
||||
const value = presetValue.value;
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
const filePath = document.querySelector('.modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('.modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
currentPresets[key] = parseFloat(value);
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await saveModelMetadata(filePath, {
|
||||
usage_tips: newPresetsJson
|
||||
});
|
||||
|
||||
loraCard.dataset.usage_tips = newPresetsJson;
|
||||
presetTags.innerHTML = renderPresetTags(currentPresets);
|
||||
|
||||
presetSelector.value = '';
|
||||
presetValue.value = '';
|
||||
presetValue.style.display = 'none';
|
||||
});
|
||||
|
||||
// Add keydown event listeners for notes
|
||||
const notesContent = document.querySelector('.notes-content');
|
||||
if (notesContent) {
|
||||
notesContent.addEventListener('keydown', async function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
// Allow shift+enter for new line
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const filePath = document.querySelector('.modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('.modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
await saveNotes(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply selection state to cards if entering bulk mode
|
||||
if (isBulkMode) {
|
||||
bulkManager.applySelectionState();
|
||||
}
|
||||
|
||||
// Add keydown event for preset value
|
||||
presetValue.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPresetBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.saveNotes = async function(filePath) {
|
||||
const content = document.querySelector('.notes-content').textContent;
|
||||
try {
|
||||
await saveModelMetadata(filePath, { notes: content });
|
||||
|
||||
// Update the corresponding lora card's dataset
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
loraCard.dataset.notes = content;
|
||||
}
|
||||
|
||||
showToast('Notes saved successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to save notes', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
async function saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/loras/api/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTriggerWords(words) {
|
||||
if (!words.length) return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<label>Trigger Words</label>
|
||||
<span>No trigger word needed</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<label>Trigger Words</label>
|
||||
<div class="trigger-words-tags">
|
||||
${words.map(word => `
|
||||
<div class="trigger-word-tag" onclick="copyTriggerWord('${word}')">
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderShowcaseImages(images) {
|
||||
if (!images?.length) return '';
|
||||
|
||||
return `
|
||||
<div class="showcase-section">
|
||||
<div class="scroll-indicator" onclick="toggleShowcase(this)">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
<span>Scroll or click to show ${images.length} examples</span>
|
||||
</div>
|
||||
<div class="carousel collapsed">
|
||||
<div class="carousel-container">
|
||||
${images.map(img => {
|
||||
// 计算适当的展示高度:
|
||||
// 1. 保持原始宽高比
|
||||
// 2. 限制最大高度为视窗高度的60%
|
||||
// 3. 确保最小高度为容器宽度的40%
|
||||
const aspectRatio = (img.height / img.width) * 100;
|
||||
const containerWidth = 800; // modal content的最大宽度
|
||||
const minHeightPercent = 40; // 最小高度为容器宽度的40%
|
||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||
const heightPercent = Math.max(
|
||||
minHeightPercent,
|
||||
Math.min(maxHeightPercent, aspectRatio)
|
||||
);
|
||||
|
||||
if (img.type === 'video') {
|
||||
return `
|
||||
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
|
||||
<video controls autoplay muted loop crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer" data-src="${img.url}"
|
||||
class="lazy">
|
||||
<source data-src="${img.url}" type="video/mp4">
|
||||
Your browser does not support video playback
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
|
||||
<img data-src="${img.url}"
|
||||
alt="Preview"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
width="${img.width}"
|
||||
height="${img.height}"
|
||||
class="lazy">
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<button class="back-to-top" onclick="scrollToTop(this)">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add this to the window object for global access
|
||||
export function toggleShowcase(element) {
|
||||
const carousel = element.nextElementSibling;
|
||||
const isCollapsed = carousel.classList.contains('collapsed');
|
||||
const indicator = element.querySelector('span');
|
||||
const icon = element.querySelector('i');
|
||||
|
||||
carousel.classList.toggle('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
indicator.textContent = `Scroll or click to hide examples`;
|
||||
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
||||
initLazyLoading(carousel);
|
||||
} else {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
indicator.textContent = `Scroll or click to show ${count} examples`;
|
||||
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
|
||||
}
|
||||
};
|
||||
|
||||
// Add lazy loading initialization
|
||||
function initLazyLoading(container) {
|
||||
const lazyElements = container.querySelectorAll('.lazy');
|
||||
|
||||
const lazyLoad = (element) => {
|
||||
if (element.tagName.toLowerCase() === 'video') {
|
||||
element.src = element.dataset.src;
|
||||
element.querySelector('source').src = element.dataset.src;
|
||||
element.load();
|
||||
} else {
|
||||
element.src = element.dataset.src;
|
||||
}
|
||||
element.classList.remove('lazy');
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
lazyLoad(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lazyElements.forEach(element => observer.observe(element));
|
||||
}
|
||||
|
||||
export function setupShowcaseScroll() {
|
||||
// Change from modal-content to window/document level
|
||||
document.addEventListener('wheel', (event) => {
|
||||
const modalContent = document.querySelector('.modal-content');
|
||||
if (!modalContent) return;
|
||||
|
||||
const showcase = modalContent.querySelector('.showcase-section');
|
||||
if (!showcase) return;
|
||||
|
||||
const carousel = showcase.querySelector('.carousel');
|
||||
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
||||
|
||||
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
||||
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||
|
||||
if (isNearBottom) {
|
||||
toggleShowcase(scrollIndicator);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}, { passive: false }); // Add passive: false option here
|
||||
|
||||
// Keep the existing scroll tracking code
|
||||
const modalContent = document.querySelector('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.addEventListener('scroll', () => {
|
||||
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
||||
if (backToTopBtn) {
|
||||
if (modalContent.scrollTop > 300) {
|
||||
backToTopBtn.classList.add('visible');
|
||||
} else {
|
||||
backToTopBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToTop(button) {
|
||||
const modalContent = button.closest('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parsePresets(usageTips) {
|
||||
if (!usageTips || usageTips === 'Save usage tips here..') return {};
|
||||
try {
|
||||
return JSON.parse(usageTips);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function renderPresetTags(presets) {
|
||||
return Object.entries(presets).map(([key, value]) => `
|
||||
<div class="preset-tag" data-key="${key}">
|
||||
<span>${formatPresetKey(key)}: ${value}</span>
|
||||
<i class="fas fa-times" onclick="removePreset('${key}')"></i>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function formatPresetKey(key) {
|
||||
return key.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
window.removePreset = async function(key) {
|
||||
const filePath = document.querySelector('.modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('.modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
delete currentPresets[key];
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await saveModelMetadata(filePath, {
|
||||
usage_tips: newPresetsJson
|
||||
});
|
||||
|
||||
loraCard.dataset.usage_tips = newPresetsJson;
|
||||
document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets);
|
||||
};
|
||||
|
||||
// 添加文件大小格式化函数
|
||||
function formatFileSize(bytes) {
|
||||
console.log('formatFileSize: ', bytes);
|
||||
if (!bytes) return 'N/A';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
1733
static/js/components/LoraModal.js
Normal file
1733
static/js/components/LoraModal.js
Normal file
File diff suppressed because it is too large
Load Diff
282
static/js/components/RecipeCard.js
Normal file
282
static/js/components/RecipeCard.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// Recipe Card Component
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
this.recipe = recipe;
|
||||
this.clickHandler = clickHandler;
|
||||
this.element = this.createCardElement();
|
||||
}
|
||||
|
||||
createCardElement() {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lora-card';
|
||||
card.dataset.filePath = this.recipe.file_path;
|
||||
card.dataset.title = this.recipe.title;
|
||||
card.dataset.created = this.recipe.created_date;
|
||||
card.dataset.id = this.recipe.id || '';
|
||||
|
||||
// Get base model
|
||||
const baseModel = this.recipe.base_model || '';
|
||||
|
||||
// Ensure loras array exists
|
||||
const loras = this.recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
|
||||
// Check if all LoRAs are available in the library
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
|
||||
// Ensure file_url exists, fallback to file_path if needed
|
||||
const imageUrl = this.recipe.file_url ||
|
||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="recipe-indicator" title="Recipe">R</div>
|
||||
<div class="card-preview">
|
||||
<img src="${imageUrl}" alt="${this.recipe.title}">
|
||||
<div class="card-header">
|
||||
<div class="base-model-wrapper">
|
||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${this.recipe.title}</span>
|
||||
</div>
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachEventListeners(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
getLoraStatusTitle(totalCount, missingCount) {
|
||||
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
}
|
||||
|
||||
attachEventListeners(card) {
|
||||
// Recipe card click event
|
||||
card.addEventListener('click', () => {
|
||||
this.clickHandler(this.recipe);
|
||||
});
|
||||
|
||||
// Share button click event - prevent propagation to card
|
||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.shareRecipe();
|
||||
});
|
||||
|
||||
// Copy button click event - prevent propagation to card
|
||||
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.copyRecipeSyntax();
|
||||
});
|
||||
|
||||
// Delete button click event - prevent propagation to card
|
||||
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.showDeleteConfirmation();
|
||||
});
|
||||
}
|
||||
|
||||
copyRecipeSyntax() {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fallback if button not found
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
return navigator.clipboard.writeText(data.syntax);
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Recipe syntax copied to clipboard', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error copying recipe syntax:', error);
|
||||
showToast('Error copying recipe syntax', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
showDeleteConfirmation() {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
<img src="${this.recipe.file_url || '/loras_static/images/no-preview.png'}" alt="${this.recipe.title}">
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${this.recipe.title}</h3>
|
||||
<p>This action cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('Error showing delete confirmation', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
confirmDeleteRecipe() {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('Cannot delete recipe: Missing recipe ID', 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
showToast('Recipe deleted successfully', 'success');
|
||||
|
||||
window.recipeManager.loadRecipes();
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('Error deleting recipe: ' + error.message, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
shareRecipe() {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot share recipe: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading toast
|
||||
showToast('Preparing recipe for sharing...', 'info');
|
||||
|
||||
// Call the API to process the image with metadata
|
||||
fetch(`/api/recipe/${recipeId}/share`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to prepare recipe for sharing');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
|
||||
// Create a temporary anchor element for download
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = data.download_url;
|
||||
downloadLink.download = data.filename;
|
||||
|
||||
// Append to body, click and remove
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
|
||||
showToast('Recipe download started', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sharing recipe:', error);
|
||||
showToast('Error sharing recipe: ' + error.message, 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sharing recipe:', error);
|
||||
showToast('Error preparing recipe for sharing', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { RecipeCard };
|
||||
1012
static/js/components/RecipeModal.js
Normal file
1012
static/js/components/RecipeModal.js
Normal file
File diff suppressed because it is too large
Load Diff
79
static/js/core.js
Normal file
79
static/js/core.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// Core application functionality
|
||||
import { state } from './state/index.js';
|
||||
import { LoadingManager } from './managers/LoadingManager.js';
|
||||
import { modalManager } from './managers/ModalManager.js';
|
||||
import { updateService } from './managers/UpdateService.js';
|
||||
import { HeaderManager } from './components/Header.js';
|
||||
import { SettingsManager } from './managers/SettingsManager.js';
|
||||
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||
|
||||
// Core application class
|
||||
export class AppCore {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
// Initialize core functionality
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
console.log('AppCore: Initializing...');
|
||||
|
||||
// Initialize managers
|
||||
state.loadingManager = new LoadingManager();
|
||||
modalManager.initialize();
|
||||
updateService.initialize();
|
||||
window.modalManager = modalManager;
|
||||
window.settingsManager = new SettingsManager();
|
||||
|
||||
// Initialize UI components
|
||||
window.headerManager = new HeaderManager();
|
||||
initTheme();
|
||||
initBackToTop();
|
||||
|
||||
// Mark as initialized
|
||||
this.initialized = true;
|
||||
|
||||
// Return the core instance for chaining
|
||||
return this;
|
||||
}
|
||||
|
||||
// Get the current page type
|
||||
getPageType() {
|
||||
const body = document.body;
|
||||
return body.dataset.page || 'unknown';
|
||||
}
|
||||
|
||||
// Show toast messages
|
||||
showToast(message, type = 'info') {
|
||||
showToast(message, type);
|
||||
}
|
||||
|
||||
// Initialize common UI features based on page type
|
||||
initializePageFeatures() {
|
||||
const pageType = this.getPageType();
|
||||
|
||||
// Initialize lazy loading for images on all pages
|
||||
lazyLoadImages();
|
||||
|
||||
// Initialize infinite scroll for pages that need it
|
||||
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
|
||||
initializeInfiniteScroll(pageType);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Migrate localStorage items to use the namespace prefix
|
||||
migrateStorageItems();
|
||||
});
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const appCore = new AppCore();
|
||||
|
||||
// Export common utilities for global use
|
||||
export { showToast, lazyLoadImages, initializeInfiniteScroll };
|
||||
123
static/js/loras.js
Normal file
123
static/js/loras.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { appCore } from './core.js';
|
||||
import { state } from './state/index.js';
|
||||
import { showLoraModal, toggleShowcase, scrollToTop } from './components/LoraModal.js';
|
||||
import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
|
||||
import {
|
||||
restoreFolderFilter,
|
||||
toggleFolder,
|
||||
copyTriggerWord,
|
||||
openCivitai,
|
||||
toggleFolderTags,
|
||||
initFolderTagsVisibility,
|
||||
} from './utils/uiHelpers.js';
|
||||
import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
|
||||
import { DownloadManager } from './managers/DownloadManager.js';
|
||||
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
||||
import { LoraContextMenu } from './components/ContextMenu.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
import { updateCardsForBulkMode } from './components/LoraCard.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { setStorageItem, getStorageItem } from './utils/storageHelpers.js';
|
||||
|
||||
// Initialize the LoRA page
|
||||
class LoraPageManager {
|
||||
constructor() {
|
||||
// Add bulk mode to state
|
||||
state.bulkMode = false;
|
||||
state.selectedLoras = new Set();
|
||||
|
||||
// Initialize managers
|
||||
this.downloadManager = new DownloadManager();
|
||||
|
||||
// Expose necessary functions to the page
|
||||
this._exposeGlobalFunctions();
|
||||
}
|
||||
|
||||
_exposeGlobalFunctions() {
|
||||
// Only expose what's needed for the page
|
||||
window.loadMoreLoras = loadMoreLoras;
|
||||
window.fetchCivitai = fetchCivitai;
|
||||
window.deleteModel = deleteModel;
|
||||
window.replacePreview = replacePreview;
|
||||
window.toggleFolder = toggleFolder;
|
||||
window.copyTriggerWord = copyTriggerWord;
|
||||
window.showLoraModal = showLoraModal;
|
||||
window.confirmDelete = confirmDelete;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
window.refreshLoras = refreshLoras;
|
||||
window.openCivitai = openCivitai;
|
||||
window.toggleFolderTags = toggleFolderTags;
|
||||
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
||||
window.downloadManager = this.downloadManager;
|
||||
window.moveManager = moveManager;
|
||||
window.toggleShowcase = toggleShowcase;
|
||||
window.scrollToTop = scrollToTop;
|
||||
|
||||
// Bulk operations
|
||||
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
|
||||
window.clearSelection = () => bulkManager.clearSelection();
|
||||
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
|
||||
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
|
||||
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
|
||||
window.bulkManager = bulkManager;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Initialize page-specific components
|
||||
this.initEventListeners();
|
||||
restoreFolderFilter();
|
||||
initFolderTagsVisibility();
|
||||
new LoraContextMenu();
|
||||
|
||||
// Initialize cards for current bulk mode state (should be false initially)
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
// Initialize the bulk manager
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize common page features (lazy loading, infinite scroll)
|
||||
appCore.initializePageFeatures();
|
||||
}
|
||||
|
||||
loadSortPreference() {
|
||||
const savedSort = getStorageItem('loras_sort');
|
||||
if (savedSort) {
|
||||
state.sortBy = savedSort;
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedSort;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveSortPreference(sortValue) {
|
||||
setStorageItem('loras_sort', sortValue);
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = state.sortBy;
|
||||
this.loadSortPreference();
|
||||
sortSelect.addEventListener('change', async (e) => {
|
||||
state.sortBy = e.target.value;
|
||||
this.saveSortPreference(e.target.value);
|
||||
await resetAndReload();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
tag.addEventListener('click', toggleFolder);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize core application
|
||||
await appCore.initialize();
|
||||
|
||||
// Initialize page-specific functionality
|
||||
const loraPage = new LoraPageManager();
|
||||
await loraPage.initialize();
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { debounce } from './utils/debounce.js';
|
||||
import { LoadingManager } from './managers/LoadingManager.js';
|
||||
import { modalManager } from './managers/ModalManager.js';
|
||||
import { state } from './state/index.js';
|
||||
import { showLoraModal, toggleShowcase, scrollToTop } from './components/LoraCard.js';
|
||||
import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
|
||||
import {
|
||||
showToast,
|
||||
lazyLoadImages,
|
||||
restoreFolderFilter,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
toggleFolder,
|
||||
copyTriggerWord,
|
||||
openCivitai,
|
||||
toggleFolderTags,
|
||||
initFolderTagsVisibility,
|
||||
initBackToTop
|
||||
} from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { showDeleteModal, confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
|
||||
import { SearchManager } from './utils/search.js';
|
||||
import { DownloadManager } from './managers/DownloadManager.js';
|
||||
import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
||||
import { LoraContextMenu } from './components/ContextMenu.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
|
||||
// Export all functions that need global access
|
||||
window.loadMoreLoras = loadMoreLoras;
|
||||
window.fetchCivitai = fetchCivitai;
|
||||
window.deleteModel = deleteModel;
|
||||
window.replacePreview = replacePreview;
|
||||
window.toggleTheme = toggleTheme;
|
||||
window.toggleFolder = toggleFolder;
|
||||
window.copyTriggerWord = copyTriggerWord;
|
||||
window.showLoraModal = showLoraModal;
|
||||
window.modalManager = modalManager;
|
||||
window.state = state;
|
||||
window.confirmDelete = confirmDelete;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
window.refreshLoras = refreshLoras;
|
||||
window.openCivitai = openCivitai;
|
||||
window.showToast = showToast
|
||||
window.toggleFolderTags = toggleFolderTags;
|
||||
window.settingsManager = new SettingsManager();
|
||||
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
||||
window.moveManager = moveManager;
|
||||
window.toggleShowcase = toggleShowcase;
|
||||
window.scrollToTop = scrollToTop;
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
state.loadingManager = new LoadingManager();
|
||||
modalManager.initialize(); // Initialize modalManager after DOM is loaded
|
||||
window.downloadManager = new DownloadManager(); // Move this after modalManager initialization
|
||||
initializeInfiniteScroll();
|
||||
initializeEventListeners();
|
||||
lazyLoadImages();
|
||||
restoreFolderFilter();
|
||||
initTheme();
|
||||
initFolderTagsVisibility();
|
||||
initBackToTop();
|
||||
window.searchManager = new SearchManager();
|
||||
new LoraContextMenu();
|
||||
});
|
||||
|
||||
// Initialize event listeners
|
||||
function initializeEventListeners() {
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = state.sortBy;
|
||||
sortSelect.addEventListener('change', async (e) => {
|
||||
state.sortBy = e.target.value;
|
||||
await resetAndReload();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
tag.addEventListener('click', toggleFolder);
|
||||
});
|
||||
}
|
||||
352
static/js/managers/BulkManager.js
Normal file
352
static/js/managers/BulkManager.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import { state } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { updateCardsForBulkMode } from '../components/LoraCard.js';
|
||||
|
||||
export class BulkManager {
|
||||
constructor() {
|
||||
this.bulkBtn = document.getElementById('bulkOperationsBtn');
|
||||
this.bulkPanel = document.getElementById('bulkOperationsPanel');
|
||||
this.isStripVisible = false; // Track strip visibility state
|
||||
|
||||
// Initialize selected loras set in state if not already there
|
||||
if (!state.selectedLoras) {
|
||||
state.selectedLoras = new Set();
|
||||
}
|
||||
|
||||
// Cache for lora metadata to handle non-visible selected loras
|
||||
if (!state.loraMetadataCache) {
|
||||
state.loraMetadataCache = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Add event listeners if needed
|
||||
// (Already handled via onclick attributes in HTML, but could be moved here)
|
||||
|
||||
// Add event listeners for the selected count to toggle thumbnail strip
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
if (selectedCount) {
|
||||
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
|
||||
}
|
||||
}
|
||||
|
||||
toggleBulkMode() {
|
||||
// Toggle the state
|
||||
state.bulkMode = !state.bulkMode;
|
||||
|
||||
// Update UI
|
||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||
|
||||
// Important: Remove the hidden class when entering bulk mode
|
||||
if (state.bulkMode) {
|
||||
this.bulkPanel.classList.remove('hidden');
|
||||
// Use setTimeout to ensure the DOM updates before adding visible class
|
||||
// This helps with the transition animation
|
||||
setTimeout(() => {
|
||||
this.bulkPanel.classList.add('visible');
|
||||
}, 10);
|
||||
} else {
|
||||
this.bulkPanel.classList.remove('visible');
|
||||
// Add hidden class back after transition completes
|
||||
setTimeout(() => {
|
||||
this.bulkPanel.classList.add('hidden');
|
||||
}, 400); // Match this with the transition duration in CSS
|
||||
|
||||
// Hide thumbnail strip if it's visible
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
|
||||
// First update all cards' visual state before clearing selection
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
// Clear selection if exiting bulk mode - do this after updating cards
|
||||
if (!state.bulkMode) {
|
||||
this.clearSelection();
|
||||
|
||||
// Force a lightweight refresh of the cards to ensure proper display
|
||||
// This is less disruptive than a full resetAndReload()
|
||||
document.querySelectorAll('.lora-card').forEach(card => {
|
||||
// Re-apply normal display mode to all card actions
|
||||
const actions = card.querySelectorAll('.card-actions, .card-button');
|
||||
actions.forEach(action => action.style.display = 'flex');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
document.querySelectorAll('.lora-card.selected').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
state.selectedLoras.clear();
|
||||
this.updateSelectedCount();
|
||||
|
||||
// Hide thumbnail strip if it's visible
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
|
||||
updateSelectedCount() {
|
||||
const countElement = document.getElementById('selectedCount');
|
||||
|
||||
if (countElement) {
|
||||
// Set text content without the icon
|
||||
countElement.textContent = `${state.selectedLoras.size} selected `;
|
||||
|
||||
// Update caret icon if it exists
|
||||
const existingCaret = countElement.querySelector('.dropdown-caret');
|
||||
if (existingCaret) {
|
||||
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||
existingCaret.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
||||
} else {
|
||||
// Create new caret icon if it doesn't exist
|
||||
const caretIcon = document.createElement('i');
|
||||
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
||||
countElement.appendChild(caretIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleCardSelection(card) {
|
||||
const filepath = card.dataset.filepath;
|
||||
|
||||
if (card.classList.contains('selected')) {
|
||||
card.classList.remove('selected');
|
||||
state.selectedLoras.delete(filepath);
|
||||
} else {
|
||||
card.classList.add('selected');
|
||||
state.selectedLoras.add(filepath);
|
||||
|
||||
// Cache the metadata for this lora
|
||||
state.loraMetadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
previewUrl: this.getCardPreviewUrl(card),
|
||||
isVideo: this.isCardPreviewVideo(card),
|
||||
modelName: card.dataset.name
|
||||
});
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
|
||||
// Update thumbnail strip if it's visible
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to get preview URL from a card
|
||||
getCardPreviewUrl(card) {
|
||||
const img = card.querySelector('img');
|
||||
const video = card.querySelector('video source');
|
||||
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
|
||||
}
|
||||
|
||||
// Helper method to check if preview is a video
|
||||
isCardPreviewVideo(card) {
|
||||
return card.querySelector('video') !== null;
|
||||
}
|
||||
|
||||
// Apply selection state to cards after they are refreshed
|
||||
applySelectionState() {
|
||||
if (!state.bulkMode) return;
|
||||
|
||||
document.querySelectorAll('.lora-card').forEach(card => {
|
||||
const filepath = card.dataset.filepath;
|
||||
if (state.selectedLoras.has(filepath)) {
|
||||
card.classList.add('selected');
|
||||
|
||||
// Update the cache with latest data
|
||||
state.loraMetadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
previewUrl: this.getCardPreviewUrl(card),
|
||||
isVideo: this.isCardPreviewVideo(card),
|
||||
modelName: card.dataset.name
|
||||
});
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
async copyAllLorasSyntax() {
|
||||
if (state.selectedLoras.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const loraSyntaxes = [];
|
||||
const missingLoras = [];
|
||||
|
||||
// Process all selected loras using our metadata cache
|
||||
for (const filepath of state.selectedLoras) {
|
||||
const metadata = state.loraMetadataCache.get(filepath);
|
||||
|
||||
if (metadata) {
|
||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||
} else {
|
||||
// If we don't have metadata, this is an error case
|
||||
missingLoras.push(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any loras with missing metadata
|
||||
if (missingLoras.length > 0) {
|
||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||
}
|
||||
|
||||
if (loraSyntaxes.length === 0) {
|
||||
showToast('No valid LoRAs to copy', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(loraSyntaxes.join(', '));
|
||||
showToast(`Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Create and show the thumbnail strip of selected LoRAs
|
||||
toggleThumbnailStrip() {
|
||||
// If no items are selected, do nothing
|
||||
if (state.selectedLoras.size === 0) return;
|
||||
|
||||
const existing = document.querySelector('.selected-thumbnails-strip');
|
||||
if (existing) {
|
||||
this.hideThumbnailStrip();
|
||||
} else {
|
||||
this.showThumbnailStrip();
|
||||
}
|
||||
}
|
||||
|
||||
showThumbnailStrip() {
|
||||
// Create the thumbnail strip container
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'selected-thumbnails-strip';
|
||||
|
||||
// Create a container for the thumbnails (for scrolling)
|
||||
const thumbnailContainer = document.createElement('div');
|
||||
thumbnailContainer.className = 'thumbnails-container';
|
||||
strip.appendChild(thumbnailContainer);
|
||||
|
||||
// Position the strip above the bulk operations panel
|
||||
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
|
||||
|
||||
// Populate the thumbnails
|
||||
this.updateThumbnailStrip();
|
||||
|
||||
// Update strip visibility state and caret direction
|
||||
this.isStripVisible = true;
|
||||
this.updateSelectedCount(); // Update caret
|
||||
|
||||
// Add animation class after a short delay to trigger transition
|
||||
setTimeout(() => strip.classList.add('visible'), 10);
|
||||
}
|
||||
|
||||
hideThumbnailStrip() {
|
||||
const strip = document.querySelector('.selected-thumbnails-strip');
|
||||
if (strip && this.isStripVisible) { // Only hide if actually visible
|
||||
strip.classList.remove('visible');
|
||||
|
||||
// Update strip visibility state
|
||||
this.isStripVisible = false;
|
||||
|
||||
// Update caret without triggering another hide
|
||||
const countElement = document.getElementById('selectedCount');
|
||||
if (countElement) {
|
||||
const caret = countElement.querySelector('.dropdown-caret');
|
||||
if (caret) {
|
||||
caret.className = 'fas fa-caret-up dropdown-caret';
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for animation to complete before removing
|
||||
setTimeout(() => {
|
||||
if (strip.parentNode) {
|
||||
strip.parentNode.removeChild(strip);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
updateThumbnailStrip() {
|
||||
const container = document.querySelector('.thumbnails-container');
|
||||
if (!container) return;
|
||||
|
||||
// Clear existing thumbnails
|
||||
container.innerHTML = '';
|
||||
|
||||
// Add a thumbnail for each selected LoRA
|
||||
for (const filepath of state.selectedLoras) {
|
||||
const metadata = state.loraMetadataCache.get(filepath);
|
||||
if (!metadata) continue;
|
||||
|
||||
const thumbnail = document.createElement('div');
|
||||
thumbnail.className = 'selected-thumbnail';
|
||||
thumbnail.dataset.filepath = filepath;
|
||||
|
||||
// Create the visual element (image or video)
|
||||
if (metadata.isVideo) {
|
||||
thumbnail.innerHTML = `
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="${metadata.previewUrl}" type="video/mp4">
|
||||
</video>
|
||||
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
|
||||
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
} else {
|
||||
thumbnail.innerHTML = `
|
||||
<img src="${metadata.previewUrl}" alt="${metadata.modelName}">
|
||||
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
|
||||
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add click handler for deselection
|
||||
thumbnail.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.thumbnail-remove')) {
|
||||
this.deselectItem(filepath);
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handler for the remove button
|
||||
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.deselectItem(filepath);
|
||||
});
|
||||
|
||||
container.appendChild(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
deselectItem(filepath) {
|
||||
// Find and deselect the corresponding card if it's in the DOM
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filepath}"]`);
|
||||
if (card) {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
|
||||
// Remove from the selection set
|
||||
state.selectedLoras.delete(filepath);
|
||||
|
||||
// Update UI
|
||||
this.updateSelectedCount();
|
||||
this.updateThumbnailStrip();
|
||||
|
||||
// Hide the strip if no more selections
|
||||
if (state.selectedLoras.size === 0) {
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const bulkManager = new BulkManager();
|
||||
150
static/js/managers/CheckpointSearchManager.js
Normal file
150
static/js/managers/CheckpointSearchManager.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* CheckpointSearchManager - Specialized search manager for the Checkpoints page
|
||||
* Extends the base SearchManager with checkpoint-specific functionality
|
||||
*/
|
||||
import { SearchManager } from './SearchManager.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
export class CheckpointSearchManager extends SearchManager {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
page: 'checkpoints',
|
||||
...options
|
||||
});
|
||||
|
||||
this.currentSearchTerm = '';
|
||||
|
||||
// Store this instance in the state
|
||||
if (state) {
|
||||
state.searchManager = this;
|
||||
}
|
||||
}
|
||||
|
||||
async performSearch() {
|
||||
const searchTerm = this.searchInput.value.trim().toLowerCase();
|
||||
|
||||
if (searchTerm === this.currentSearchTerm && !this.isSearching) {
|
||||
return; // Avoid duplicate searches
|
||||
}
|
||||
|
||||
this.currentSearchTerm = searchTerm;
|
||||
|
||||
const grid = document.getElementById('checkpointGrid');
|
||||
|
||||
if (!searchTerm) {
|
||||
if (state) {
|
||||
state.currentPage = 1;
|
||||
}
|
||||
this.resetAndReloadCheckpoints();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isSearching = true;
|
||||
if (state && state.loadingManager) {
|
||||
state.loadingManager.showSimpleLoading('Searching checkpoints...');
|
||||
}
|
||||
|
||||
// Store current scroll position
|
||||
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
if (state) {
|
||||
state.currentPage = 1;
|
||||
state.hasMore = true;
|
||||
}
|
||||
|
||||
const url = new URL('/api/checkpoints', window.location.origin);
|
||||
url.searchParams.set('page', '1');
|
||||
url.searchParams.set('page_size', '20');
|
||||
url.searchParams.set('sort_by', state ? state.sortBy : 'name');
|
||||
url.searchParams.set('search', searchTerm);
|
||||
url.searchParams.set('fuzzy', 'true');
|
||||
|
||||
// Add search options
|
||||
const searchOptions = this.getActiveSearchOptions();
|
||||
url.searchParams.set('search_filename', searchOptions.filename.toString());
|
||||
url.searchParams.set('search_modelname', searchOptions.modelname.toString());
|
||||
|
||||
// Always send folder parameter if there is an active folder
|
||||
if (state && state.activeFolder) {
|
||||
url.searchParams.set('folder', state.activeFolder);
|
||||
// Add recursive parameter when recursive search is enabled
|
||||
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
|
||||
url.searchParams.set('recursive', recursive.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (searchTerm === this.currentSearchTerm && grid) {
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (data.items.length === 0) {
|
||||
grid.innerHTML = '<div class="no-results">No matching checkpoints found</div>';
|
||||
if (state) {
|
||||
state.hasMore = false;
|
||||
}
|
||||
} else {
|
||||
this.appendCheckpointCards(data.items);
|
||||
if (state) {
|
||||
state.hasMore = state.currentPage < data.total_pages;
|
||||
state.currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position after content is loaded
|
||||
setTimeout(() => {
|
||||
window.scrollTo({
|
||||
top: scrollPosition,
|
||||
behavior: 'instant' // Use 'instant' to prevent animation
|
||||
});
|
||||
}, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checkpoint search error:', error);
|
||||
showToast('Checkpoint search failed', 'error');
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
if (state && state.loadingManager) {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetAndReloadCheckpoints() {
|
||||
// This function would be implemented in the checkpoints page
|
||||
if (typeof window.loadCheckpoints === 'function') {
|
||||
window.loadCheckpoints();
|
||||
} else {
|
||||
// Fallback to reloading the page
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
appendCheckpointCards(checkpoints) {
|
||||
// This function would be implemented in the checkpoints page
|
||||
const grid = document.getElementById('checkpointGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (typeof window.appendCheckpointCards === 'function') {
|
||||
window.appendCheckpointCards(checkpoints);
|
||||
} else {
|
||||
// Fallback implementation
|
||||
checkpoints.forEach(checkpoint => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'checkpoint-card';
|
||||
card.innerHTML = `
|
||||
<h3>${checkpoint.name}</h3>
|
||||
<p>${checkpoint.filename || 'No filename'}</p>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
|
||||
import { LoadingManager } from './LoadingManager.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/loraApi.js';
|
||||
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
this.currentVersion = null;
|
||||
@@ -71,7 +71,6 @@ export class DownloadManager {
|
||||
const errorElement = document.getElementById('urlError');
|
||||
|
||||
try {
|
||||
// Show loading while fetching versions
|
||||
this.loadingManager.showSimpleLoading('Fetching model versions...');
|
||||
|
||||
const modelId = this.extractModelId(url);
|
||||
@@ -98,7 +97,6 @@ export class DownloadManager {
|
||||
} catch (error) {
|
||||
errorElement.textContent = error.message;
|
||||
} finally {
|
||||
// Hide loading when done
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
@@ -120,21 +118,56 @@ export class DownloadManager {
|
||||
|
||||
const versionList = document.getElementById('versionList');
|
||||
versionList.innerHTML = this.versions.map(version => {
|
||||
// Find first image (skip videos)
|
||||
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||
const fileSize = (version.files[0]?.sizeKB / 1024).toFixed(2); // Convert to MB
|
||||
|
||||
// Use version-level size or fallback to first file
|
||||
const fileSize = version.modelSizeKB ?
|
||||
(version.modelSizeKB / 1024).toFixed(2) :
|
||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
||||
|
||||
// Use version-level existsLocally flag
|
||||
const existsLocally = version.existsLocally;
|
||||
const localPath = version.localPath;
|
||||
|
||||
// Check if this is an early access version
|
||||
const isEarlyAccess = version.availability === 'EarlyAccess';
|
||||
|
||||
// Create early access badge if needed
|
||||
let earlyAccessBadge = '';
|
||||
if (isEarlyAccess) {
|
||||
earlyAccessBadge = `
|
||||
<div class="early-access-badge" title="Early access required">
|
||||
<i class="fas fa-clock"></i> Early Access
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(earlyAccessBadge);
|
||||
|
||||
// Status badge for local models
|
||||
const localStatus = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
<i class="fas fa-check"></i> In Library
|
||||
<div class="local-path">${localPath || ''}</div>
|
||||
</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}"
|
||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||
${existsLocally ? 'exists-locally' : ''}
|
||||
${isEarlyAccess ? 'is-early-access' : ''}"
|
||||
onclick="downloadManager.selectVersion('${version.id}')">
|
||||
<div class="version-thumbnail">
|
||||
<img src="${thumbnailUrl}" alt="Version preview">
|
||||
</div>
|
||||
<div class="version-content">
|
||||
<h3>${version.name}</h3>
|
||||
<div class="version-header">
|
||||
<h3>${version.name}</h3>
|
||||
${localStatus}
|
||||
</div>
|
||||
<div class="version-info">
|
||||
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
|
||||
${earlyAccessBadge}
|
||||
</div>
|
||||
<div class="version-meta">
|
||||
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
||||
@@ -144,15 +177,45 @@ export class DownloadManager {
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Update Next button state based on initial selection
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
selectVersion(versionId) {
|
||||
this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString());
|
||||
if (!this.currentVersion) return;
|
||||
|
||||
// Remove the toast notification - it's redundant with the visual indicator
|
||||
// const existsLocally = this.currentVersion.files[0]?.existsLocally;
|
||||
// if (existsLocally) {
|
||||
// showToast('This version already exists in your library', 'info');
|
||||
// }
|
||||
|
||||
document.querySelectorAll('.version-item').forEach(item => {
|
||||
item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name);
|
||||
});
|
||||
|
||||
// Update Next button state after selection
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
// Update this method to use version-level existsLocally
|
||||
updateNextButtonState() {
|
||||
const nextButton = document.querySelector('#versionStep .primary-btn');
|
||||
if (!nextButton) return;
|
||||
|
||||
const existsLocally = this.currentVersion?.existsLocally;
|
||||
|
||||
if (existsLocally) {
|
||||
nextButton.disabled = true;
|
||||
nextButton.classList.add('disabled');
|
||||
nextButton.textContent = 'Already in Library';
|
||||
} else {
|
||||
nextButton.disabled = false;
|
||||
nextButton.classList.remove('disabled');
|
||||
nextButton.textContent = 'Next';
|
||||
}
|
||||
}
|
||||
|
||||
async proceedToLocation() {
|
||||
@@ -160,6 +223,13 @@ export class DownloadManager {
|
||||
showToast('Please select a version', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-check if the version exists locally
|
||||
const existsLocally = this.currentVersion.existsLocally;
|
||||
if (existsLocally) {
|
||||
showToast('This version already exists in your library', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('versionStep').style.display = 'none';
|
||||
document.getElementById('locationStep').style.display = 'block';
|
||||
@@ -176,6 +246,12 @@ export class DownloadManager {
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
if (defaultRoot && data.roots.includes(defaultRoot)) {
|
||||
loraRoot.value = defaultRoot;
|
||||
}
|
||||
|
||||
// Initialize folder browser after loading roots
|
||||
this.initializeFolderBrowser();
|
||||
} catch (error) {
|
||||
@@ -218,18 +294,37 @@ export class DownloadManager {
|
||||
throw new Error('No download URL available');
|
||||
}
|
||||
|
||||
// Show loading with progress bar for download
|
||||
this.loadingManager.show('Downloading LoRA...', 0);
|
||||
// Show enhanced loading with progress details
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||
updateProgress(0, 0, this.currentVersion.name);
|
||||
|
||||
// Setup WebSocket for progress updates
|
||||
const ws = new WebSocket(`ws://${window.location.host}/ws/fetch-progress`);
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
this.loadingManager.setProgress(data.progress);
|
||||
this.loadingManager.setStatus(`Downloading: ${data.progress}%`);
|
||||
// Update progress display with current progress
|
||||
updateProgress(data.progress, 0, this.currentVersion.name);
|
||||
|
||||
// Add more detailed status messages based on progress
|
||||
if (data.progress < 3) {
|
||||
this.loadingManager.setStatus(`Preparing download...`);
|
||||
} else if (data.progress === 3) {
|
||||
this.loadingManager.setStatus(`Downloaded preview image`);
|
||||
} else if (data.progress > 3 && data.progress < 100) {
|
||||
this.loadingManager.setStatus(`Downloading LoRA file`);
|
||||
} else {
|
||||
this.loadingManager.setStatus(`Finalizing download...`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
// Continue with download even if WebSocket fails
|
||||
};
|
||||
|
||||
// Start download
|
||||
const response = await fetch('/api/download-lora', {
|
||||
|
||||
377
static/js/managers/FilterManager.js
Normal file
377
static/js/managers/FilterManager.js
Normal file
@@ -0,0 +1,377 @@
|
||||
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||
import { loadMoreLoras } from '../api/loraApi.js';
|
||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export class FilterManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
this.filters = pageState.filters || {
|
||||
baseModel: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
this.filterPanel = document.getElementById('filterPanel');
|
||||
this.filterButton = document.getElementById('filterButton');
|
||||
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||
this.tagsLoaded = false;
|
||||
|
||||
this.initialize();
|
||||
|
||||
// Store this instance in the state
|
||||
if (pageState) {
|
||||
pageState.filterManager = this;
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Create base model filter tags if they exist
|
||||
if (document.getElementById('baseModelTags')) {
|
||||
this.createBaseModelTags();
|
||||
}
|
||||
|
||||
// Add click handler for filter button
|
||||
if (this.filterButton) {
|
||||
this.filterButton.addEventListener('click', () => {
|
||||
this.toggleFilterPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Close filter panel when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
|
||||
e.target !== this.filterButton &&
|
||||
!this.filterButton.contains(e.target) &&
|
||||
!this.filterPanel.classList.contains('hidden')) {
|
||||
this.closeFilterPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize active filters from localStorage if available
|
||||
this.loadFiltersFromStorage();
|
||||
}
|
||||
|
||||
async loadTopTags() {
|
||||
try {
|
||||
// Show loading state
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||
|
||||
// Determine the API endpoint based on the page type
|
||||
let tagsEndpoint = '/api/loras/top-tags?limit=20';
|
||||
if (this.currentPage === 'recipes') {
|
||||
tagsEndpoint = '/api/recipes/top-tags?limit=20';
|
||||
}
|
||||
|
||||
const response = await fetch(tagsEndpoint);
|
||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.tags) {
|
||||
this.createTagFilterElements(data.tags);
|
||||
|
||||
// After creating tag elements, mark any previously selected ones
|
||||
this.updateTagSelections();
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading top tags:', error);
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
if (tagsContainer) {
|
||||
tagsContainer.innerHTML = '<div class="tags-error">Failed to load tags</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createTagFilterElements(tags) {
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
tagsContainer.innerHTML = '';
|
||||
|
||||
if (!tags.length) {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tags.forEach(tag => {
|
||||
const tagEl = document.createElement('div');
|
||||
tagEl.className = 'filter-tag tag-filter';
|
||||
const tagName = tag.tag;
|
||||
tagEl.dataset.tag = tagName;
|
||||
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||
|
||||
// Add click handler to toggle selection and automatically apply
|
||||
tagEl.addEventListener('click', async () => {
|
||||
tagEl.classList.toggle('active');
|
||||
|
||||
if (tagEl.classList.contains('active')) {
|
||||
if (!this.filters.tags.includes(tagName)) {
|
||||
this.filters.tags.push(tagName);
|
||||
}
|
||||
} else {
|
||||
this.filters.tags = this.filters.tags.filter(t => t !== tagName);
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
// Auto-apply filter when tag is clicked
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(tagEl);
|
||||
});
|
||||
}
|
||||
|
||||
createBaseModelTags() {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
if (!baseModelTagsContainer) return;
|
||||
|
||||
// Set the appropriate API endpoint based on current page
|
||||
let apiEndpoint = '';
|
||||
if (this.currentPage === 'loras') {
|
||||
apiEndpoint = '/api/loras/base-models';
|
||||
} else if (this.currentPage === 'recipes') {
|
||||
apiEndpoint = '/api/recipes/base-models';
|
||||
} else {
|
||||
return; // No API endpoint for other pages
|
||||
}
|
||||
|
||||
// Fetch base models
|
||||
fetch(apiEndpoint)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.base_models) {
|
||||
baseModelTagsContainer.innerHTML = '';
|
||||
|
||||
data.base_models.forEach(model => {
|
||||
const tag = document.createElement('div');
|
||||
// Add base model classes only for the loras page
|
||||
const baseModelClass = (this.currentPage === 'loras' && BASE_MODEL_CLASSES[model.name])
|
||||
? BASE_MODEL_CLASSES[model.name]
|
||||
: '';
|
||||
tag.className = `filter-tag base-model-tag ${baseModelClass}`;
|
||||
tag.dataset.baseModel = model.name;
|
||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||
|
||||
// Add click handler to toggle selection and automatically apply
|
||||
tag.addEventListener('click', async () => {
|
||||
tag.classList.toggle('active');
|
||||
|
||||
if (tag.classList.contains('active')) {
|
||||
if (!this.filters.baseModel.includes(model.name)) {
|
||||
this.filters.baseModel.push(model.name);
|
||||
}
|
||||
} else {
|
||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
// Auto-apply filter when tag is clicked
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
baseModelTagsContainer.appendChild(tag);
|
||||
});
|
||||
|
||||
// Update selections based on stored filters
|
||||
this.updateTagSelections();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
||||
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
||||
});
|
||||
}
|
||||
|
||||
toggleFilterPanel() {
|
||||
if (this.filterPanel) {
|
||||
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
// Update panel positions before showing
|
||||
updatePanelPositions();
|
||||
|
||||
this.filterPanel.classList.remove('hidden');
|
||||
this.filterButton.classList.add('active');
|
||||
|
||||
// Load tags if they haven't been loaded yet
|
||||
if (!this.tagsLoaded) {
|
||||
this.loadTopTags();
|
||||
this.tagsLoaded = true;
|
||||
}
|
||||
} else {
|
||||
this.closeFilterPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeFilterPanel() {
|
||||
if (this.filterPanel) {
|
||||
this.filterPanel.classList.add('hidden');
|
||||
}
|
||||
if (this.filterButton) {
|
||||
this.filterButton.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
updateTagSelections() {
|
||||
// Update base model tags
|
||||
const baseModelTags = document.querySelectorAll('.base-model-tag');
|
||||
baseModelTags.forEach(tag => {
|
||||
const baseModel = tag.dataset.baseModel;
|
||||
if (this.filters.baseModel.includes(baseModel)) {
|
||||
tag.classList.add('active');
|
||||
} else {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update model tags
|
||||
const modelTags = document.querySelectorAll('.tag-filter');
|
||||
modelTags.forEach(tag => {
|
||||
const tagName = tag.dataset.tag;
|
||||
if (this.filters.tags.includes(tagName)) {
|
||||
tag.classList.add('active');
|
||||
} else {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateActiveFiltersCount() {
|
||||
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
|
||||
|
||||
if (this.activeFiltersCount) {
|
||||
if (totalActiveFilters > 0) {
|
||||
this.activeFiltersCount.textContent = totalActiveFilters;
|
||||
this.activeFiltersCount.style.display = 'inline-flex';
|
||||
} else {
|
||||
this.activeFiltersCount.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyFilters(showToastNotification = true) {
|
||||
const pageState = getCurrentPageState();
|
||||
const storageKey = `${this.currentPage}_filters`;
|
||||
|
||||
// Save filters to localStorage
|
||||
setStorageItem(storageKey, this.filters);
|
||||
|
||||
// Update state with current filters
|
||||
pageState.filters = { ...this.filters };
|
||||
|
||||
// Call the appropriate manager's load method based on page type
|
||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||
await window.recipeManager.loadRecipes(true);
|
||||
} else if (this.currentPage === 'loras') {
|
||||
// For loras page, reset the page and reload
|
||||
await loadMoreLoras(true, true);
|
||||
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
|
||||
await window.checkpointManager.loadCheckpoints(true);
|
||||
}
|
||||
|
||||
// Update filter button to show active state
|
||||
if (this.hasActiveFilters()) {
|
||||
this.filterButton.classList.add('active');
|
||||
if (showToastNotification) {
|
||||
const baseModelCount = this.filters.baseModel.length;
|
||||
const tagsCount = this.filters.tags.length;
|
||||
|
||||
let message = '';
|
||||
if (baseModelCount > 0 && tagsCount > 0) {
|
||||
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||
} else if (baseModelCount > 0) {
|
||||
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''}`;
|
||||
} else if (tagsCount > 0) {
|
||||
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
showToast(message, 'success');
|
||||
}
|
||||
} else {
|
||||
this.filterButton.classList.remove('active');
|
||||
if (showToastNotification) {
|
||||
showToast('Filters cleared', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearFilters() {
|
||||
// Clear all filters
|
||||
this.filters = {
|
||||
baseModel: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
// Update state
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.filters = { ...this.filters };
|
||||
|
||||
// Update UI
|
||||
this.updateTagSelections();
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
// Remove from local Storage
|
||||
const storageKey = `${this.currentPage}_filters`;
|
||||
removeStorageItem(storageKey);
|
||||
|
||||
// Update UI
|
||||
this.filterButton.classList.remove('active');
|
||||
|
||||
// Reload data using the appropriate method for the current page
|
||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||
await window.recipeManager.loadRecipes(true);
|
||||
} else if (this.currentPage === 'loras') {
|
||||
await loadMoreLoras(true, true);
|
||||
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
|
||||
await window.checkpointManager.loadCheckpoints(true);
|
||||
}
|
||||
|
||||
showToast(`Filters cleared`, 'info');
|
||||
}
|
||||
|
||||
loadFiltersFromStorage() {
|
||||
const storageKey = `${this.currentPage}_filters`;
|
||||
const savedFilters = getStorageItem(storageKey);
|
||||
|
||||
if (savedFilters) {
|
||||
try {
|
||||
// Ensure backward compatibility with older filter format
|
||||
this.filters = {
|
||||
baseModel: savedFilters.baseModel || [],
|
||||
tags: savedFilters.tags || []
|
||||
};
|
||||
|
||||
// Update state with loaded filters
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.filters = { ...this.filters };
|
||||
|
||||
this.updateTagSelections();
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
if (this.hasActiveFilters()) {
|
||||
this.filterButton.classList.add('active');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${this.currentPage} filters from storage:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasActiveFilters() {
|
||||
return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
|
||||
}
|
||||
}
|
||||
1262
static/js/managers/ImportManager.js
Normal file
1262
static/js/managers/ImportManager.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,22 @@ export class LoadingManager {
|
||||
this.overlay = document.getElementById('loading-overlay');
|
||||
this.progressBar = this.overlay.querySelector('.progress-bar');
|
||||
this.statusText = this.overlay.querySelector('.loading-status');
|
||||
this.detailsContainer = null; // Will be created when needed
|
||||
}
|
||||
|
||||
show(message = 'Loading...', progress = 0) {
|
||||
this.overlay.style.display = 'flex';
|
||||
this.setProgress(progress);
|
||||
this.setStatus(message);
|
||||
|
||||
// Remove any existing details container
|
||||
this.removeDetailsContainer();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.overlay.style.display = 'none';
|
||||
this.reset();
|
||||
this.removeDetailsContainer();
|
||||
}
|
||||
|
||||
setProgress(percent) {
|
||||
@@ -29,6 +34,101 @@ export class LoadingManager {
|
||||
reset() {
|
||||
this.setProgress(0);
|
||||
this.setStatus('');
|
||||
this.removeDetailsContainer();
|
||||
}
|
||||
|
||||
// Create a details container for enhanced progress display
|
||||
createDetailsContainer() {
|
||||
// Remove existing container if any
|
||||
this.removeDetailsContainer();
|
||||
|
||||
// Create new container
|
||||
this.detailsContainer = document.createElement('div');
|
||||
this.detailsContainer.className = 'progress-details-container';
|
||||
|
||||
// Insert after the main progress bar
|
||||
const loadingContent = this.overlay.querySelector('.loading-content');
|
||||
if (loadingContent) {
|
||||
loadingContent.appendChild(this.detailsContainer);
|
||||
}
|
||||
|
||||
return this.detailsContainer;
|
||||
}
|
||||
|
||||
// Remove details container
|
||||
removeDetailsContainer() {
|
||||
if (this.detailsContainer) {
|
||||
this.detailsContainer.remove();
|
||||
this.detailsContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Show enhanced progress for downloads
|
||||
showDownloadProgress(totalItems = 1) {
|
||||
this.show('Preparing download...', 0);
|
||||
|
||||
// Create details container
|
||||
const detailsContainer = this.createDetailsContainer();
|
||||
|
||||
// Create current item progress
|
||||
const currentItemContainer = document.createElement('div');
|
||||
currentItemContainer.className = 'current-item-progress';
|
||||
|
||||
const currentItemLabel = document.createElement('div');
|
||||
currentItemLabel.className = 'current-item-label';
|
||||
currentItemLabel.textContent = 'Current file:';
|
||||
|
||||
const currentItemBar = document.createElement('div');
|
||||
currentItemBar.className = 'current-item-bar-container';
|
||||
|
||||
const currentItemProgress = document.createElement('div');
|
||||
currentItemProgress.className = 'current-item-bar';
|
||||
currentItemProgress.style.width = '0%';
|
||||
|
||||
const currentItemPercent = document.createElement('span');
|
||||
currentItemPercent.className = 'current-item-percent';
|
||||
currentItemPercent.textContent = '0%';
|
||||
|
||||
currentItemBar.appendChild(currentItemProgress);
|
||||
currentItemContainer.appendChild(currentItemLabel);
|
||||
currentItemContainer.appendChild(currentItemBar);
|
||||
currentItemContainer.appendChild(currentItemPercent);
|
||||
|
||||
// Create overall progress elements if multiple items
|
||||
let overallLabel = null;
|
||||
if (totalItems > 1) {
|
||||
overallLabel = document.createElement('div');
|
||||
overallLabel.className = 'overall-progress-label';
|
||||
overallLabel.textContent = `Overall progress (0/${totalItems} complete):`;
|
||||
detailsContainer.appendChild(overallLabel);
|
||||
}
|
||||
|
||||
// Add current item progress to container
|
||||
detailsContainer.appendChild(currentItemContainer);
|
||||
|
||||
// Return update function
|
||||
return (currentProgress, currentIndex = 0, currentName = '') => {
|
||||
// Update current item progress
|
||||
currentItemProgress.style.width = `${currentProgress}%`;
|
||||
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
|
||||
|
||||
// Update current item label if name provided
|
||||
if (currentName) {
|
||||
currentItemLabel.textContent = `Downloading: ${currentName}`;
|
||||
}
|
||||
|
||||
// Update overall label if multiple items
|
||||
if (totalItems > 1 && overallLabel) {
|
||||
overallLabel.textContent = `Overall progress (${currentIndex}/${totalItems} complete):`;
|
||||
|
||||
// Calculate and update overall progress
|
||||
const overallProgress = Math.floor((currentIndex + currentProgress/100) / totalItems * 100);
|
||||
this.setProgress(overallProgress);
|
||||
} else {
|
||||
// Single item, just update main progress
|
||||
this.setProgress(currentProgress);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async showWithProgress(callback, options = {}) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export class ModalManager {
|
||||
constructor() {
|
||||
this.modals = new Map();
|
||||
this.scrollPosition = 0;
|
||||
this.currentOpenModal = null; // Track currently open modal
|
||||
}
|
||||
|
||||
initialize() {
|
||||
@@ -9,49 +10,120 @@ export class ModalManager {
|
||||
|
||||
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||
|
||||
// Register all modals
|
||||
this.registerModal('loraModal', {
|
||||
element: document.getElementById('loraModal'),
|
||||
onClose: () => {
|
||||
this.getModal('loraModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
// Register all modals - only if they exist in the current page
|
||||
const loraModal = document.getElementById('loraModal');
|
||||
if (loraModal) {
|
||||
this.registerModal('loraModal', {
|
||||
element: loraModal,
|
||||
onClose: () => {
|
||||
this.getModal('loraModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
this.registerModal('deleteModal', {
|
||||
element: document.getElementById('deleteModal'),
|
||||
onClose: () => {
|
||||
this.getModal('deleteModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
if (deleteModal) {
|
||||
this.registerModal('deleteModal', {
|
||||
element: deleteModal,
|
||||
onClose: () => {
|
||||
this.getModal('deleteModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add downloadModal registration
|
||||
this.registerModal('downloadModal', {
|
||||
element: document.getElementById('downloadModal'),
|
||||
onClose: () => {
|
||||
this.getModal('downloadModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
const downloadModal = document.getElementById('downloadModal');
|
||||
if (downloadModal) {
|
||||
this.registerModal('downloadModal', {
|
||||
element: downloadModal,
|
||||
onClose: () => {
|
||||
this.getModal('downloadModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add settingsModal registration
|
||||
this.registerModal('settingsModal', {
|
||||
element: document.getElementById('settingsModal'),
|
||||
onClose: () => {
|
||||
this.getModal('settingsModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal) {
|
||||
this.registerModal('settingsModal', {
|
||||
element: settingsModal,
|
||||
onClose: () => {
|
||||
this.getModal('settingsModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add moveModal registration
|
||||
this.registerModal('moveModal', {
|
||||
element: document.getElementById('moveModal'),
|
||||
onClose: () => {
|
||||
this.getModal('moveModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
const moveModal = document.getElementById('moveModal');
|
||||
if (moveModal) {
|
||||
this.registerModal('moveModal', {
|
||||
element: moveModal,
|
||||
onClose: () => {
|
||||
this.getModal('moveModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add supportModal registration
|
||||
const supportModal = document.getElementById('supportModal');
|
||||
if (supportModal) {
|
||||
this.registerModal('supportModal', {
|
||||
element: supportModal,
|
||||
onClose: () => {
|
||||
this.getModal('supportModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add updateModal registration
|
||||
const updateModal = document.getElementById('updateModal');
|
||||
if (updateModal) {
|
||||
this.registerModal('updateModal', {
|
||||
element: updateModal,
|
||||
onClose: () => {
|
||||
this.getModal('updateModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add importModal registration
|
||||
const importModal = document.getElementById('importModal');
|
||||
if (importModal) {
|
||||
this.registerModal('importModal', {
|
||||
element: importModal,
|
||||
onClose: () => {
|
||||
this.getModal('importModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add recipeModal registration
|
||||
const recipeModal = document.getElementById('recipeModal');
|
||||
if (recipeModal) {
|
||||
this.registerModal('recipeModal', {
|
||||
element: recipeModal,
|
||||
onClose: () => {
|
||||
this.getModal('recipeModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for modal toggles
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
supportToggle.addEventListener('click', () => this.toggleModal('supportModal'));
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
@@ -64,8 +136,8 @@ export class ModalManager {
|
||||
isOpen: false
|
||||
});
|
||||
|
||||
// Only add click outside handler if it's the lora modal
|
||||
if (id == 'loraModal') {
|
||||
// Add click outside handler if specified in config
|
||||
if (config.closeOnOutsideClick) {
|
||||
config.element.addEventListener('click', (e) => {
|
||||
if (e.target === config.element) {
|
||||
this.closeModal(id);
|
||||
@@ -78,10 +150,26 @@ export class ModalManager {
|
||||
return this.modals.get(id);
|
||||
}
|
||||
|
||||
// Check if any modal is currently open
|
||||
isAnyModalOpen() {
|
||||
for (const [id, modal] of this.modals) {
|
||||
if (modal.isOpen) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
showModal(id, content = null, onCloseCallback = null) {
|
||||
const modal = this.getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
// Close any open modal before showing the new one
|
||||
const openModalId = this.isAnyModalOpen();
|
||||
if (openModalId && openModalId !== id) {
|
||||
this.closeModal(openModalId);
|
||||
}
|
||||
|
||||
if (content) {
|
||||
modal.element.innerHTML = content;
|
||||
}
|
||||
@@ -101,6 +189,7 @@ export class ModalManager {
|
||||
}
|
||||
|
||||
modal.isOpen = true;
|
||||
this.currentOpenModal = id; // Update currently open modal
|
||||
document.body.style.top = `-${this.scrollPosition}px`;
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
@@ -112,6 +201,11 @@ export class ModalManager {
|
||||
modal.onClose();
|
||||
modal.isOpen = false;
|
||||
|
||||
// Clear current open modal if this is the one being closed
|
||||
if (this.currentOpenModal === id) {
|
||||
this.currentOpenModal = null;
|
||||
}
|
||||
|
||||
// Remove fixed positioning and restore scroll position
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.top = '';
|
||||
@@ -126,15 +220,26 @@ export class ModalManager {
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
// Close the last opened modal
|
||||
for (const [id, modal] of this.modals) {
|
||||
if (modal.isOpen) {
|
||||
this.closeModal(id);
|
||||
break;
|
||||
}
|
||||
// Close the current open modal if it exists
|
||||
if (this.currentOpenModal) {
|
||||
this.closeModal(this.currentOpenModal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleModal(id, content = null, onCloseCallback = null) {
|
||||
const modal = this.getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
// If this modal is already open, close it
|
||||
if (modal.isOpen) {
|
||||
this.closeModal(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, show the modal
|
||||
this.showModal(id, content, onCloseCallback);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/loraApi.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
class MoveManager {
|
||||
constructor() {
|
||||
this.currentFilePath = null;
|
||||
this.bulkFilePaths = null;
|
||||
this.modal = document.getElementById('moveModal');
|
||||
this.loraRootSelect = document.getElementById('moveLoraRoot');
|
||||
this.folderBrowser = document.getElementById('moveFolderBrowser');
|
||||
this.newFolderInput = document.getElementById('moveNewFolder');
|
||||
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||
this.modalTitle = document.getElementById('moveModalTitle');
|
||||
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
@@ -43,7 +47,24 @@ class MoveManager {
|
||||
}
|
||||
|
||||
async showMoveModal(filePath) {
|
||||
this.currentFilePath = filePath;
|
||||
// Reset state
|
||||
this.currentFilePath = null;
|
||||
this.bulkFilePaths = null;
|
||||
|
||||
// Handle bulk mode
|
||||
if (filePath === 'bulk') {
|
||||
const selectedPaths = Array.from(state.selectedLoras);
|
||||
if (selectedPaths.length === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
return;
|
||||
}
|
||||
this.bulkFilePaths = selectedPaths;
|
||||
this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
|
||||
} else {
|
||||
// Single file mode
|
||||
this.currentFilePath = filePath;
|
||||
this.modalTitle.textContent = "Move Model";
|
||||
}
|
||||
|
||||
// 清除之前的选择
|
||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
||||
@@ -67,6 +88,12 @@ class MoveManager {
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
if (defaultRoot && data.roots.includes(defaultRoot)) {
|
||||
this.loraRootSelect.value = defaultRoot;
|
||||
}
|
||||
|
||||
this.updatePathPreview();
|
||||
modalManager.showModal('moveModal');
|
||||
|
||||
@@ -105,35 +132,122 @@ class MoveManager {
|
||||
targetPath = `${targetPath}/${newFolder}`;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.bulkFilePaths) {
|
||||
// Bulk move mode
|
||||
await this.moveBulkModels(this.bulkFilePaths, targetPath);
|
||||
} else {
|
||||
// Single move mode
|
||||
await this.moveSingleModel(this.currentFilePath, targetPath);
|
||||
}
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
await resetAndReload(true);
|
||||
|
||||
// If we were in bulk mode, exit it after successful move
|
||||
if (this.bulkFilePaths && state.bulkMode) {
|
||||
toggleBulkMode();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error moving model(s):', error);
|
||||
showToast('Failed to move model(s): ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async moveSingleModel(filePath, targetPath) {
|
||||
// show toast if current path is same as target path
|
||||
if (this.currentFilePath.substring(0, this.currentFilePath.lastIndexOf('/')) === targetPath) {
|
||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
||||
showToast('Model is already in the selected folder', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/move_model', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: this.currentFilePath,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
const response = await fetch('/api/move_model', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to move model');
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
throw new Error('Failed to move model');
|
||||
}
|
||||
|
||||
if (result && result.message) {
|
||||
showToast(result.message, 'info');
|
||||
} else {
|
||||
showToast('Model moved successfully', 'success');
|
||||
modalManager.closeModal('moveModal');
|
||||
await resetAndReload(true);
|
||||
}
|
||||
}
|
||||
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
// Filter out models already in the target path
|
||||
const movedPaths = filePaths.filter(path => {
|
||||
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
||||
});
|
||||
|
||||
if (movedPaths.length === 0) {
|
||||
showToast('All selected models are already in the target folder', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error moving model:', error);
|
||||
showToast('Failed to move model: ' + error.message, 'error');
|
||||
const response = await fetch('/api/move_models_bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_paths: movedPaths,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to move models');
|
||||
}
|
||||
|
||||
// Display results with more details
|
||||
if (result.success) {
|
||||
if (result.failure_count > 0) {
|
||||
// Some files failed to move
|
||||
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
|
||||
|
||||
// Log details about failures
|
||||
console.log('Move operation results:', result.results);
|
||||
|
||||
// Get list of failed files with reasons
|
||||
const failedFiles = result.results
|
||||
.filter(r => !r.success)
|
||||
.map(r => {
|
||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||
return `${fileName}: ${r.message}`;
|
||||
});
|
||||
|
||||
// Show first few failures in a toast
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage = failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||
|
||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
// All files moved successfully
|
||||
showToast(`Successfully moved ${result.success_count} models`, 'success');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to move models');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
324
static/js/managers/SearchManager.js
Normal file
324
static/js/managers/SearchManager.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
||||
import { getCurrentPageState } from "../state/index.js";
|
||||
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
|
||||
/**
|
||||
* SearchManager - Handles search functionality across different pages
|
||||
* Each page can extend or customize this base functionality
|
||||
*/
|
||||
export class SearchManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
searchDelay: 300,
|
||||
minSearchLength: 2,
|
||||
...options
|
||||
};
|
||||
|
||||
this.searchInput = document.getElementById('searchInput');
|
||||
this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
|
||||
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
|
||||
this.closeSearchOptions = document.getElementById('closeSearchOptions');
|
||||
this.searchOptionTags = document.querySelectorAll('.search-option-tag');
|
||||
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
|
||||
|
||||
this.searchTimeout = null;
|
||||
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||
this.isSearching = false;
|
||||
|
||||
// Create clear button for search input
|
||||
this.createClearButton();
|
||||
|
||||
this.initEventListeners();
|
||||
this.loadSearchPreferences();
|
||||
|
||||
updatePanelPositions();
|
||||
|
||||
// Add resize listener
|
||||
window.addEventListener('resize', updatePanelPositions);
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// Search input event
|
||||
if (this.searchInput) {
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => this.performSearch(), this.options.searchDelay);
|
||||
this.updateClearButtonVisibility();
|
||||
});
|
||||
|
||||
// Clear search with Escape key
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.searchInput.value = '';
|
||||
this.updateClearButtonVisibility();
|
||||
this.performSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search options toggle
|
||||
if (this.searchOptionsToggle) {
|
||||
this.searchOptionsToggle.addEventListener('click', () => {
|
||||
this.toggleSearchOptionsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Close search options
|
||||
if (this.closeSearchOptions) {
|
||||
this.closeSearchOptions.addEventListener('click', () => {
|
||||
this.closeSearchOptionsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Search option tags
|
||||
if (this.searchOptionTags) {
|
||||
this.searchOptionTags.forEach(tag => {
|
||||
tag.addEventListener('click', () => {
|
||||
// Check if clicking would deselect the last active option
|
||||
const activeOptions = document.querySelectorAll('.search-option-tag.active');
|
||||
if (activeOptions.length === 1 && activeOptions[0] === tag) {
|
||||
// Don't allow deselecting the last option
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('At least one search option must be selected', 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tag.classList.toggle('active');
|
||||
this.saveSearchPreferences();
|
||||
this.performSearch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Recursive search toggle
|
||||
if (this.recursiveSearchToggle) {
|
||||
this.recursiveSearchToggle.addEventListener('change', () => {
|
||||
this.saveSearchPreferences();
|
||||
this.performSearch();
|
||||
});
|
||||
}
|
||||
|
||||
// Add global click handler to close panels when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
// Close search options panel when clicking outside
|
||||
if (this.searchOptionsPanel &&
|
||||
!this.searchOptionsPanel.contains(e.target) &&
|
||||
e.target !== this.searchOptionsToggle &&
|
||||
!this.searchOptionsToggle.contains(e.target)) {
|
||||
this.closeSearchOptionsPanel();
|
||||
}
|
||||
|
||||
// Close filter panel when clicking outside (if filterManager exists)
|
||||
const filterPanel = document.getElementById('filterPanel');
|
||||
const filterButton = document.getElementById('filterButton');
|
||||
if (filterPanel &&
|
||||
!filterPanel.contains(e.target) &&
|
||||
e.target !== filterButton &&
|
||||
!filterButton.contains(e.target) &&
|
||||
window.filterManager) {
|
||||
window.filterManager.closeFilterPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createClearButton() {
|
||||
// Create clear button if it doesn't exist
|
||||
if (!this.searchInput) return;
|
||||
|
||||
// Check if clear button already exists
|
||||
let clearButton = this.searchInput.parentNode.querySelector('.search-clear');
|
||||
|
||||
if (!clearButton) {
|
||||
// Create clear button
|
||||
clearButton = document.createElement('button');
|
||||
clearButton.className = 'search-clear';
|
||||
clearButton.innerHTML = '<i class="fas fa-times"></i>';
|
||||
clearButton.title = 'Clear search';
|
||||
|
||||
// Add click handler
|
||||
clearButton.addEventListener('click', () => {
|
||||
this.searchInput.value = '';
|
||||
this.updateClearButtonVisibility();
|
||||
this.performSearch();
|
||||
});
|
||||
|
||||
// Insert after search input
|
||||
this.searchInput.parentNode.appendChild(clearButton);
|
||||
}
|
||||
|
||||
this.clearButton = clearButton;
|
||||
|
||||
// Set initial visibility
|
||||
this.updateClearButtonVisibility();
|
||||
}
|
||||
|
||||
updateClearButtonVisibility() {
|
||||
if (this.clearButton) {
|
||||
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSearchOptionsPanel() {
|
||||
if (this.searchOptionsPanel) {
|
||||
const isHidden = this.searchOptionsPanel.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
// Update position before showing
|
||||
updatePanelPositions();
|
||||
this.searchOptionsPanel.classList.remove('hidden');
|
||||
this.searchOptionsToggle.classList.add('active');
|
||||
|
||||
// Ensure the panel is visible
|
||||
this.searchOptionsPanel.style.display = 'block';
|
||||
} else {
|
||||
this.closeSearchOptionsPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeSearchOptionsPanel() {
|
||||
if (this.searchOptionsPanel) {
|
||||
this.searchOptionsPanel.classList.add('hidden');
|
||||
this.searchOptionsToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
loadSearchPreferences() {
|
||||
try {
|
||||
const preferences = getStorageItem(`${this.currentPage}_search_prefs`) || {};
|
||||
|
||||
// Apply search options
|
||||
if (preferences.options) {
|
||||
this.searchOptionTags.forEach(tag => {
|
||||
const option = tag.dataset.option;
|
||||
if (preferences.options[option] !== undefined) {
|
||||
tag.classList.toggle('active', preferences.options[option]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply recursive search - only if the toggle exists
|
||||
if (this.recursiveSearchToggle && preferences.recursive !== undefined) {
|
||||
this.recursiveSearchToggle.checked = preferences.recursive;
|
||||
}
|
||||
|
||||
// Ensure at least one search option is selected
|
||||
this.validateSearchOptions();
|
||||
} catch (error) {
|
||||
console.error('Error loading search preferences:', error);
|
||||
// Set default options if loading fails
|
||||
this.setDefaultSearchOptions();
|
||||
}
|
||||
}
|
||||
|
||||
validateSearchOptions() {
|
||||
// Check if at least one search option is active
|
||||
const hasActiveOption = Array.from(this.searchOptionTags).some(tag =>
|
||||
tag.classList.contains('active')
|
||||
);
|
||||
|
||||
// If no search options are active, activate default options
|
||||
if (!hasActiveOption) {
|
||||
this.setDefaultSearchOptions();
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultSearchOptions() {
|
||||
// Default to filename search option if available
|
||||
const filenameOption = Array.from(this.searchOptionTags).find(tag =>
|
||||
tag.dataset.option === 'filename'
|
||||
);
|
||||
|
||||
if (filenameOption) {
|
||||
filenameOption.classList.add('active');
|
||||
} else if (this.searchOptionTags.length > 0) {
|
||||
// Otherwise, select the first option
|
||||
this.searchOptionTags[0].classList.add('active');
|
||||
}
|
||||
|
||||
// Save the default preferences
|
||||
this.saveSearchPreferences();
|
||||
}
|
||||
|
||||
saveSearchPreferences() {
|
||||
try {
|
||||
const options = {};
|
||||
this.searchOptionTags.forEach(tag => {
|
||||
options[tag.dataset.option] = tag.classList.contains('active');
|
||||
});
|
||||
|
||||
const preferences = {
|
||||
options
|
||||
};
|
||||
|
||||
// Only add recursive option if the toggle exists
|
||||
if (this.recursiveSearchToggle) {
|
||||
preferences.recursive = this.recursiveSearchToggle.checked;
|
||||
}
|
||||
|
||||
setStorageItem(`${this.currentPage}_search_prefs`, preferences);
|
||||
} catch (error) {
|
||||
console.error('Error saving search preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveSearchOptions() {
|
||||
const options = {};
|
||||
this.searchOptionTags.forEach(tag => {
|
||||
options[tag.dataset.option] = tag.classList.contains('active');
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
performSearch() {
|
||||
const query = this.searchInput.value.trim();
|
||||
const options = this.getActiveSearchOptions();
|
||||
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
|
||||
|
||||
// Update the state with search parameters
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
// Set search query in filters
|
||||
if (pageState && pageState.filters) {
|
||||
pageState.filters.search = query;
|
||||
}
|
||||
|
||||
// Update search options based on page type
|
||||
if (pageState && pageState.searchOptions) {
|
||||
if (this.currentPage === 'recipes') {
|
||||
pageState.searchOptions = {
|
||||
title: options.title || false,
|
||||
tags: options.tags || false,
|
||||
loraName: options.loraName || false,
|
||||
loraModel: options.loraModel || false
|
||||
};
|
||||
} else if (this.currentPage === 'loras') {
|
||||
pageState.searchOptions = {
|
||||
filename: options.filename || false,
|
||||
modelname: options.modelname || false,
|
||||
tags: options.tags || false,
|
||||
recursive: recursive
|
||||
};
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
pageState.searchOptions = {
|
||||
filename: options.filename || false,
|
||||
modelname: options.modelname || false,
|
||||
recursive: recursive
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Call the appropriate manager's load method based on page type
|
||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||
window.recipeManager.loadRecipes(true); // true to reset pagination
|
||||
} else if (this.currentPage === 'loras' && window.loadMoreLoras) {
|
||||
// Reset loras page and reload
|
||||
if (pageState) {
|
||||
pageState.currentPage = 1;
|
||||
pageState.hasMore = true;
|
||||
}
|
||||
window.loadMoreLoras(true); // true to reset pagination
|
||||
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
|
||||
window.checkpointManager.loadCheckpoints(true); // true to reset pagination
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,114 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/loraApi.js';
|
||||
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.isOpen = false;
|
||||
|
||||
// Add initialization to sync with modal state
|
||||
this.currentPage = document.body.dataset.page || 'loras';
|
||||
|
||||
// Ensure settings are loaded from localStorage
|
||||
this.loadSettingsFromStorage();
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
loadSettingsFromStorage() {
|
||||
// Get saved settings from localStorage
|
||||
const savedSettings = getStorageItem('settings');
|
||||
|
||||
// Apply saved settings to state if available
|
||||
if (savedSettings) {
|
||||
state.global.settings = { ...state.global.settings, ...savedSettings };
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Add event listener to sync state when modal is closed via other means (like Escape key)
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
this.isOpen = settingsModal.style.display === 'block';
|
||||
|
||||
// When modal is opened, update checkbox state from current settings
|
||||
if (this.isOpen) {
|
||||
this.loadSettingsToUI();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(settingsModal, { attributes: true });
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async loadSettingsToUI() {
|
||||
// Set frontend settings from state
|
||||
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
||||
if (blurMatureContentCheckbox) {
|
||||
blurMatureContentCheckbox.checked = state.global.settings.blurMatureContent;
|
||||
}
|
||||
|
||||
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
|
||||
if (showOnlySFWCheckbox) {
|
||||
// Sync with state (backend will set this via template)
|
||||
state.global.settings.show_only_sfw = showOnlySFWCheckbox.checked;
|
||||
}
|
||||
|
||||
// Load default lora root
|
||||
await this.loadLoraRoots();
|
||||
|
||||
// Backend settings are loaded from the template directly
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
try {
|
||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||
if (!defaultLoraRootSelect) return;
|
||||
|
||||
// Fetch lora roots
|
||||
const response = await fetch('/api/lora-roots');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch LoRA roots');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No LoRA roots found');
|
||||
}
|
||||
|
||||
// Clear existing options except the first one (No Default)
|
||||
const noDefaultOption = defaultLoraRootSelect.querySelector('option[value=""]');
|
||||
defaultLoraRootSelect.innerHTML = '';
|
||||
defaultLoraRootSelect.appendChild(noDefaultOption);
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
const option = document.createElement('option');
|
||||
option.value = root;
|
||||
option.textContent = root;
|
||||
defaultLoraRootSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_loras_root || '';
|
||||
defaultLoraRootSelect.value = defaultRoot;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading LoRA roots:', error);
|
||||
showToast('Failed to load LoRA roots: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings() {
|
||||
@@ -16,24 +120,33 @@ export class SettingsManager {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
/*
|
||||
showSettings() {
|
||||
console.log('Opening settings modal...'); // Debug log
|
||||
modalManager.showModal('settingsModal');
|
||||
}
|
||||
*/
|
||||
|
||||
async saveSettings() {
|
||||
// Get frontend settings from UI
|
||||
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
||||
|
||||
// Get backend settings
|
||||
const apiKey = document.getElementById('civitaiApiKey').value;
|
||||
|
||||
// Update frontend state and save to localStorage
|
||||
state.global.settings.blurMatureContent = blurMatureContent;
|
||||
state.global.settings.show_only_sfw = showOnlySFW;
|
||||
state.global.settings.default_loras_root = defaultLoraRoot;
|
||||
|
||||
// Save settings to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
try {
|
||||
// Save backend settings via API
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
civitai_api_key: apiKey
|
||||
civitai_api_key: apiKey,
|
||||
show_only_sfw: showOnlySFW
|
||||
})
|
||||
});
|
||||
|
||||
@@ -43,10 +156,39 @@ export class SettingsManager {
|
||||
|
||||
showToast('Settings saved successfully', 'success');
|
||||
modalManager.closeModal('settingsModal');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
if (this.currentPage === 'loras') {
|
||||
// Reload the loras without updating folders
|
||||
await resetAndReload(false);
|
||||
} else if (this.currentPage === 'recipes') {
|
||||
// Reload the recipes without updating folders
|
||||
await window.recipeManager.loadRecipes();
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
// Reload the checkpoints without updating folders
|
||||
await window.checkpointsManager.loadCheckpoints();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to save settings: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
applyFrontendSettings() {
|
||||
// Apply blur setting to existing content
|
||||
const blurSetting = state.global.settings.blurMatureContent;
|
||||
document.querySelectorAll('.lora-card[data-nsfw="true"] .card-image').forEach(img => {
|
||||
if (blurSetting) {
|
||||
img.classList.add('nsfw-blur');
|
||||
} else {
|
||||
img.classList.remove('nsfw-blur');
|
||||
}
|
||||
});
|
||||
|
||||
// For show_only_sfw, there's no immediate action needed as it affects content loading
|
||||
// The setting will take effect on next reload
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for toggling API key visibility
|
||||
|
||||
255
static/js/managers/UpdateService.js
Normal file
255
static/js/managers/UpdateService.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export class UpdateService {
|
||||
constructor() {
|
||||
this.updateCheckInterval = 24 * 60 * 60 * 1000; // 24 hours
|
||||
this.currentVersion = "v0.0.0"; // Initialize with default values
|
||||
this.latestVersion = "v0.0.0"; // Initialize with default values
|
||||
this.updateInfo = null;
|
||||
this.updateAvailable = false;
|
||||
this.updateNotificationsEnabled = getStorageItem('show_update_notifications');
|
||||
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Register event listener for update notification toggle
|
||||
const updateCheckbox = document.getElementById('updateNotifications');
|
||||
if (updateCheckbox) {
|
||||
updateCheckbox.checked = this.updateNotificationsEnabled;
|
||||
updateCheckbox.addEventListener('change', (e) => {
|
||||
this.updateNotificationsEnabled = e.target.checked;
|
||||
setStorageItem('show_update_notifications', e.target.checked);
|
||||
this.updateBadgeVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// Perform update check if needed
|
||||
this.checkForUpdates().then(() => {
|
||||
// Ensure badges are updated after checking
|
||||
this.updateBadgeVisibility();
|
||||
});
|
||||
|
||||
// Set up event listener for update button
|
||||
// const updateToggle = document.getElementById('updateToggleBtn');
|
||||
// if (updateToggle) {
|
||||
// updateToggle.addEventListener('click', () => this.toggleUpdateModal());
|
||||
// }
|
||||
|
||||
// Immediately update modal content with current values (even if from default)
|
||||
this.updateModalContent();
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
// Check if we should perform an update check
|
||||
const now = Date.now();
|
||||
const forceCheck = this.lastCheckTime === 0;
|
||||
|
||||
if (!forceCheck && now - this.lastCheckTime < this.updateCheckInterval) {
|
||||
// If we already have update info, just update the UI
|
||||
if (this.updateAvailable) {
|
||||
this.updateBadgeVisibility();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend API to check for updates
|
||||
const response = await fetch('/loras/api/check-updates');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.currentVersion = data.current_version || "v0.0.0";
|
||||
this.latestVersion = data.latest_version || "v0.0.0";
|
||||
this.updateInfo = data;
|
||||
|
||||
// Explicitly set update availability based on version comparison
|
||||
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion);
|
||||
|
||||
// Update last check time
|
||||
this.lastCheckTime = now;
|
||||
setStorageItem('last_update_check', now.toString());
|
||||
|
||||
// Update UI
|
||||
this.updateBadgeVisibility();
|
||||
this.updateModalContent();
|
||||
|
||||
console.log("Update check complete:", {
|
||||
currentVersion: this.currentVersion,
|
||||
latestVersion: this.latestVersion,
|
||||
updateAvailable: this.updateAvailable
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to compare version strings
|
||||
isNewerVersion(latestVersion, currentVersion) {
|
||||
if (!latestVersion || !currentVersion) return false;
|
||||
|
||||
// Remove 'v' prefix if present
|
||||
const latest = latestVersion.replace(/^v/, '');
|
||||
const current = currentVersion.replace(/^v/, '');
|
||||
|
||||
// Split version strings into components
|
||||
const latestParts = latest.split(/[-\.]/);
|
||||
const currentParts = current.split(/[-\.]/);
|
||||
|
||||
// Compare major, minor, patch versions
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const latestNum = parseInt(latestParts[i] || '0', 10);
|
||||
const currentNum = parseInt(currentParts[i] || '0', 10);
|
||||
|
||||
if (latestNum > currentNum) return true;
|
||||
if (latestNum < currentNum) return false;
|
||||
}
|
||||
|
||||
// If numeric versions are the same, check for beta/alpha status
|
||||
const latestIsBeta = latest.includes('beta') || latest.includes('alpha');
|
||||
const currentIsBeta = current.includes('beta') || current.includes('alpha');
|
||||
|
||||
// Release version is newer than beta/alpha
|
||||
if (!latestIsBeta && currentIsBeta) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
updateBadgeVisibility() {
|
||||
const updateToggle = document.querySelector('.update-toggle');
|
||||
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
||||
const cornerBadge = document.querySelector('.corner-badge');
|
||||
|
||||
if (updateToggle) {
|
||||
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
|
||||
? "Update Available"
|
||||
: "Check Updates";
|
||||
}
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
|
||||
if (updateBadge) {
|
||||
updateBadge.classList.toggle('hidden', !shouldShow);
|
||||
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
|
||||
}
|
||||
|
||||
if (cornerBadge) {
|
||||
cornerBadge.classList.toggle('hidden', !shouldShow);
|
||||
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
|
||||
}
|
||||
}
|
||||
|
||||
updateModalContent() {
|
||||
const modal = document.getElementById('updateModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Update title based on update availability
|
||||
const headerTitle = modal.querySelector('.update-header h2');
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = this.updateAvailable ? "Update Available" : "Check for Updates";
|
||||
}
|
||||
|
||||
// Always update version information, even if updateInfo is null
|
||||
const currentVersionEl = modal.querySelector('.current-version .version-number');
|
||||
const newVersionEl = modal.querySelector('.new-version .version-number');
|
||||
|
||||
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
|
||||
if (newVersionEl) newVersionEl.textContent = this.latestVersion;
|
||||
|
||||
// Update changelog content if available
|
||||
if (this.updateInfo && this.updateInfo.changelog) {
|
||||
const changelogContent = modal.querySelector('.changelog-content');
|
||||
if (changelogContent) {
|
||||
changelogContent.innerHTML = ''; // Clear existing content
|
||||
|
||||
// Create changelog item
|
||||
const changelogItem = document.createElement('div');
|
||||
changelogItem.className = 'changelog-item';
|
||||
|
||||
const versionHeader = document.createElement('h4');
|
||||
versionHeader.textContent = `Version ${this.latestVersion}`;
|
||||
changelogItem.appendChild(versionHeader);
|
||||
|
||||
// Create changelog list
|
||||
const changelogList = document.createElement('ul');
|
||||
|
||||
if (this.updateInfo.changelog && this.updateInfo.changelog.length > 0) {
|
||||
this.updateInfo.changelog.forEach(item => {
|
||||
const listItem = document.createElement('li');
|
||||
// Parse markdown in changelog items
|
||||
listItem.innerHTML = this.parseMarkdown(item);
|
||||
changelogList.appendChild(listItem);
|
||||
});
|
||||
} else {
|
||||
// If no changelog items available
|
||||
const listItem = document.createElement('li');
|
||||
listItem.textContent = "No detailed changelog available. Check GitHub for more information.";
|
||||
changelogList.appendChild(listItem);
|
||||
}
|
||||
|
||||
changelogItem.appendChild(changelogList);
|
||||
changelogContent.appendChild(changelogItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Update GitHub link to point to the specific release if available
|
||||
const githubLink = modal.querySelector('.update-link');
|
||||
if (githubLink && this.latestVersion) {
|
||||
const versionTag = this.latestVersion.replace(/^v/, '');
|
||||
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple markdown parser for changelog items
|
||||
parseMarkdown(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Handle bold text (**text**)
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Handle italic text (*text*)
|
||||
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Handle inline code (`code`)
|
||||
text = text.replace(/`(.*?)`/g, '<code>$1</code>');
|
||||
|
||||
// Handle links [text](url)
|
||||
text = text.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
toggleUpdateModal() {
|
||||
const updateModal = modalManager.getModal('updateModal');
|
||||
|
||||
// If modal is already open, just close it
|
||||
if (updateModal && updateModal.isOpen) {
|
||||
modalManager.closeModal('updateModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the modal content immediately with current data
|
||||
this.updateModalContent();
|
||||
|
||||
// Show the modal with current data
|
||||
modalManager.showModal('updateModal');
|
||||
|
||||
// Then check for updates in the background
|
||||
this.manualCheckForUpdates().then(() => {
|
||||
// Update the modal content again after the check completes
|
||||
this.updateModalContent();
|
||||
});
|
||||
}
|
||||
|
||||
async manualCheckForUpdates() {
|
||||
this.lastCheckTime = 0; // Reset last check time to force check
|
||||
await this.checkForUpdates();
|
||||
// Ensure badge visibility is updated after manual check
|
||||
this.updateBadgeVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const updateService = new UpdateService();
|
||||
187
static/js/recipes.js
Normal file
187
static/js/recipes.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// Recipe manager module
|
||||
import { appCore } from './core.js';
|
||||
import { ImportManager } from './managers/ImportManager.js';
|
||||
import { RecipeCard } from './components/RecipeCard.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { getCurrentPageState } from './state/index.js';
|
||||
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
||||
|
||||
class RecipeManager {
|
||||
constructor() {
|
||||
// Get page state
|
||||
this.pageState = getCurrentPageState();
|
||||
|
||||
// Initialize ImportManager
|
||||
this.importManager = new ImportManager();
|
||||
|
||||
// Initialize RecipeModal
|
||||
this.recipeModal = new RecipeModal();
|
||||
|
||||
// Add state tracking for infinite scroll
|
||||
this.pageState.isLoading = false;
|
||||
this.pageState.hasMore = true;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Initialize event listeners
|
||||
this.initEventListeners();
|
||||
|
||||
// Set default search options if not already defined
|
||||
this._initSearchOptions();
|
||||
|
||||
// Load initial set of recipes
|
||||
await this.loadRecipes();
|
||||
|
||||
// Expose necessary functions to the page
|
||||
this._exposeGlobalFunctions();
|
||||
|
||||
// Initialize common page features (lazy loading, infinite scroll)
|
||||
appCore.initializePageFeatures();
|
||||
}
|
||||
|
||||
_initSearchOptions() {
|
||||
// Ensure recipes search options are properly initialized
|
||||
if (!this.pageState.searchOptions) {
|
||||
this.pageState.searchOptions = {
|
||||
title: true, // Recipe title
|
||||
tags: true, // Recipe tags
|
||||
loraName: true, // LoRA file name
|
||||
loraModel: true // LoRA model name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_exposeGlobalFunctions() {
|
||||
// Only expose what's needed for the page
|
||||
window.recipeManager = this;
|
||||
window.importManager = this.importManager;
|
||||
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// Sort select
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.addEventListener('change', () => {
|
||||
this.pageState.sortBy = sortSelect.value;
|
||||
this.loadRecipes();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecipes(resetPage = true) {
|
||||
try {
|
||||
// Show loading indicator
|
||||
document.body.classList.add('loading');
|
||||
this.pageState.isLoading = true;
|
||||
|
||||
// Reset to first page if requested
|
||||
if (resetPage) {
|
||||
this.pageState.currentPage = 1;
|
||||
// Clear grid if resetting
|
||||
const grid = document.getElementById('recipeGrid');
|
||||
if (grid) grid.innerHTML = '';
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
page: this.pageState.currentPage,
|
||||
page_size: this.pageState.pageSize || 20,
|
||||
sort_by: this.pageState.sortBy
|
||||
});
|
||||
|
||||
// Add search filter if present
|
||||
if (this.pageState.filters.search) {
|
||||
params.append('search', this.pageState.filters.search);
|
||||
|
||||
// Add search option parameters
|
||||
if (this.pageState.searchOptions) {
|
||||
params.append('search_title', this.pageState.searchOptions.title.toString());
|
||||
params.append('search_tags', this.pageState.searchOptions.tags.toString());
|
||||
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
|
||||
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
|
||||
params.append('fuzzy', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Add base model filters
|
||||
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
|
||||
params.append('base_models', this.pageState.filters.baseModel.join(','));
|
||||
}
|
||||
|
||||
// Add tag filters
|
||||
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
|
||||
params.append('tags', this.pageState.filters.tags.join(','));
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
const response = await fetch(`/api/recipes?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update recipes grid
|
||||
this.updateRecipesGrid(data, resetPage);
|
||||
|
||||
// Update pagination state based on current page and total pages
|
||||
this.pageState.hasMore = data.page < data.total_pages;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading recipes:', error);
|
||||
appCore.showToast('Failed to load recipes', 'error');
|
||||
} finally {
|
||||
// Hide loading indicator
|
||||
document.body.classList.remove('loading');
|
||||
this.pageState.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateRecipesGrid(data, resetGrid = true) {
|
||||
const grid = document.getElementById('recipeGrid');
|
||||
if (!grid) return;
|
||||
|
||||
// Check if data exists and has items
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (resetGrid) {
|
||||
grid.innerHTML = `
|
||||
<div class="placeholder-message">
|
||||
<p>No recipes found</p>
|
||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear grid if resetting
|
||||
if (resetGrid) {
|
||||
grid.innerHTML = '';
|
||||
}
|
||||
|
||||
// Create recipe cards
|
||||
data.items.forEach(recipe => {
|
||||
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
|
||||
grid.appendChild(recipeCard.element);
|
||||
});
|
||||
}
|
||||
|
||||
showRecipeDetails(recipe) {
|
||||
this.recipeModal.showRecipeDetails(recipe);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize core application
|
||||
await appCore.initialize();
|
||||
|
||||
// Initialize recipe manager
|
||||
const recipeManager = new RecipeManager();
|
||||
await recipeManager.initialize();
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
export { RecipeManager };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user