mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af90eeaf37 | ||
|
|
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 | ||
|
|
d69406c4cb | ||
|
|
250e8445bb | ||
|
|
e6aafe8773 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
settings.json
|
settings.json
|
||||||
|
output/*
|
||||||
|
py/run_test.py
|
||||||
33
README.md
33
README.md
@@ -1,8 +1,14 @@
|
|||||||
# ComfyUI LoRA Manager
|
# 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
|
## 📺 Tutorial: One-Click LoRA Integration
|
||||||
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
||||||
@@ -13,6 +19,14 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
### v0.7.37
|
### v0.7.37
|
||||||
* Added NSFW content control settings (blur mature content and SFW-only filter)
|
* Added NSFW content control settings (blur mature content and SFW-only filter)
|
||||||
* Implemented intelligent blur effects for previews and showcase media
|
* Implemented intelligent blur effects for previews and showcase media
|
||||||
@@ -91,6 +105,12 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
|||||||
- Trigger words at a glance
|
- Trigger words at a glance
|
||||||
- One-click workflow integration with preset values
|
- 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**
|
- 💻 **User Friendly**
|
||||||
- One-click access from ComfyUI menu
|
- One-click access from ComfyUI menu
|
||||||
- Context menu for quick actions
|
- Context menu for quick actions
|
||||||
@@ -154,12 +174,3 @@ Join our Discord community for support, discussions, and updates:
|
|||||||
[Discord Server](https://discord.gg/vcqNrWVFvM)
|
[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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ from .py.lora_manager import LoraManager
|
|||||||
from .py.nodes.lora_loader import LoraManagerLoader
|
from .py.nodes.lora_loader import LoraManagerLoader
|
||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
from .py.nodes.lora_stacker import LoraStacker
|
from .py.nodes.lora_stacker import LoraStacker
|
||||||
|
# from .py.nodes.save_image import SaveImage
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||||
LoraStacker.NAME: LoraStacker
|
LoraStacker.NAME: LoraStacker,
|
||||||
|
# SaveImage.NAME: SaveImage
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|
||||||
# Register routes on import
|
# Register routes on import
|
||||||
LoraManager.add_routes()
|
LoraManager.add_routes()
|
||||||
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
|
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Config:
|
|||||||
# 静态路由映射字典, target to route mapping
|
# 静态路由映射字典, target to route mapping
|
||||||
self._route_mappings = {}
|
self._route_mappings = {}
|
||||||
self.loras_roots = self._init_lora_paths()
|
self.loras_roots = self._init_lora_paths()
|
||||||
|
self.temp_directory = folder_paths.get_temp_directory()
|
||||||
# 在初始化时扫描符号链接
|
# 在初始化时扫描符号链接
|
||||||
self._scan_symbolic_links()
|
self._scan_symbolic_links()
|
||||||
|
|
||||||
@@ -87,9 +88,9 @@ class Config:
|
|||||||
|
|
||||||
def _init_lora_paths(self) -> List[str]:
|
def _init_lora_paths(self) -> List[str]:
|
||||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
"""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")
|
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))
|
print("Found LoRA roots:", "\n - " + "\n - ".join(paths))
|
||||||
|
|
||||||
if not paths:
|
if not paths:
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ from server import PromptServer # type: ignore
|
|||||||
from .config import config
|
from .config import config
|
||||||
from .routes.lora_routes import LoraRoutes
|
from .routes.lora_routes import LoraRoutes
|
||||||
from .routes.api_routes import ApiRoutes
|
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.lora_scanner import LoraScanner
|
||||||
|
from .services.recipe_scanner import RecipeScanner
|
||||||
from .services.file_monitor import LoraFileMonitor
|
from .services.file_monitor import LoraFileMonitor
|
||||||
from .services.lora_cache import LoraCache
|
from .services.lora_cache import LoraCache
|
||||||
|
from .services.recipe_cache import RecipeCache
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -56,36 +60,42 @@ class LoraManager:
|
|||||||
|
|
||||||
# Setup feature routes
|
# Setup feature routes
|
||||||
routes = LoraRoutes()
|
routes = LoraRoutes()
|
||||||
|
checkpoints_routes = CheckpointsRoutes()
|
||||||
|
|
||||||
# Setup file monitoring
|
# Setup file monitoring
|
||||||
monitor = LoraFileMonitor(routes.scanner, config.loras_roots)
|
monitor = LoraFileMonitor(routes.scanner, config.loras_roots)
|
||||||
monitor.start()
|
monitor.start()
|
||||||
|
|
||||||
routes.setup_routes(app)
|
routes.setup_routes(app)
|
||||||
|
checkpoints_routes.setup_routes(app)
|
||||||
ApiRoutes.setup_routes(app, monitor)
|
ApiRoutes.setup_routes(app, monitor)
|
||||||
|
RecipeRoutes.setup_routes(app)
|
||||||
|
|
||||||
# Store monitor in app for cleanup
|
# Store monitor in app for cleanup
|
||||||
app['lora_monitor'] = monitor
|
app['lora_monitor'] = monitor
|
||||||
|
|
||||||
# Schedule cache initialization using the application's startup handler
|
# 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
|
# Add cleanup
|
||||||
app.on_shutdown.append(cls._cleanup)
|
app.on_shutdown.append(cls._cleanup)
|
||||||
app.on_shutdown.append(ApiRoutes.cleanup)
|
app.on_shutdown.append(ApiRoutes.cleanup)
|
||||||
|
|
||||||
@classmethod
|
@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"""
|
"""Schedule cache initialization in the running event loop"""
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
print(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _initialize_cache(cls, scanner: LoraScanner):
|
async def _initialize_lora_cache(cls, scanner: LoraScanner):
|
||||||
"""Initialize cache in background"""
|
"""Initialize lora cache in background"""
|
||||||
try:
|
try:
|
||||||
# 设置初始缓存占位
|
# 设置初始缓存占位
|
||||||
scanner._cache = LoraCache(
|
scanner._cache = LoraCache(
|
||||||
@@ -98,10 +108,29 @@ class LoraManager:
|
|||||||
# 分阶段加载缓存
|
# 分阶段加载缓存
|
||||||
await scanner.get_cached_data(force_refresh=True)
|
await scanner.get_cached_data(force_refresh=True)
|
||||||
except Exception as e:
|
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
|
@classmethod
|
||||||
async def _cleanup(cls, app):
|
async def _cleanup(cls, app):
|
||||||
"""Cleanup resources"""
|
"""Cleanup resources"""
|
||||||
if 'lora_monitor' in app:
|
if 'lora_monitor' in app:
|
||||||
app['lora_monitor'].stop()
|
app['lora_monitor'].stop()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from nodes import LoraLoader
|
from nodes import LoraLoader
|
||||||
from comfy.comfy_types import IO # type: ignore
|
from comfy.comfy_types import IO # type: ignore
|
||||||
from ..services.lora_scanner import LoraScanner
|
from ..services.lora_scanner import LoraScanner
|
||||||
@@ -6,6 +7,8 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
from .utils import FlexibleOptionalInputType, any_type
|
from .utils import FlexibleOptionalInputType, any_type
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class LoraManagerLoader:
|
class LoraManagerLoader:
|
||||||
NAME = "Lora Loader (LoraManager)"
|
NAME = "Lora Loader (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/loaders"
|
CATEGORY = "Lora Manager/loaders"
|
||||||
@@ -26,8 +29,8 @@ class LoraManagerLoader:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING)
|
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras"
|
FUNCTION = "load_loras"
|
||||||
|
|
||||||
async def get_lora_info(self, lora_name):
|
async def get_lora_info(self, lora_name):
|
||||||
@@ -55,6 +58,23 @@ class LoraManagerLoader:
|
|||||||
basename = os.path.basename(lora_path)
|
basename = os.path.basename(lora_path)
|
||||||
return os.path.splitext(basename)[0]
|
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, clip, text, **kwargs):
|
def load_loras(self, model, clip, text, **kwargs):
|
||||||
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
||||||
loaded_loras = []
|
loaded_loras = []
|
||||||
@@ -74,26 +94,30 @@ class LoraManagerLoader:
|
|||||||
all_trigger_words.extend(trigger_words)
|
all_trigger_words.extend(trigger_words)
|
||||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||||
|
|
||||||
# Then process loras from kwargs
|
# Then process loras from kwargs with support for both old and new formats
|
||||||
if 'loras' in kwargs:
|
loras_list = self._get_loras_list(kwargs)
|
||||||
for lora in kwargs['loras']:
|
for lora in loras_list:
|
||||||
if not lora.get('active', False):
|
if not lora.get('active', False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_name = lora['name']
|
|
||||||
strength = float(lora['strength'])
|
|
||||||
|
|
||||||
# Get lora path and trigger words
|
lora_name = lora['name']
|
||||||
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
strength = float(lora['strength'])
|
||||||
|
|
||||||
# Apply the LoRA using the resolved path
|
# Get lora path and trigger words
|
||||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
|
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||||
loaded_loras.append(f"{lora_name}: {strength}")
|
|
||||||
|
# Apply the LoRA using the resolved path
|
||||||
# Add trigger words to collection
|
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
|
||||||
all_trigger_words.extend(trigger_words)
|
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
|
# use ',, ' to separate trigger words for group mode
|
||||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
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)
|
||||||
@@ -4,6 +4,9 @@ from ..config import config
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from .utils import FlexibleOptionalInputType, any_type
|
from .utils import FlexibleOptionalInputType, any_type
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class LoraStacker:
|
class LoraStacker:
|
||||||
NAME = "Lora Stacker (LoraManager)"
|
NAME = "Lora Stacker (LoraManager)"
|
||||||
@@ -52,6 +55,23 @@ class LoraStacker:
|
|||||||
basename = os.path.basename(lora_path)
|
basename = os.path.basename(lora_path)
|
||||||
return os.path.splitext(basename)[0]
|
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):
|
def stack_loras(self, text, **kwargs):
|
||||||
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
|
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
|
||||||
stack = []
|
stack = []
|
||||||
@@ -67,23 +87,25 @@ class LoraStacker:
|
|||||||
_, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
_, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||||
all_trigger_words.extend(trigger_words)
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
if 'loras' in kwargs:
|
# Process loras from kwargs with support for both old and new formats
|
||||||
for lora in kwargs['loras']:
|
loras_list = self._get_loras_list(kwargs)
|
||||||
if not lora.get('active', False):
|
for lora in loras_list:
|
||||||
continue
|
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_name = lora['name']
|
||||||
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
model_strength = float(lora['strength'])
|
||||||
|
clip_strength = model_strength # Using same strength for both as in the original loader
|
||||||
# Add to stack without loading
|
|
||||||
stack.append((lora_path, model_strength, clip_strength))
|
# Get lora path and trigger words
|
||||||
|
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||||
# Add trigger words to collection
|
|
||||||
all_trigger_words.extend(trigger_words)
|
# 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))
|
||||||
|
|
||||||
|
# Add trigger words to collection
|
||||||
|
all_trigger_words.extend(trigger_words)
|
||||||
|
|
||||||
# use ',, ' to separate trigger words for group mode
|
# use ',, ' to separate trigger words for group mode
|
||||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||||
|
|||||||
41
py/nodes/save_image.py
Normal file
41
py/nodes/save_image.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import json
|
||||||
|
from server import PromptServer # type: ignore
|
||||||
|
|
||||||
|
class SaveImage:
|
||||||
|
NAME = "Save Image (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/utils"
|
||||||
|
DESCRIPTION = "Experimental node to display image preview and print prompt and extra_pnginfo"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"image": ("IMAGE",),
|
||||||
|
},
|
||||||
|
"hidden": {
|
||||||
|
"prompt": "PROMPT",
|
||||||
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE",)
|
||||||
|
RETURN_NAMES = ("image",)
|
||||||
|
FUNCTION = "process_image"
|
||||||
|
|
||||||
|
def process_image(self, image, prompt=None, extra_pnginfo=None):
|
||||||
|
# Print the prompt information
|
||||||
|
print("SaveImage Node - Prompt:")
|
||||||
|
if prompt:
|
||||||
|
print(json.dumps(prompt, indent=2))
|
||||||
|
else:
|
||||||
|
print("No prompt information available")
|
||||||
|
|
||||||
|
# Print the extra_pnginfo
|
||||||
|
print("\nSaveImage Node - Extra PNG Info:")
|
||||||
|
if extra_pnginfo:
|
||||||
|
print(json.dumps(extra_pnginfo, indent=2))
|
||||||
|
else:
|
||||||
|
print("No extra PNG info available")
|
||||||
|
|
||||||
|
# Return the image unchanged
|
||||||
|
return (image,)
|
||||||
@@ -2,6 +2,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # type: ignore
|
||||||
from .utils import FlexibleOptionalInputType, any_type
|
from .utils import FlexibleOptionalInputType, any_type
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TriggerWordToggle:
|
class TriggerWordToggle:
|
||||||
NAME = "TriggerWord Toggle (LoraManager)"
|
NAME = "TriggerWord Toggle (LoraManager)"
|
||||||
@@ -24,8 +28,24 @@ class TriggerWordToggle:
|
|||||||
RETURN_NAMES = ("filtered_trigger_words",)
|
RETURN_NAMES = ("filtered_trigger_words",)
|
||||||
FUNCTION = "process_trigger_words"
|
FUNCTION = "process_trigger_words"
|
||||||
|
|
||||||
|
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):
|
def process_trigger_words(self, id, group_mode, **kwargs):
|
||||||
trigger_words = kwargs.get("trigger_words", "")
|
# 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
|
# Send trigger words to frontend
|
||||||
PromptServer.instance.send_sync("trigger_word_update", {
|
PromptServer.instance.send_sync("trigger_word_update", {
|
||||||
"id": id,
|
"id": id,
|
||||||
@@ -34,11 +54,10 @@ class TriggerWordToggle:
|
|||||||
|
|
||||||
filtered_triggers = trigger_words
|
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:
|
try:
|
||||||
# Get trigger word toggle data
|
|
||||||
trigger_data = kwargs['toggle_trigger_words']
|
|
||||||
|
|
||||||
# Convert to list if it's a JSON string
|
# Convert to list if it's a JSON string
|
||||||
if isinstance(trigger_data, str):
|
if isinstance(trigger_data, str):
|
||||||
trigger_data = json.loads(trigger_data)
|
trigger_data = json.loads(trigger_data)
|
||||||
@@ -72,6 +91,6 @@ class TriggerWordToggle:
|
|||||||
filtered_triggers = ""
|
filtered_triggers = ""
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error processing trigger words: {e}")
|
logger.error(f"Error processing trigger words: {e}")
|
||||||
|
|
||||||
return (filtered_triggers,)
|
return (filtered_triggers,)
|
||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from ..utils.model_utils import determine_base_model
|
||||||
|
|
||||||
from ..services.file_monitor import LoraFileMonitor
|
from ..services.file_monitor import LoraFileMonitor
|
||||||
from ..services.download_manager import DownloadManager
|
from ..services.download_manager import DownloadManager
|
||||||
from ..services.civitai_client import CivitaiClient
|
from ..services.civitai_client import CivitaiClient
|
||||||
@@ -14,6 +16,7 @@ from ..services.websocket_manager import ws_manager
|
|||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
import asyncio
|
import asyncio
|
||||||
from .update_routes import UpdateRoutes
|
from .update_routes import UpdateRoutes
|
||||||
|
from ..services.recipe_scanner import RecipeScanner
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ class ApiRoutes:
|
|||||||
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
|
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('/ws/fetch-progress', ws_manager.handle_connection)
|
||||||
app.router.add_get('/api/lora-roots', routes.get_lora_roots)
|
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/versions/{model_id}', routes.get_civitai_versions)
|
||||||
app.router.add_post('/api/download-lora', routes.download_lora)
|
app.router.add_post('/api/download-lora', routes.download_lora)
|
||||||
app.router.add_post('/api/settings', routes.update_settings)
|
app.router.add_post('/api/settings', routes.update_settings)
|
||||||
@@ -45,7 +49,9 @@ class ApiRoutes:
|
|||||||
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
|
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_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_post('/api/move_models_bulk', routes.move_models_bulk)
|
||||||
app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags
|
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
|
||||||
|
|
||||||
# Add update check routes
|
# Add update check routes
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
@@ -126,7 +132,6 @@ class ApiRoutes:
|
|||||||
folder = request.query.get('folder')
|
folder = request.query.get('folder')
|
||||||
search = request.query.get('search', '').lower()
|
search = request.query.get('search', '').lower()
|
||||||
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
|
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
|
||||||
recursive = request.query.get('recursive', 'false').lower() == 'true'
|
|
||||||
|
|
||||||
# Parse base models filter parameter
|
# Parse base models filter parameter
|
||||||
base_models = request.query.get('base_models', '').split(',')
|
base_models = request.query.get('base_models', '').split(',')
|
||||||
@@ -136,6 +141,7 @@ class ApiRoutes:
|
|||||||
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
||||||
search_modelname = request.query.get('search_modelname', 'true').lower() == 'true'
|
search_modelname = request.query.get('search_modelname', 'true').lower() == 'true'
|
||||||
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
||||||
|
recursive = request.query.get('recursive', 'false').lower() == 'true'
|
||||||
|
|
||||||
# Validate parameters
|
# Validate parameters
|
||||||
if page < 1 or page_size < 1 or page_size > 100:
|
if page < 1 or page_size < 1 or page_size > 100:
|
||||||
@@ -160,13 +166,13 @@ class ApiRoutes:
|
|||||||
folder=folder,
|
folder=folder,
|
||||||
search=search,
|
search=search,
|
||||||
fuzzy=fuzzy,
|
fuzzy=fuzzy,
|
||||||
recursive=recursive,
|
|
||||||
base_models=base_models, # Pass base models filter
|
base_models=base_models, # Pass base models filter
|
||||||
tags=tags, # Add tags parameter
|
tags=tags, # Add tags parameter
|
||||||
search_options={
|
search_options={
|
||||||
'filename': search_filename,
|
'filename': search_filename,
|
||||||
'modelname': search_modelname,
|
'modelname': search_modelname,
|
||||||
'tags': search_tags
|
'tags': search_tags,
|
||||||
|
'recursive': recursive
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,6 +269,9 @@ class ApiRoutes:
|
|||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
cache.raw_data = [item for item in cache.raw_data if item['file_path'] != main_path]
|
cache.raw_data = [item for item in cache.raw_data if item['file_path'] != main_path]
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
|
|
||||||
|
# update hash index
|
||||||
|
self.scanner._hash_index.remove_by_path(main_path)
|
||||||
|
|
||||||
# Delete optional files
|
# Delete optional files
|
||||||
for pattern in patterns[1:]:
|
for pattern in patterns[1:]:
|
||||||
@@ -351,8 +360,8 @@ class ApiRoutes:
|
|||||||
|
|
||||||
# Update model name if available
|
# Update model name if available
|
||||||
if 'model' in civitai_metadata:
|
if 'model' in civitai_metadata:
|
||||||
local_metadata['model_name'] = civitai_metadata['model'].get('name',
|
if civitai_metadata.get('model', {}).get('name'):
|
||||||
local_metadata.get('model_name'))
|
local_metadata['model_name'] = civitai_metadata['model']['name']
|
||||||
|
|
||||||
# Fetch additional model metadata (description and tags) if we have model ID
|
# Fetch additional model metadata (description and tags) if we have model ID
|
||||||
model_id = civitai_metadata['modelId']
|
model_id = civitai_metadata['modelId']
|
||||||
@@ -363,7 +372,7 @@ class ApiRoutes:
|
|||||||
local_metadata['tags'] = model_metadata.get('tags', [])
|
local_metadata['tags'] = model_metadata.get('tags', [])
|
||||||
|
|
||||||
# Update base model
|
# 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
|
# Update preview if needed
|
||||||
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
|
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
|
||||||
@@ -516,6 +525,13 @@ class ApiRoutes:
|
|||||||
return web.json_response({
|
return web.json_response({
|
||||||
'roots': config.loras_roots
|
'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:
|
async def get_civitai_versions(self, request: web.Request) -> web.Response:
|
||||||
"""Get available versions for a Civitai model with local availability info"""
|
"""Get available versions for a Civitai model with local availability info"""
|
||||||
@@ -527,13 +543,24 @@ class ApiRoutes:
|
|||||||
|
|
||||||
# Check local availability for each version
|
# Check local availability for each version
|
||||||
for version in versions:
|
for version in versions:
|
||||||
for file in version.get('files', []):
|
# Find the model file (type="Model") in the files list
|
||||||
sha256 = file.get('hashes', {}).get('SHA256')
|
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:
|
if sha256:
|
||||||
file['existsLocally'] = self.scanner.has_lora_hash(sha256)
|
# Set existsLocally and localPath at the version level
|
||||||
if file['existsLocally']:
|
version['existsLocally'] = self.scanner.has_lora_hash(sha256)
|
||||||
file['localPath'] = self.scanner.get_lora_path_by_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)
|
return web.json_response(versions)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching model versions: {e}")
|
logger.error(f"Error fetching model versions: {e}")
|
||||||
@@ -555,16 +582,36 @@ class ApiRoutes:
|
|||||||
download_url=data.get('download_url'),
|
download_url=data.get('download_url'),
|
||||||
save_dir=data.get('lora_root'),
|
save_dir=data.get('lora_root'),
|
||||||
relative_path=data.get('relative_path'),
|
relative_path=data.get('relative_path'),
|
||||||
progress_callback=progress_callback # Add progress callback
|
progress_callback=progress_callback
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.get('success', False):
|
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)
|
return web.json_response(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error downloading LoRA: {e}")
|
error_message = str(e)
|
||||||
return web.Response(status=500, text=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:
|
async def update_settings(self, request: web.Request) -> web.Response:
|
||||||
"""Update application settings"""
|
"""Update application settings"""
|
||||||
@@ -694,6 +741,48 @@ class ApiRoutes:
|
|||||||
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
||||||
return web.Response(text=str(e), status=500)
|
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:
|
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||||
"""Handle bulk model move request"""
|
"""Handle bulk model move request"""
|
||||||
try:
|
try:
|
||||||
@@ -820,3 +909,27 @@ class ApiRoutes:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Internal server error'
|
'error': 'Internal server error'
|
||||||
}, status=500)
|
}, 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}", 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
|
from typing import Dict, List
|
||||||
import logging
|
import logging
|
||||||
from ..services.lora_scanner import LoraScanner
|
from ..services.lora_scanner import LoraScanner
|
||||||
|
from ..services.recipe_scanner import RecipeScanner
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..services.settings_manager import settings # Add this import
|
from ..services.settings_manager import settings # Add this import
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class LoraRoutes:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scanner = LoraScanner()
|
self.scanner = LoraScanner()
|
||||||
|
self.recipe_scanner = RecipeScanner(self.scanner)
|
||||||
self.template_env = jinja2.Environment(
|
self.template_env = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||||
autoescape=True
|
autoescape=True
|
||||||
@@ -69,7 +71,8 @@ class LoraRoutes:
|
|||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
folders=[], # 空文件夹列表
|
folders=[], # 空文件夹列表
|
||||||
is_initializing=True, # 新增标志
|
is_initializing=True, # 新增标志
|
||||||
settings=settings # Pass settings to template
|
settings=settings, # Pass settings to template
|
||||||
|
request=request # Pass the request object to the template
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 正常流程
|
# 正常流程
|
||||||
@@ -78,7 +81,8 @@ class LoraRoutes:
|
|||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
folders=cache.folders,
|
folders=cache.folders,
|
||||||
is_initializing=False,
|
is_initializing=False,
|
||||||
settings=settings # Pass settings to template
|
settings=settings, # Pass settings to template
|
||||||
|
request=request # Pass the request object to the template
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
@@ -93,6 +97,65 @@ class LoraRoutes:
|
|||||||
status=500
|
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):
|
def setup_routes(self, app: web.Application):
|
||||||
"""Register routes with the application"""
|
"""Register routes with the application"""
|
||||||
app.router.add_get('/loras', self.handle_loras_page)
|
app.router.add_get('/loras', self.handle_loras_page)
|
||||||
|
app.router.add_get('/loras/recipes', self.handle_recipes_page)
|
||||||
|
|||||||
908
py/routes/recipe_routes.py
Normal file
908
py/routes/recipe_routes.py
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from aiohttp import web
|
||||||
|
from typing import Dict
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
from ..utils.recipe_parsers import RecipeParserFactory
|
||||||
|
from ..services.civitai_client import CivitaiClient
|
||||||
|
|
||||||
|
from ..services.recipe_scanner import RecipeScanner
|
||||||
|
from ..services.lora_scanner import LoraScanner
|
||||||
|
from ..config import config
|
||||||
|
from ..workflow.parser import WorkflowParser
|
||||||
|
from ..utils.utils import download_twitter_image
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RecipeRoutes:
|
||||||
|
"""API route handlers for Recipe management"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.recipe_scanner = RecipeScanner(LoraScanner())
|
||||||
|
self.civitai_client = CivitaiClient()
|
||||||
|
self.parser = WorkflowParser()
|
||||||
|
|
||||||
|
# Pre-warm the cache
|
||||||
|
self._init_cache_task = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_routes(cls, app: web.Application):
|
||||||
|
"""Register API routes"""
|
||||||
|
routes = cls()
|
||||||
|
app.router.add_get('/api/recipes', routes.get_recipes)
|
||||||
|
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
||||||
|
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
||||||
|
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
||||||
|
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
||||||
|
|
||||||
|
# Add new filter-related endpoints
|
||||||
|
app.router.add_get('/api/recipes/top-tags', routes.get_top_tags)
|
||||||
|
app.router.add_get('/api/recipes/base-models', routes.get_base_models)
|
||||||
|
|
||||||
|
# Add new sharing endpoints
|
||||||
|
app.router.add_get('/api/recipe/{recipe_id}/share', routes.share_recipe)
|
||||||
|
app.router.add_get('/api/recipe/{recipe_id}/share/download', routes.download_shared_recipe)
|
||||||
|
|
||||||
|
# Start cache initialization
|
||||||
|
app.on_startup.append(routes._init_cache)
|
||||||
|
|
||||||
|
app.router.add_post('/api/recipes/save-from-widget', routes.save_recipe_from_widget)
|
||||||
|
|
||||||
|
async def _init_cache(self, app):
|
||||||
|
"""Initialize cache on startup"""
|
||||||
|
try:
|
||||||
|
# First, ensure the lora scanner is fully initialized
|
||||||
|
lora_scanner = self.recipe_scanner._lora_scanner
|
||||||
|
|
||||||
|
# Get lora cache to ensure it's initialized
|
||||||
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Verify hash index is built
|
||||||
|
if hasattr(lora_scanner, '_hash_index'):
|
||||||
|
hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0
|
||||||
|
|
||||||
|
# Now that lora scanner is initialized, initialize recipe cache
|
||||||
|
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def get_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
"""API endpoint for getting paginated recipes"""
|
||||||
|
try:
|
||||||
|
# Get query parameters with defaults
|
||||||
|
page = int(request.query.get('page', '1'))
|
||||||
|
page_size = int(request.query.get('page_size', '20'))
|
||||||
|
sort_by = request.query.get('sort_by', 'date')
|
||||||
|
search = request.query.get('search', None)
|
||||||
|
|
||||||
|
# Get search options (renamed for better clarity)
|
||||||
|
search_title = request.query.get('search_title', 'true').lower() == 'true'
|
||||||
|
search_tags = request.query.get('search_tags', 'true').lower() == 'true'
|
||||||
|
search_lora_name = request.query.get('search_lora_name', 'true').lower() == 'true'
|
||||||
|
search_lora_model = request.query.get('search_lora_model', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Get filter parameters
|
||||||
|
base_models = request.query.get('base_models', None)
|
||||||
|
tags = request.query.get('tags', None)
|
||||||
|
|
||||||
|
# Parse filter parameters
|
||||||
|
filters = {}
|
||||||
|
if base_models:
|
||||||
|
filters['base_model'] = base_models.split(',')
|
||||||
|
if tags:
|
||||||
|
filters['tags'] = tags.split(',')
|
||||||
|
|
||||||
|
# Add search options to filters
|
||||||
|
search_options = {
|
||||||
|
'title': search_title,
|
||||||
|
'tags': search_tags,
|
||||||
|
'lora_name': search_lora_name,
|
||||||
|
'lora_model': search_lora_model
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get paginated data
|
||||||
|
result = await self.recipe_scanner.get_paginated_data(
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
sort_by=sort_by,
|
||||||
|
search=search,
|
||||||
|
filters=filters,
|
||||||
|
search_options=search_options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format the response data with static URLs for file paths
|
||||||
|
for item in result['items']:
|
||||||
|
# Always ensure file_url is set
|
||||||
|
if 'file_path' in item:
|
||||||
|
item['file_url'] = self._format_recipe_file_url(item['file_path'])
|
||||||
|
else:
|
||||||
|
item['file_url'] = '/loras_static/images/no-preview.png'
|
||||||
|
|
||||||
|
# 确保 loras 数组存在
|
||||||
|
if 'loras' not in item:
|
||||||
|
item['loras'] = []
|
||||||
|
|
||||||
|
# 确保有 base_model 字段
|
||||||
|
if 'base_model' not in item:
|
||||||
|
item['base_model'] = ""
|
||||||
|
|
||||||
|
return web.json_response(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving recipes: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def get_recipe_detail(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get detailed information about a specific recipe"""
|
||||||
|
try:
|
||||||
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
|
||||||
|
# Get all recipes from cache
|
||||||
|
cache = await self.recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Find the specific recipe
|
||||||
|
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
||||||
|
|
||||||
|
if not recipe:
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
# Format recipe data
|
||||||
|
formatted_recipe = self._format_recipe_data(recipe)
|
||||||
|
|
||||||
|
return web.json_response(formatted_recipe)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving recipe details: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
def _format_recipe_file_url(self, file_path: str) -> str:
|
||||||
|
"""Format file path for recipe image as a URL"""
|
||||||
|
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 _format_recipe_data(self, recipe: Dict) -> Dict:
|
||||||
|
"""Format recipe data for API response"""
|
||||||
|
formatted = {**recipe} # Copy all fields
|
||||||
|
|
||||||
|
# Format file paths to URLs
|
||||||
|
if 'file_path' in formatted:
|
||||||
|
formatted['file_url'] = self._format_recipe_file_url(formatted['file_path'])
|
||||||
|
|
||||||
|
# Format dates for display
|
||||||
|
for date_field in ['created_date', 'modified']:
|
||||||
|
if date_field in formatted:
|
||||||
|
formatted[f"{date_field}_formatted"] = self._format_timestamp(formatted[date_field])
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
def _format_timestamp(self, timestamp: float) -> str:
|
||||||
|
"""Format timestamp for display"""
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async def analyze_recipe_image(self, request: web.Request) -> web.Response:
|
||||||
|
"""Analyze an uploaded image or URL for recipe metadata"""
|
||||||
|
temp_path = None
|
||||||
|
try:
|
||||||
|
# Check if request contains multipart data (image) or JSON data (url)
|
||||||
|
content_type = request.headers.get('Content-Type', '')
|
||||||
|
|
||||||
|
is_url_mode = False
|
||||||
|
|
||||||
|
if 'multipart/form-data' in content_type:
|
||||||
|
# Handle image upload
|
||||||
|
reader = await request.multipart()
|
||||||
|
field = await reader.next()
|
||||||
|
|
||||||
|
if field.name != 'image':
|
||||||
|
return web.json_response({
|
||||||
|
"error": "No image field found",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Create a temporary file to store the uploaded image
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||||
|
while True:
|
||||||
|
chunk = await field.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
temp_file.write(chunk)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
elif 'application/json' in content_type:
|
||||||
|
# Handle URL input
|
||||||
|
data = await request.json()
|
||||||
|
url = data.get('url')
|
||||||
|
is_url_mode = True
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "No URL provided",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Download image from URL
|
||||||
|
temp_path = download_twitter_image(url)
|
||||||
|
|
||||||
|
if not temp_path:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Failed to download image from URL",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Extract metadata from the image using ExifUtils
|
||||||
|
user_comment = ExifUtils.extract_user_comment(temp_path)
|
||||||
|
|
||||||
|
# If no metadata found, return a more specific error
|
||||||
|
if not user_comment:
|
||||||
|
result = {
|
||||||
|
"error": "No metadata found in this image",
|
||||||
|
"loras": [] # Return empty loras array to prevent client-side errors
|
||||||
|
}
|
||||||
|
|
||||||
|
# For URL mode, include the image data as base64
|
||||||
|
if is_url_mode and temp_path:
|
||||||
|
import base64
|
||||||
|
with open(temp_path, "rb") as image_file:
|
||||||
|
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
# Use the parser factory to get the appropriate parser
|
||||||
|
parser = RecipeParserFactory.create_parser(user_comment)
|
||||||
|
|
||||||
|
if parser is None:
|
||||||
|
result = {
|
||||||
|
"error": "No parser found for this image",
|
||||||
|
"loras": [] # Return empty loras array to prevent client-side errors
|
||||||
|
}
|
||||||
|
|
||||||
|
# For URL mode, include the image data as base64
|
||||||
|
if is_url_mode and temp_path:
|
||||||
|
import base64
|
||||||
|
with open(temp_path, "rb") as image_file:
|
||||||
|
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
# Parse the metadata
|
||||||
|
result = await parser.parse_metadata(
|
||||||
|
user_comment,
|
||||||
|
recipe_scanner=self.recipe_scanner,
|
||||||
|
civitai_client=self.civitai_client
|
||||||
|
)
|
||||||
|
|
||||||
|
# For URL mode, include the image data as base64
|
||||||
|
if is_url_mode and temp_path:
|
||||||
|
import base64
|
||||||
|
with open(temp_path, "rb") as image_file:
|
||||||
|
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
if "error" in result and not result.get("loras"):
|
||||||
|
return web.json_response(result, status=200)
|
||||||
|
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing recipe image: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"error": str(e),
|
||||||
|
"loras": [] # Return empty loras array to prevent client-side errors
|
||||||
|
}, status=500)
|
||||||
|
finally:
|
||||||
|
# Clean up the temporary file in the finally block
|
||||||
|
if temp_path and os.path.exists(temp_path):
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting temporary file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
"""Save a recipe to the recipes folder"""
|
||||||
|
try:
|
||||||
|
reader = await request.multipart()
|
||||||
|
|
||||||
|
# Process form data
|
||||||
|
image = None
|
||||||
|
image_base64 = None
|
||||||
|
image_url = None
|
||||||
|
name = None
|
||||||
|
tags = []
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
field = await reader.next()
|
||||||
|
if field is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if field.name == 'image':
|
||||||
|
# Read image data
|
||||||
|
image_data = b''
|
||||||
|
while True:
|
||||||
|
chunk = await field.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
image_data += chunk
|
||||||
|
image = image_data
|
||||||
|
|
||||||
|
elif field.name == 'image_base64':
|
||||||
|
# Get base64 image data
|
||||||
|
image_base64 = await field.text()
|
||||||
|
|
||||||
|
elif field.name == 'image_url':
|
||||||
|
# Get image URL
|
||||||
|
image_url = await field.text()
|
||||||
|
|
||||||
|
elif field.name == 'name':
|
||||||
|
name = await field.text()
|
||||||
|
|
||||||
|
elif field.name == 'tags':
|
||||||
|
tags_text = await field.text()
|
||||||
|
try:
|
||||||
|
tags = json.loads(tags_text)
|
||||||
|
except:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
elif field.name == 'metadata':
|
||||||
|
metadata_text = await field.text()
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_text)
|
||||||
|
except:
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
missing_fields = []
|
||||||
|
if not name:
|
||||||
|
missing_fields.append("name")
|
||||||
|
if not metadata:
|
||||||
|
missing_fields.append("metadata")
|
||||||
|
if missing_fields:
|
||||||
|
return web.json_response({"error": f"Missing required fields: {', '.join(missing_fields)}"}, status=400)
|
||||||
|
|
||||||
|
# Handle different image sources
|
||||||
|
if not image:
|
||||||
|
if image_base64:
|
||||||
|
# Convert base64 to binary
|
||||||
|
import base64
|
||||||
|
try:
|
||||||
|
# Remove potential data URL prefix
|
||||||
|
if ',' in image_base64:
|
||||||
|
image_base64 = image_base64.split(',', 1)[1]
|
||||||
|
image = base64.b64decode(image_base64)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": f"Invalid base64 image data: {str(e)}"}, status=400)
|
||||||
|
elif image_url:
|
||||||
|
# Download image from URL
|
||||||
|
from ..utils.utils import download_twitter_image
|
||||||
|
temp_path = download_twitter_image(image_url)
|
||||||
|
if not temp_path:
|
||||||
|
return web.json_response({"error": "Failed to download image from URL"}, status=400)
|
||||||
|
|
||||||
|
# Read the downloaded image
|
||||||
|
with open(temp_path, 'rb') as f:
|
||||||
|
image = f.read()
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return web.json_response({"error": "No image data provided"}, status=400)
|
||||||
|
|
||||||
|
# Create recipes directory if it doesn't exist
|
||||||
|
recipes_dir = self.recipe_scanner.recipes_dir
|
||||||
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate UUID for the recipe
|
||||||
|
import uuid
|
||||||
|
recipe_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Optimize the image (resize and convert to WebP)
|
||||||
|
optimized_image, extension = ExifUtils.optimize_image(
|
||||||
|
image_data=image,
|
||||||
|
target_width=480,
|
||||||
|
format='webp',
|
||||||
|
quality=85,
|
||||||
|
preserve_metadata=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the optimized image
|
||||||
|
image_filename = f"{recipe_id}{extension}"
|
||||||
|
image_path = os.path.join(recipes_dir, image_filename)
|
||||||
|
with open(image_path, 'wb') as f:
|
||||||
|
f.write(optimized_image)
|
||||||
|
|
||||||
|
# Create the recipe JSON
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Format loras data according to the recipe.json format
|
||||||
|
loras_data = []
|
||||||
|
for lora in metadata.get("loras", []):
|
||||||
|
# Skip deleted LoRAs if they're marked to be excluded
|
||||||
|
if lora.get("isDeleted", False) and lora.get("exclude", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert frontend lora format to recipe format
|
||||||
|
lora_entry = {
|
||||||
|
"file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0],
|
||||||
|
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
|
||||||
|
"strength": float(lora.get("weight", 1.0)),
|
||||||
|
"modelVersionId": lora.get("id", ""),
|
||||||
|
"modelName": lora.get("name", ""),
|
||||||
|
"modelVersionName": lora.get("version", ""),
|
||||||
|
"isDeleted": lora.get("isDeleted", False) # Preserve deletion status in saved recipe
|
||||||
|
}
|
||||||
|
loras_data.append(lora_entry)
|
||||||
|
|
||||||
|
# Format gen_params according to the recipe.json format
|
||||||
|
gen_params = metadata.get("gen_params", {})
|
||||||
|
if not gen_params and "raw_metadata" in metadata:
|
||||||
|
# Extract from raw metadata if available
|
||||||
|
raw_metadata = metadata.get("raw_metadata", {})
|
||||||
|
gen_params = {
|
||||||
|
"prompt": raw_metadata.get("prompt", ""),
|
||||||
|
"negative_prompt": raw_metadata.get("negative_prompt", ""),
|
||||||
|
"checkpoint": raw_metadata.get("checkpoint", {}),
|
||||||
|
"steps": raw_metadata.get("steps", ""),
|
||||||
|
"sampler": raw_metadata.get("sampler", ""),
|
||||||
|
"cfg_scale": raw_metadata.get("cfg_scale", ""),
|
||||||
|
"seed": raw_metadata.get("seed", ""),
|
||||||
|
"size": raw_metadata.get("size", ""),
|
||||||
|
"clip_skip": raw_metadata.get("clip_skip", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create the recipe data structure
|
||||||
|
recipe_data = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": image_path,
|
||||||
|
"title": name,
|
||||||
|
"modified": current_time,
|
||||||
|
"created_date": current_time,
|
||||||
|
"base_model": metadata.get("base_model", ""),
|
||||||
|
"loras": loras_data,
|
||||||
|
"gen_params": gen_params
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add tags if provided
|
||||||
|
if tags:
|
||||||
|
recipe_data["tags"] = tags
|
||||||
|
|
||||||
|
# Save the recipe JSON
|
||||||
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
|
with open(json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Add recipe metadata to the image
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||||
|
|
||||||
|
# Simplified cache update approach
|
||||||
|
# Instead of trying to update the cache directly, just set it to None
|
||||||
|
# to force a refresh on the next get_cached_data call
|
||||||
|
if self.recipe_scanner._cache is not None:
|
||||||
|
# Add the recipe to the raw data if the cache exists
|
||||||
|
# This is a simple direct update without locks or timeouts
|
||||||
|
self.recipe_scanner._cache.raw_data.append(recipe_data)
|
||||||
|
# Schedule a background task to resort the cache
|
||||||
|
asyncio.create_task(self.recipe_scanner._cache.resort())
|
||||||
|
logger.info(f"Added recipe {recipe_id} to cache")
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'recipe_id': recipe_id,
|
||||||
|
'image_path': image_path,
|
||||||
|
'json_path': json_path
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving recipe: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
"""Delete a recipe by ID"""
|
||||||
|
try:
|
||||||
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
|
||||||
|
# Get recipes directory
|
||||||
|
recipes_dir = self.recipe_scanner.recipes_dir
|
||||||
|
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||||
|
return web.json_response({"error": "Recipes directory not found"}, status=404)
|
||||||
|
|
||||||
|
# Find recipe JSON file
|
||||||
|
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
|
if not os.path.exists(recipe_json_path):
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
# Load recipe data to get image path
|
||||||
|
with open(recipe_json_path, 'r', encoding='utf-8') as f:
|
||||||
|
recipe_data = json.load(f)
|
||||||
|
|
||||||
|
# Get image path
|
||||||
|
image_path = recipe_data.get('file_path')
|
||||||
|
|
||||||
|
# Delete recipe JSON file
|
||||||
|
os.remove(recipe_json_path)
|
||||||
|
logger.info(f"Deleted recipe JSON file: {recipe_json_path}")
|
||||||
|
|
||||||
|
# Delete recipe image if it exists
|
||||||
|
if image_path and os.path.exists(image_path):
|
||||||
|
os.remove(image_path)
|
||||||
|
logger.info(f"Deleted recipe image: {image_path}")
|
||||||
|
|
||||||
|
# Simplified cache update approach
|
||||||
|
if self.recipe_scanner._cache is not None:
|
||||||
|
# Remove the recipe from raw_data if it exists
|
||||||
|
self.recipe_scanner._cache.raw_data = [
|
||||||
|
r for r in self.recipe_scanner._cache.raw_data
|
||||||
|
if str(r.get('id', '')) != recipe_id
|
||||||
|
]
|
||||||
|
# Schedule a background task to resort the cache
|
||||||
|
asyncio.create_task(self.recipe_scanner._cache.resort())
|
||||||
|
logger.info(f"Removed recipe {recipe_id} from cache")
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "message": "Recipe deleted successfully"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting recipe: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get top tags used in recipes"""
|
||||||
|
try:
|
||||||
|
# Get limit parameter with default
|
||||||
|
limit = int(request.query.get('limit', '20'))
|
||||||
|
|
||||||
|
# Get all recipes from cache
|
||||||
|
cache = await self.recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Count tag occurrences
|
||||||
|
tag_counts = {}
|
||||||
|
for recipe in cache.raw_data:
|
||||||
|
if 'tags' in recipe and recipe['tags']:
|
||||||
|
for tag in recipe['tags']:
|
||||||
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# Sort tags by count and limit results
|
||||||
|
sorted_tags = [{'tag': tag, 'count': count} for tag, count in tag_counts.items()]
|
||||||
|
sorted_tags.sort(key=lambda x: x['count'], reverse=True)
|
||||||
|
top_tags = sorted_tags[:limit]
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'tags': top_tags
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving top tags: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get base models used in recipes"""
|
||||||
|
try:
|
||||||
|
# Get all recipes from cache
|
||||||
|
cache = await self.recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Count base model occurrences
|
||||||
|
base_model_counts = {}
|
||||||
|
for recipe in cache.raw_data:
|
||||||
|
if 'base_model' in recipe and recipe['base_model']:
|
||||||
|
base_model = recipe['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 web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'base_models': sorted_models
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving base models: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def share_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
"""Process a recipe image for sharing by adding metadata to EXIF"""
|
||||||
|
try:
|
||||||
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
|
||||||
|
# Get all recipes from cache
|
||||||
|
cache = await self.recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Find the specific recipe
|
||||||
|
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
||||||
|
|
||||||
|
if not recipe:
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
# Get the image path
|
||||||
|
image_path = recipe.get('file_path')
|
||||||
|
if not image_path or not os.path.exists(image_path):
|
||||||
|
return web.json_response({"error": "Recipe image not found"}, status=404)
|
||||||
|
|
||||||
|
# Create a temporary copy of the image to modify
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Create temp file with same extension
|
||||||
|
ext = os.path.splitext(image_path)[1]
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as temp_file:
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
# Copy the original image to temp file
|
||||||
|
shutil.copy2(image_path, temp_path)
|
||||||
|
processed_path = temp_path
|
||||||
|
|
||||||
|
# Create a URL for the processed image
|
||||||
|
# Use a timestamp to prevent caching
|
||||||
|
timestamp = int(time.time())
|
||||||
|
url_path = f"/api/recipe/{recipe_id}/share/download?t={timestamp}"
|
||||||
|
|
||||||
|
# Store the temp path in a dictionary to serve later
|
||||||
|
if not hasattr(self, '_shared_recipes'):
|
||||||
|
self._shared_recipes = {}
|
||||||
|
|
||||||
|
self._shared_recipes[recipe_id] = {
|
||||||
|
'path': processed_path,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'expires': time.time() + 300 # Expire after 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up old entries
|
||||||
|
self._cleanup_shared_recipes()
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'download_url': url_path,
|
||||||
|
'filename': f"recipe_{recipe.get('title', '').replace(' ', '_').lower()}{ext}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sharing recipe: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def download_shared_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
"""Serve a processed recipe image for download"""
|
||||||
|
try:
|
||||||
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
|
||||||
|
# Check if we have this shared recipe
|
||||||
|
if not hasattr(self, '_shared_recipes') or recipe_id not in self._shared_recipes:
|
||||||
|
return web.json_response({"error": "Shared recipe not found or expired"}, status=404)
|
||||||
|
|
||||||
|
shared_info = self._shared_recipes[recipe_id]
|
||||||
|
file_path = shared_info['path']
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return web.json_response({"error": "Shared recipe file not found"}, status=404)
|
||||||
|
|
||||||
|
# Get recipe to determine filename
|
||||||
|
cache = await self.recipe_scanner.get_cached_data()
|
||||||
|
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
||||||
|
|
||||||
|
# Set filename for download
|
||||||
|
filename = f"recipe_{recipe.get('title', '').replace(' ', '_').lower() if recipe else recipe_id}"
|
||||||
|
ext = os.path.splitext(file_path)[1]
|
||||||
|
download_filename = f"{filename}{ext}"
|
||||||
|
|
||||||
|
# Serve the file
|
||||||
|
return web.FileResponse(
|
||||||
|
file_path,
|
||||||
|
headers={
|
||||||
|
'Content-Disposition': f'attachment; filename="{download_filename}"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading shared recipe: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
def _cleanup_shared_recipes(self):
|
||||||
|
"""Clean up expired shared recipes"""
|
||||||
|
if not hasattr(self, '_shared_recipes'):
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
expired_ids = [rid for rid, info in self._shared_recipes.items()
|
||||||
|
if current_time > info.get('expires', 0)]
|
||||||
|
|
||||||
|
for rid in expired_ids:
|
||||||
|
try:
|
||||||
|
# Delete the temporary file
|
||||||
|
file_path = self._shared_recipes[rid]['path']
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.unlink(file_path)
|
||||||
|
|
||||||
|
# Remove from dictionary
|
||||||
|
del self._shared_recipes[rid]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up shared recipe {rid}: {e}")
|
||||||
|
|
||||||
|
async def save_recipe_from_widget(self, request: web.Request) -> web.Response:
|
||||||
|
"""Save a recipe from the LoRAs widget"""
|
||||||
|
try:
|
||||||
|
reader = await request.multipart()
|
||||||
|
|
||||||
|
# Process form data
|
||||||
|
workflow_json = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
field = await reader.next()
|
||||||
|
if field is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if field.name == 'workflow_json':
|
||||||
|
workflow_text = await field.text()
|
||||||
|
try:
|
||||||
|
workflow_json = json.loads(workflow_text)
|
||||||
|
except:
|
||||||
|
return web.json_response({"error": "Invalid workflow JSON"}, status=400)
|
||||||
|
|
||||||
|
if not workflow_json:
|
||||||
|
return web.json_response({"error": "Missing required workflow_json field"}, status=400)
|
||||||
|
|
||||||
|
# Find the latest image in the temp directory
|
||||||
|
temp_dir = config.temp_directory
|
||||||
|
image_files = []
|
||||||
|
|
||||||
|
for file in os.listdir(temp_dir):
|
||||||
|
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
|
||||||
|
file_path = os.path.join(temp_dir, file)
|
||||||
|
image_files.append((file_path, os.path.getmtime(file_path)))
|
||||||
|
|
||||||
|
if not image_files:
|
||||||
|
return web.json_response({"error": "No recent images found to use for recipe"}, status=400)
|
||||||
|
|
||||||
|
# Sort by modification time (newest first)
|
||||||
|
image_files.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
latest_image_path = image_files[0][0]
|
||||||
|
|
||||||
|
# Parse the workflow to extract generation parameters and loras
|
||||||
|
parsed_workflow = self.parser.parse_workflow(workflow_json)
|
||||||
|
|
||||||
|
if not parsed_workflow or not parsed_workflow.get("gen_params"):
|
||||||
|
return web.json_response({"error": "Could not extract generation parameters from workflow"}, status=400)
|
||||||
|
|
||||||
|
# Get the lora stack from the parsed workflow
|
||||||
|
lora_stack = parsed_workflow.get("loras", "")
|
||||||
|
|
||||||
|
# Parse the lora stack format: "<lora:name:strength> <lora:name2:strength2> ..."
|
||||||
|
import re
|
||||||
|
lora_matches = re.findall(r'<lora:([^:]+):([^>]+)>', lora_stack)
|
||||||
|
|
||||||
|
# Check if any loras were found
|
||||||
|
if not lora_matches:
|
||||||
|
return web.json_response({"error": "No LoRAs found in the workflow"}, status=400)
|
||||||
|
|
||||||
|
# Generate recipe name from the first 3 loras (or less if fewer are available)
|
||||||
|
loras_for_name = lora_matches[:3] # Take at most 3 loras for the name
|
||||||
|
|
||||||
|
recipe_name_parts = []
|
||||||
|
for lora_name, lora_strength in loras_for_name:
|
||||||
|
# Get the basename without path or extension
|
||||||
|
basename = os.path.basename(lora_name)
|
||||||
|
basename = os.path.splitext(basename)[0]
|
||||||
|
recipe_name_parts.append(f"{basename}:{lora_strength}")
|
||||||
|
|
||||||
|
recipe_name = " ".join(recipe_name_parts)
|
||||||
|
|
||||||
|
# Read the image
|
||||||
|
with open(latest_image_path, 'rb') as f:
|
||||||
|
image = f.read()
|
||||||
|
|
||||||
|
# Create recipes directory if it doesn't exist
|
||||||
|
recipes_dir = self.recipe_scanner.recipes_dir
|
||||||
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate UUID for the recipe
|
||||||
|
import uuid
|
||||||
|
recipe_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Optimize the image (resize and convert to WebP)
|
||||||
|
optimized_image, extension = ExifUtils.optimize_image(
|
||||||
|
image_data=image,
|
||||||
|
target_width=480,
|
||||||
|
format='webp',
|
||||||
|
quality=85,
|
||||||
|
preserve_metadata=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the optimized image
|
||||||
|
image_filename = f"{recipe_id}{extension}"
|
||||||
|
image_path = os.path.join(recipes_dir, image_filename)
|
||||||
|
with open(image_path, 'wb') as f:
|
||||||
|
f.write(optimized_image)
|
||||||
|
|
||||||
|
# Format loras data from the lora stack
|
||||||
|
loras_data = []
|
||||||
|
|
||||||
|
for lora_name, lora_strength in lora_matches:
|
||||||
|
try:
|
||||||
|
# Get lora info from scanner
|
||||||
|
lora_info = await self.recipe_scanner._lora_scanner.get_lora_info_by_name(lora_name)
|
||||||
|
|
||||||
|
# Create lora entry
|
||||||
|
lora_entry = {
|
||||||
|
"file_name": lora_name,
|
||||||
|
"hash": lora_info.get("sha256", "").lower() if lora_info else "",
|
||||||
|
"strength": float(lora_strength),
|
||||||
|
"modelVersionId": lora_info.get("civitai", {}).get("id", "") if lora_info else "",
|
||||||
|
"modelName": lora_info.get("civitai", {}).get("model", {}).get("name", "") if lora_info else lora_name,
|
||||||
|
"modelVersionName": lora_info.get("civitai", {}).get("name", "") if lora_info else "",
|
||||||
|
"isDeleted": False
|
||||||
|
}
|
||||||
|
loras_data.append(lora_entry)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error processing LoRA {lora_name}: {e}")
|
||||||
|
|
||||||
|
# Get base model from lora scanner for the available loras
|
||||||
|
base_model_counts = {}
|
||||||
|
for lora in loras_data:
|
||||||
|
lora_info = await self.recipe_scanner._lora_scanner.get_lora_info_by_name(lora.get("file_name", ""))
|
||||||
|
if lora_info and "base_model" in lora_info:
|
||||||
|
base_model = lora_info["base_model"]
|
||||||
|
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||||
|
|
||||||
|
# Get most common base model
|
||||||
|
most_common_base_model = ""
|
||||||
|
if base_model_counts:
|
||||||
|
most_common_base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
|
# Create the recipe data structure
|
||||||
|
recipe_data = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": image_path,
|
||||||
|
"title": recipe_name, # Use generated recipe name
|
||||||
|
"modified": time.time(),
|
||||||
|
"created_date": time.time(),
|
||||||
|
"base_model": most_common_base_model,
|
||||||
|
"loras": loras_data,
|
||||||
|
"gen_params": parsed_workflow.get("gen_params", {}), # Use the parsed workflow parameters
|
||||||
|
"loras_stack": lora_stack # Include the original lora stack string
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save the recipe JSON
|
||||||
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
|
with open(json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Add recipe metadata to the image
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
if self.recipe_scanner._cache is not None:
|
||||||
|
# Add the recipe to the raw data if the cache exists
|
||||||
|
self.recipe_scanner._cache.raw_data.append(recipe_data)
|
||||||
|
# Schedule a background task to resort the cache
|
||||||
|
asyncio.create_task(self.recipe_scanner._cache.resort())
|
||||||
|
logger.info(f"Added recipe {recipe_id} to cache")
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'recipe_id': recipe_id,
|
||||||
|
'image_path': image_path,
|
||||||
|
'json_path': json_path,
|
||||||
|
'recipe_name': recipe_name # Include the generated recipe name in the response
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving recipe from widget: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
@@ -24,11 +24,9 @@ class UpdateRoutes:
|
|||||||
try:
|
try:
|
||||||
# Read local version from pyproject.toml
|
# Read local version from pyproject.toml
|
||||||
local_version = UpdateRoutes._get_local_version()
|
local_version = UpdateRoutes._get_local_version()
|
||||||
logger.info(f"Local version: {local_version}")
|
|
||||||
|
|
||||||
# Fetch remote version from GitHub
|
# Fetch remote version from GitHub
|
||||||
remote_version, changelog = await UpdateRoutes._get_remote_version()
|
remote_version, changelog = await UpdateRoutes._get_remote_version()
|
||||||
logger.info(f"Remote version: {remote_version}")
|
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
update_available = UpdateRoutes._compare_versions(
|
update_available = UpdateRoutes._compare_versions(
|
||||||
@@ -36,8 +34,6 @@ class UpdateRoutes:
|
|||||||
remote_version.replace('v', '')
|
remote_version.replace('v', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Update available: {update_available}")
|
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'current_version': local_version,
|
'current_version': local_version,
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ class CivitaiClient:
|
|||||||
headers = self._get_request_headers()
|
headers = self._get_request_headers()
|
||||||
async with session.get(url, headers=headers, allow_redirects=True) as response:
|
async with session.get(url, headers=headers, allow_redirects=True) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
|
# Handle early access 401 unauthorized responses
|
||||||
|
if response.status == 401:
|
||||||
|
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
||||||
|
return False, "Early access restriction: You must purchase early access to download this LoRA."
|
||||||
|
|
||||||
|
# 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}"
|
return False, f"Download failed with status {response.status}"
|
||||||
|
|
||||||
# Get filename from content-disposition header
|
# Get filename from content-disposition header
|
||||||
@@ -214,4 +225,30 @@ class CivitaiClient:
|
|||||||
"""Close the session if it exists"""
|
"""Close the session if it exists"""
|
||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
await self._session.close()
|
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
|
||||||
|
|
||||||
|
logger.info(f"Fetching model version info from Civitai for ID: {model_version_id}")
|
||||||
|
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'):
|
||||||
|
logger.warning(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.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
|
||||||
|
|
||||||
|
logger.warning(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
|
||||||
@@ -28,6 +28,25 @@ class DownloadManager:
|
|||||||
if not version_info:
|
if not version_info:
|
||||||
return {'success': False, 'error': 'Failed to fetch model metadata'}
|
return {'success': False, 'error': 'Failed to fetch model metadata'}
|
||||||
|
|
||||||
|
# Check if this is an early access LoRA
|
||||||
|
if 'earlyAccessEndsAt' in version_info:
|
||||||
|
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
|
# Report initial progress
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
await progress_callback(0)
|
await progress_callback(0)
|
||||||
@@ -42,11 +61,18 @@ class DownloadManager:
|
|||||||
save_path = os.path.join(save_dir, file_name)
|
save_path = os.path.join(save_dir, file_name)
|
||||||
file_size = file_info.get('sizeKB', 0) * 1024
|
file_size = file_info.get('sizeKB', 0) * 1024
|
||||||
|
|
||||||
# 4. 通知文件监控系统
|
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
|
||||||
self.file_monitor.handler.add_ignore_path(
|
if self.file_monitor and self.file_monitor.handler:
|
||||||
save_path.replace(os.sep, '/'),
|
# Add both the normalized path and potential alternative paths
|
||||||
file_size
|
normalized_path = save_path.replace(os.sep, '/')
|
||||||
)
|
self.file_monitor.handler.add_ignore_path(normalized_path, file_size)
|
||||||
|
|
||||||
|
# Also add the path with file extension variations (.safetensors)
|
||||||
|
if not normalized_path.endswith('.safetensors'):
|
||||||
|
safetensors_path = os.path.splitext(normalized_path)[0] + '.safetensors'
|
||||||
|
self.file_monitor.handler.add_ignore_path(safetensors_path, file_size)
|
||||||
|
|
||||||
|
logger.debug(f"Added download path to ignore list: {normalized_path} (size: {file_size} bytes)")
|
||||||
|
|
||||||
# 5. 准备元数据
|
# 5. 准备元数据
|
||||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
@@ -75,6 +101,10 @@ class DownloadManager:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
|
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)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
async def _execute_download(self, download_url: str, save_dir: str,
|
async def _execute_download(self, download_url: str, save_dir: str,
|
||||||
@@ -135,6 +165,12 @@ class DownloadManager:
|
|||||||
all_folders = set(cache.folders)
|
all_folders = set(cache.folders)
|
||||||
all_folders.add(relative_path)
|
all_folders.add(relative_path)
|
||||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
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
|
# Report 100% completion
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|||||||
@@ -20,29 +20,75 @@ class LoraFileHandler(FileSystemEventHandler):
|
|||||||
self.pending_changes = set() # 待处理的变更
|
self.pending_changes = set() # 待处理的变更
|
||||||
self.lock = Lock() # 线程安全锁
|
self.lock = Lock() # 线程安全锁
|
||||||
self.update_task = None # 异步更新任务
|
self.update_task = None # 异步更新任务
|
||||||
self._ignore_paths = set() # Add ignore paths set
|
self._ignore_paths = {} # Change to dictionary to store expiration times
|
||||||
self._min_ignore_timeout = 5 # minimum timeout in seconds
|
self._min_ignore_timeout = 5 # minimum timeout in seconds
|
||||||
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
|
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
|
||||||
|
|
||||||
def _should_ignore(self, path: str) -> bool:
|
def _should_ignore(self, path: str) -> bool:
|
||||||
"""Check if path should be ignored"""
|
"""Check if path should be ignored"""
|
||||||
real_path = os.path.realpath(path) # Resolve any symbolic links
|
real_path = os.path.realpath(path) # Resolve any symbolic links
|
||||||
return real_path.replace(os.sep, '/') in self._ignore_paths
|
normalized_path = real_path.replace(os.sep, '/')
|
||||||
|
|
||||||
|
# Also check with backslashes for Windows compatibility
|
||||||
|
alt_path = real_path.replace('/', '\\')
|
||||||
|
|
||||||
|
# 使用传入的事件循环而不是尝试获取当前线程的事件循环
|
||||||
|
current_time = self.loop.time()
|
||||||
|
|
||||||
|
# Check if path is in ignore list and not expired
|
||||||
|
if normalized_path in self._ignore_paths and self._ignore_paths[normalized_path] > current_time:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Also check alternative path format
|
||||||
|
if alt_path in self._ignore_paths and self._ignore_paths[alt_path] > current_time:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def add_ignore_path(self, path: str, file_size: int = 0):
|
def add_ignore_path(self, path: str, file_size: int = 0):
|
||||||
"""Add path to ignore list with dynamic timeout based on file size"""
|
"""Add path to ignore list with dynamic timeout based on file size"""
|
||||||
real_path = os.path.realpath(path) # Resolve any symbolic links
|
real_path = os.path.realpath(path) # Resolve any symbolic links
|
||||||
self._ignore_paths.add(real_path.replace(os.sep, '/'))
|
normalized_path = real_path.replace(os.sep, '/')
|
||||||
|
|
||||||
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
|
# Calculate timeout based on file size
|
||||||
timeout = 5
|
# For small files, use minimum timeout
|
||||||
|
# For larger files, estimate download time + buffer
|
||||||
|
if file_size > 0:
|
||||||
|
# Estimate download time in seconds (size / speed) + buffer
|
||||||
|
estimated_time = (file_size / self._download_speed) + 10
|
||||||
|
timeout = max(self._min_ignore_timeout, estimated_time)
|
||||||
|
else:
|
||||||
|
timeout = self._min_ignore_timeout
|
||||||
|
|
||||||
asyncio.get_event_loop().call_later(
|
current_time = self.loop.time()
|
||||||
|
expiration_time = current_time + timeout
|
||||||
|
|
||||||
|
# Store both normalized and alternative path formats
|
||||||
|
self._ignore_paths[normalized_path] = expiration_time
|
||||||
|
|
||||||
|
# Also store with backslashes for Windows compatibility
|
||||||
|
alt_path = real_path.replace('/', '\\')
|
||||||
|
self._ignore_paths[alt_path] = expiration_time
|
||||||
|
|
||||||
|
logger.debug(f"Added ignore path: {normalized_path} (expires in {timeout:.1f}s)")
|
||||||
|
|
||||||
|
self.loop.call_later(
|
||||||
timeout,
|
timeout,
|
||||||
self._ignore_paths.discard,
|
self._remove_ignore_path,
|
||||||
real_path.replace(os.sep, '/')
|
normalized_path
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _remove_ignore_path(self, path: str):
|
||||||
|
"""Remove path from ignore list after timeout"""
|
||||||
|
if path in self._ignore_paths:
|
||||||
|
del self._ignore_paths[path]
|
||||||
|
logger.debug(f"Removed ignore path: {path}")
|
||||||
|
|
||||||
|
# Also remove alternative path format
|
||||||
|
alt_path = path.replace('/', '\\')
|
||||||
|
if alt_path in self._ignore_paths:
|
||||||
|
del self._ignore_paths[alt_path]
|
||||||
|
|
||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ class LoraHashIndex:
|
|||||||
"""Add or update a hash -> path mapping"""
|
"""Add or update a hash -> path mapping"""
|
||||||
if not sha256 or not file_path:
|
if not sha256 or not file_path:
|
||||||
return
|
return
|
||||||
self._hash_to_path[sha256] = file_path
|
# Always store lowercase hashes for consistency
|
||||||
|
self._hash_to_path[sha256.lower()] = file_path
|
||||||
|
|
||||||
def remove_entry(self, sha256: str) -> None:
|
def remove_entry(self, sha256: str) -> None:
|
||||||
"""Remove a hash entry"""
|
"""Remove a hash entry"""
|
||||||
self._hash_to_path.pop(sha256, None)
|
if sha256:
|
||||||
|
self._hash_to_path.pop(sha256.lower(), None)
|
||||||
|
|
||||||
def remove_by_path(self, file_path: str) -> None:
|
def remove_by_path(self, file_path: str) -> None:
|
||||||
"""Remove entry by file path"""
|
"""Remove entry by file path"""
|
||||||
@@ -30,7 +32,9 @@ class LoraHashIndex:
|
|||||||
|
|
||||||
def get_path(self, sha256: str) -> Optional[str]:
|
def get_path(self, sha256: str) -> Optional[str]:
|
||||||
"""Get file path for a given hash"""
|
"""Get file path for a given hash"""
|
||||||
return self._hash_to_path.get(sha256)
|
if not sha256:
|
||||||
|
return None
|
||||||
|
return self._hash_to_path.get(sha256.lower())
|
||||||
|
|
||||||
def get_hash(self, file_path: str) -> Optional[str]:
|
def get_hash(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a given file path"""
|
"""Get hash for a given file path"""
|
||||||
@@ -41,7 +45,9 @@ class LoraHashIndex:
|
|||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if hash exists in index"""
|
"""Check if hash exists in index"""
|
||||||
return sha256 in self._hash_to_path
|
if not sha256:
|
||||||
|
return False
|
||||||
|
return sha256.lower() in self._hash_to_path
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all entries"""
|
"""Clear all entries"""
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ from operator import itemgetter
|
|||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.file_utils import load_metadata, get_file_info
|
from ..utils.file_utils import load_metadata, get_file_info
|
||||||
from .lora_cache import LoraCache
|
from .lora_cache import LoraCache
|
||||||
from difflib import SequenceMatcher
|
|
||||||
from .lora_hash_index import LoraHashIndex
|
from .lora_hash_index import LoraHashIndex
|
||||||
from .settings_manager import settings
|
from .settings_manager import settings
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from ..utils.constants import NSFW_LEVELS
|
||||||
|
from ..utils.utils import fuzzy_match
|
||||||
|
import sys
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ class LoraScanner:
|
|||||||
# Build hash index and tags count
|
# Build hash index and tags count
|
||||||
for lora_data in raw_data:
|
for lora_data in raw_data:
|
||||||
if 'sha256' in lora_data and 'file_path' in lora_data:
|
if 'sha256' in lora_data and 'file_path' in lora_data:
|
||||||
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path'])
|
self._hash_index.add_entry(lora_data['sha256'].lower(), lora_data['file_path'])
|
||||||
|
|
||||||
# Count tags
|
# Count tags
|
||||||
if 'tags' in lora_data and lora_data['tags']:
|
if 'tags' in lora_data and lora_data['tags']:
|
||||||
@@ -131,45 +132,9 @@ class LoraScanner:
|
|||||||
folders=[]
|
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',
|
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
||||||
folder: str = None, search: str = None, fuzzy: bool = False,
|
folder: str = None, search: str = None, fuzzy: bool = False,
|
||||||
recursive: bool = False, base_models: list = None, tags: list = None,
|
base_models: list = None, tags: list = None,
|
||||||
search_options: dict = None) -> Dict:
|
search_options: dict = None) -> Dict:
|
||||||
"""Get paginated and filtered lora data
|
"""Get paginated and filtered lora data
|
||||||
|
|
||||||
@@ -180,10 +145,9 @@ class LoraScanner:
|
|||||||
folder: Filter by folder path
|
folder: Filter by folder path
|
||||||
search: Search term
|
search: Search term
|
||||||
fuzzy: Use fuzzy matching for search
|
fuzzy: Use fuzzy matching for search
|
||||||
recursive: Include subfolders when folder filter is applied
|
|
||||||
base_models: List of base models to filter by
|
base_models: List of base models to filter by
|
||||||
tags: List of tags to filter by
|
tags: List of tags to filter by
|
||||||
search_options: Dictionary with search options (filename, modelname, tags)
|
search_options: Dictionary with search options (filename, modelname, tags, recursive)
|
||||||
"""
|
"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -192,7 +156,8 @@ class LoraScanner:
|
|||||||
search_options = {
|
search_options = {
|
||||||
'filename': True,
|
'filename': True,
|
||||||
'modelname': True,
|
'modelname': True,
|
||||||
'tags': False
|
'tags': False,
|
||||||
|
'recursive': False
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the base data set
|
# Get the base data set
|
||||||
@@ -207,7 +172,7 @@ class LoraScanner:
|
|||||||
|
|
||||||
# Apply folder filtering
|
# Apply folder filtering
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
if recursive:
|
if search_options.get('recursive', False):
|
||||||
# Recursive mode: match all paths starting with this folder
|
# Recursive mode: match all paths starting with this folder
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
item for item in filtered_data
|
||||||
@@ -236,16 +201,47 @@ class LoraScanner:
|
|||||||
|
|
||||||
# Apply search filtering
|
# Apply search filtering
|
||||||
if search:
|
if search:
|
||||||
if fuzzy:
|
search_results = []
|
||||||
filtered_data = [
|
for item in filtered_data:
|
||||||
item for item in filtered_data
|
# Check filename if enabled
|
||||||
if self._fuzzy_search_match(item, search, search_options)
|
if search_options.get('filename', True):
|
||||||
]
|
if fuzzy:
|
||||||
else:
|
if fuzzy_match(item.get('file_name', ''), search):
|
||||||
filtered_data = [
|
search_results.append(item)
|
||||||
item for item in filtered_data
|
continue
|
||||||
if self._exact_search_match(item, search, search_options)
|
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
|
# Calculate pagination
|
||||||
total_items = len(filtered_data)
|
total_items = len(filtered_data)
|
||||||
@@ -262,44 +258,6 @@ class LoraScanner:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _fuzzy_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
|
|
||||||
"""Check if an item matches the search term using fuzzy matching with search options"""
|
|
||||||
# Check filename if enabled
|
|
||||||
if search_options.get('filename', True) and self.fuzzy_match(item.get('file_name', ''), search):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check model name if enabled
|
|
||||||
if search_options.get('modelname', True) and self.fuzzy_match(item.get('model_name', ''), search):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check tags if enabled
|
|
||||||
if search_options.get('tags', False) and item.get('tags'):
|
|
||||||
for tag in item['tags']:
|
|
||||||
if self.fuzzy_match(tag, search):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _exact_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
|
|
||||||
"""Check if an item matches the search term using exact matching with search options"""
|
|
||||||
search = search.lower()
|
|
||||||
|
|
||||||
# Check filename if enabled
|
|
||||||
if search_options.get('filename', True) and search in item.get('file_name', '').lower():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check model name if enabled
|
|
||||||
if search_options.get('modelname', True) and search in item.get('model_name', '').lower():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check tags if enabled
|
|
||||||
if search_options.get('tags', False) and item.get('tags'):
|
|
||||||
for tag in item['tags']:
|
|
||||||
if search in tag.lower():
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def invalidate_cache(self):
|
def invalidate_cache(self):
|
||||||
"""Invalidate the current cache"""
|
"""Invalidate the current cache"""
|
||||||
self._cache = None
|
self._cache = None
|
||||||
@@ -604,7 +562,7 @@ class LoraScanner:
|
|||||||
|
|
||||||
# Update hash index with new path
|
# Update hash index with new path
|
||||||
if 'sha256' in metadata:
|
if 'sha256' in metadata:
|
||||||
self._hash_index.add_entry(metadata['sha256'], new_path)
|
self._hash_index.add_entry(metadata['sha256'].lower(), new_path)
|
||||||
|
|
||||||
# Update folders list
|
# Update folders list
|
||||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||||
@@ -649,15 +607,35 @@ class LoraScanner:
|
|||||||
# Add new methods for hash index functionality
|
# Add new methods for hash index functionality
|
||||||
def has_lora_hash(self, sha256: str) -> bool:
|
def has_lora_hash(self, sha256: str) -> bool:
|
||||||
"""Check if a LoRA with given hash exists"""
|
"""Check if a LoRA with given hash exists"""
|
||||||
return self._hash_index.has_hash(sha256)
|
return self._hash_index.has_hash(sha256.lower())
|
||||||
|
|
||||||
def get_lora_path_by_hash(self, sha256: str) -> Optional[str]:
|
def get_lora_path_by_hash(self, sha256: str) -> Optional[str]:
|
||||||
"""Get file path for a LoRA by its hash"""
|
"""Get file path for a LoRA by its hash"""
|
||||||
return self._hash_index.get_path(sha256)
|
return self._hash_index.get_path(sha256.lower())
|
||||||
|
|
||||||
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
|
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a LoRA by its file path"""
|
"""Get hash for a LoRA by its file path"""
|
||||||
return self._hash_index.get_hash(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
|
# Add new method to get top tags
|
||||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||||
@@ -681,4 +659,81 @@ class LoraScanner:
|
|||||||
|
|
||||||
# Return limited number
|
# Return limited number
|
||||||
return sorted_tags[:limit]
|
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
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
# 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
|
||||||
451
py/services/recipe_scanner.py
Normal file
451
py/services/recipe_scanner.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import List, Dict, Optional, Any
|
||||||
|
from datetime import datetime
|
||||||
|
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:
|
||||||
|
# Use a timeout for acquiring the lock
|
||||||
|
async with asyncio.timeout(1.0):
|
||||||
|
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:
|
||||||
|
# First ensure the lora scanner is initialized
|
||||||
|
if self._lora_scanner:
|
||||||
|
try:
|
||||||
|
lora_cache = await asyncio.wait_for(
|
||||||
|
self._lora_scanner.get_cached_data(),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("Timeout waiting for lora scanner initialization")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error waiting for lora scanner: {e}")
|
||||||
|
|
||||||
|
# Scan for recipe data
|
||||||
|
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 asyncio.TimeoutError:
|
||||||
|
# If we can't acquire the lock in time, return the current cache or an empty one
|
||||||
|
logger.warning("Timeout acquiring initialization lock - returning current cache state")
|
||||||
|
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
||||||
|
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.warning(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.warning(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.warning(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.warning(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
|
||||||
486
py/utils/exif_utils.py
Normal file
486
py/utils/exif_utils.py
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import piexif
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
from io import BytesIO
|
||||||
|
import os
|
||||||
|
from PIL import Image
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ExifUtils:
|
||||||
|
"""Utility functions for working with EXIF data in images"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_user_comment(image_path: str) -> Optional[str]:
|
||||||
|
"""Extract UserComment field from image EXIF data"""
|
||||||
|
try:
|
||||||
|
# First try to open as image to check format
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
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
|
||||||
|
return None
|
||||||
|
|
||||||
|
# For JPEG/TIFF/WEBP, use piexif
|
||||||
|
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
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_user_comment(image_path: str, user_comment: str) -> str:
|
||||||
|
"""Update UserComment field in image EXIF data"""
|
||||||
|
try:
|
||||||
|
# Load the image and its EXIF data
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
# Get original format
|
||||||
|
img_format = img.format
|
||||||
|
|
||||||
|
# For WebP format, we need a different approach
|
||||||
|
if img_format == 'WEBP':
|
||||||
|
# WebP doesn't support standard EXIF through piexif
|
||||||
|
# We'll use PIL's exif parameter directly
|
||||||
|
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + user_comment.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 the standard approach
|
||||||
|
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 = user_comment.encode('utf-16be')
|
||||||
|
user_comment_bytes = b'UNICODE\0' + unicode_bytes
|
||||||
|
|
||||||
|
exif_dict['Exif'][piexif.ExifIFD.UserComment] = user_comment_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 EXIF data 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 user comment
|
||||||
|
user_comment = ExifUtils.extract_user_comment(image_path)
|
||||||
|
|
||||||
|
# Check if there's already recipe metadata in the user comment
|
||||||
|
if user_comment:
|
||||||
|
# Remove any existing recipe metadata
|
||||||
|
user_comment = ExifUtils.remove_recipe_metadata(user_comment)
|
||||||
|
|
||||||
|
# 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 user comment or create new one
|
||||||
|
new_user_comment = f"{user_comment} \n {recipe_metadata_marker}" if user_comment else recipe_metadata_marker
|
||||||
|
|
||||||
|
# Write back to the image
|
||||||
|
return ExifUtils.update_user_comment(image_path, new_user_comment)
|
||||||
|
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
|
||||||
|
user_comment = None
|
||||||
|
if preserve_metadata:
|
||||||
|
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||||
|
# It's a file path
|
||||||
|
user_comment = ExifUtils.extract_user_comment(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)
|
||||||
|
user_comment = ExifUtils.extract_user_comment(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 user_comment:
|
||||||
|
# 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' + user_comment.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_user_comment(temp_path, user_comment)
|
||||||
|
|
||||||
|
# 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'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_comfyui_workflow(workflow_data: Any) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse ComfyUI workflow data and extract relevant generation parameters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow_data: Raw workflow data (string or dict)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted generation parameters dictionary
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# If workflow_data is a string, try to parse it as JSON
|
||||||
|
if isinstance(workflow_data, str):
|
||||||
|
try:
|
||||||
|
workflow_data = json.loads(workflow_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Failed to parse workflow data as JSON")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Now workflow_data should be a dictionary
|
||||||
|
if not isinstance(workflow_data, dict):
|
||||||
|
logger.error(f"Workflow data is not a dictionary: {type(workflow_data)}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Initialize parameters dictionary with only the required fields
|
||||||
|
gen_params = {
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"steps": "",
|
||||||
|
"sampler": "",
|
||||||
|
"cfg_scale": "",
|
||||||
|
"seed": "",
|
||||||
|
"size": "",
|
||||||
|
"clip_skip": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# First pass: find the KSampler node to get basic parameters and node references
|
||||||
|
# Store node references to follow for prompts
|
||||||
|
positive_ref = None
|
||||||
|
negative_ref = None
|
||||||
|
|
||||||
|
for node_id, node_data in workflow_data.items():
|
||||||
|
if not isinstance(node_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract node inputs if available
|
||||||
|
inputs = node_data.get("inputs", {})
|
||||||
|
if not inputs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# KSampler nodes contain most generation parameters and references to prompt nodes
|
||||||
|
if "KSampler" in node_data.get("class_type", ""):
|
||||||
|
# Extract basic sampling parameters
|
||||||
|
gen_params["steps"] = inputs.get("steps", "")
|
||||||
|
gen_params["cfg_scale"] = inputs.get("cfg", "")
|
||||||
|
gen_params["sampler"] = inputs.get("sampler_name", "")
|
||||||
|
gen_params["seed"] = inputs.get("seed", "")
|
||||||
|
if isinstance(gen_params["seed"], list) and len(gen_params["seed"]) > 1:
|
||||||
|
gen_params["seed"] = gen_params["seed"][1] # Use the actual value if it's a list
|
||||||
|
|
||||||
|
# Get references to positive and negative prompt nodes
|
||||||
|
positive_ref = inputs.get("positive", "")
|
||||||
|
negative_ref = inputs.get("negative", "")
|
||||||
|
|
||||||
|
# CLIPSetLastLayer contains clip_skip information
|
||||||
|
elif "CLIPSetLastLayer" in node_data.get("class_type", ""):
|
||||||
|
gen_params["clip_skip"] = inputs.get("stop_at_clip_layer", "")
|
||||||
|
if isinstance(gen_params["clip_skip"], int) and gen_params["clip_skip"] < 0:
|
||||||
|
# Convert negative layer index to positive clip skip value
|
||||||
|
gen_params["clip_skip"] = abs(gen_params["clip_skip"])
|
||||||
|
|
||||||
|
# Look for resolution information
|
||||||
|
elif "LatentImage" in node_data.get("class_type", "") or "Empty" in node_data.get("class_type", ""):
|
||||||
|
width = inputs.get("width", 0)
|
||||||
|
height = inputs.get("height", 0)
|
||||||
|
if width and height:
|
||||||
|
gen_params["size"] = f"{width}x{height}"
|
||||||
|
|
||||||
|
# Some nodes have resolution as a string like "832x1216 (0.68)"
|
||||||
|
resolution = inputs.get("resolution", "")
|
||||||
|
if isinstance(resolution, str) and "x" in resolution:
|
||||||
|
gen_params["size"] = resolution.split(" ")[0] # Extract just the dimensions
|
||||||
|
|
||||||
|
# Helper function to follow node references and extract text content
|
||||||
|
def get_text_from_node_ref(node_ref, workflow_data):
|
||||||
|
if not node_ref or not isinstance(node_ref, list) or len(node_ref) < 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
node_id, slot_idx = node_ref
|
||||||
|
|
||||||
|
# If we can't find the node, return empty string
|
||||||
|
if node_id not in workflow_data:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
node = workflow_data[node_id]
|
||||||
|
inputs = node.get("inputs", {})
|
||||||
|
|
||||||
|
# Direct text input in CLIP Text Encode nodes
|
||||||
|
if "CLIPTextEncode" in node.get("class_type", ""):
|
||||||
|
text = inputs.get("text", "")
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
elif isinstance(text, list) and len(text) >= 2:
|
||||||
|
# If text is a reference to another node, follow it
|
||||||
|
return get_text_from_node_ref(text, workflow_data)
|
||||||
|
|
||||||
|
# Other nodes might have text input with different field names
|
||||||
|
for field_name, field_value in inputs.items():
|
||||||
|
if field_name == "text" and isinstance(field_value, str):
|
||||||
|
return field_value
|
||||||
|
elif isinstance(field_value, list) and len(field_value) >= 2 and field_name in ["text"]:
|
||||||
|
# If it's a reference to another node, follow it
|
||||||
|
return get_text_from_node_ref(field_value, workflow_data)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Extract prompts by following references from KSampler node
|
||||||
|
if positive_ref:
|
||||||
|
gen_params["prompt"] = get_text_from_node_ref(positive_ref, workflow_data)
|
||||||
|
|
||||||
|
if negative_ref:
|
||||||
|
gen_params["negative_prompt"] = get_text_from_node_ref(negative_ref, workflow_data)
|
||||||
|
|
||||||
|
# Fallback: if we couldn't extract prompts via references, use the traditional method
|
||||||
|
if not gen_params["prompt"] or not gen_params["negative_prompt"]:
|
||||||
|
for node_id, node_data in workflow_data.items():
|
||||||
|
if not isinstance(node_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
inputs = node_data.get("inputs", {})
|
||||||
|
if not inputs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "CLIPTextEncode" in node_data.get("class_type", ""):
|
||||||
|
# Check for negative prompt nodes
|
||||||
|
title = node_data.get("_meta", {}).get("title", "").lower()
|
||||||
|
prompt_text = inputs.get("text", "")
|
||||||
|
|
||||||
|
if isinstance(prompt_text, str):
|
||||||
|
if "negative" in title and not gen_params["negative_prompt"]:
|
||||||
|
gen_params["negative_prompt"] = prompt_text
|
||||||
|
elif prompt_text and not "negative" in title and not gen_params["prompt"]:
|
||||||
|
gen_params["prompt"] = prompt_text
|
||||||
|
|
||||||
|
return gen_params
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing ComfyUI workflow: {e}", exc_info=True)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_comfyui_gen_params(image_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract ComfyUI workflow data from PNG images and format for recipe data
|
||||||
|
Only extracts the specific generation parameters needed for recipes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the ComfyUI-generated PNG image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing formatted generation parameters
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if the file exists and is accessible
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.error(f"Image file not found: {image_path}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Open the image to extract embedded workflow data
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
workflow_data = None
|
||||||
|
|
||||||
|
# For PNG images, look for the ComfyUI workflow data in PNG chunks
|
||||||
|
if img.format == 'PNG':
|
||||||
|
# Check standard metadata fields that might contain workflow
|
||||||
|
if 'parameters' in img.info:
|
||||||
|
workflow_data = img.info['parameters']
|
||||||
|
elif 'prompt' in img.info:
|
||||||
|
workflow_data = img.info['prompt']
|
||||||
|
else:
|
||||||
|
# Look for other potential field names that might contain workflow data
|
||||||
|
for key in img.info:
|
||||||
|
if isinstance(key, str) and ('workflow' in key.lower() or 'comfy' in key.lower()):
|
||||||
|
workflow_data = img.info[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no workflow data found in PNG chunks, try EXIF as fallback
|
||||||
|
if not workflow_data:
|
||||||
|
user_comment = ExifUtils.extract_user_comment(image_path)
|
||||||
|
if user_comment and '{' in user_comment and '}' in user_comment:
|
||||||
|
# Try to extract JSON part
|
||||||
|
json_start = user_comment.find('{')
|
||||||
|
json_end = user_comment.rfind('}') + 1
|
||||||
|
workflow_data = user_comment[json_start:json_end]
|
||||||
|
|
||||||
|
# Parse workflow data if found
|
||||||
|
if workflow_data:
|
||||||
|
return ExifUtils._parse_comfyui_workflow(workflow_data)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting ComfyUI gen params from {image_path}: {e}", exc_info=True)
|
||||||
|
return {}
|
||||||
@@ -4,6 +4,8 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from .model_utils import determine_base_model
|
||||||
|
|
||||||
from .lora_metadata import extract_lora_metadata
|
from .lora_metadata import extract_lora_metadata
|
||||||
from .models import LoraMetadata
|
from .models import LoraMetadata
|
||||||
|
|
||||||
@@ -105,6 +107,12 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
needs_update = False
|
needs_update = False
|
||||||
|
|
||||||
|
# Check and normalize base model name
|
||||||
|
normalized_base_model = determine_base_model(data['base_model'])
|
||||||
|
if data['base_model'] != normalized_base_model:
|
||||||
|
data['base_model'] = normalized_base_model
|
||||||
|
needs_update = True
|
||||||
|
|
||||||
# Compare paths without extensions
|
# Compare paths without extensions
|
||||||
stored_path_base = os.path.splitext(data['file_path'])[0]
|
stored_path_base = os.path.splitext(data['file_path'])[0]
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ from typing import Optional
|
|||||||
|
|
||||||
# Base model mapping based on version string
|
# Base model mapping based on version string
|
||||||
BASE_MODEL_MAPPING = {
|
BASE_MODEL_MAPPING = {
|
||||||
|
"sd_1.5": "SD 1.5",
|
||||||
"sd-v1-5": "SD 1.5",
|
"sd-v1-5": "SD 1.5",
|
||||||
"sd-v2-1": "SD 2.1",
|
"sd-v2-1": "SD 2.1",
|
||||||
"sdxl": "SDXL 1.0",
|
"sdxl": "SDXL 1.0",
|
||||||
"sd-v2": "SD 2.0",
|
"sd-v2": "SD 2.0",
|
||||||
"flux1": "Flux.1 D",
|
"flux1": "Flux.1 D",
|
||||||
"flux.1 d": "Flux.1 D",
|
"flux.1 d": "Flux.1 D",
|
||||||
"illustrious": "IL",
|
"illustrious": "Illustrious",
|
||||||
|
"il": "Illustrious",
|
||||||
"pony": "Pony",
|
"pony": "Pony",
|
||||||
"Hunyuan Video": "Hunyuan Video"
|
"Hunyuan Video": "Hunyuan Video"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class LoraMetadata:
|
|||||||
file_path=save_path.replace(os.sep, '/'),
|
file_path=save_path.replace(os.sep, '/'),
|
||||||
size=file_info.get('sizeKB', 0) * 1024,
|
size=file_info.get('sizeKB', 0) * 1024,
|
||||||
modified=datetime.now().timestamp(),
|
modified=datetime.now().timestamp(),
|
||||||
sha256=file_info['hashes'].get('SHA256', ''),
|
sha256=file_info['hashes'].get('SHA256', '').lower(),
|
||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
preview_url=None, # Will be updated after preview download
|
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
|
preview_nsfw_level=0, # Will be updated after preview download, it is decided by the nsfw level of the preview image
|
||||||
|
|||||||
547
py/utils/recipe_parsers.py
Normal file
547
py/utils/recipe_parsers.py
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants for generation parameters
|
||||||
|
GEN_PARAM_KEYS = [
|
||||||
|
'prompt',
|
||||||
|
'negative_prompt',
|
||||||
|
'steps',
|
||||||
|
'sampler',
|
||||||
|
'cfg_scale',
|
||||||
|
'seed',
|
||||||
|
'size',
|
||||||
|
'clip_skip',
|
||||||
|
]
|
||||||
|
|
||||||
|
class RecipeMetadataParser(ABC):
|
||||||
|
"""Interface for parsing recipe metadata from image user comments"""
|
||||||
|
|
||||||
|
METADATA_MARKER = None
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
|
"""Check if the user comment matches the metadata format"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse metadata from user comment and return structured recipe data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_comment: The EXIF UserComment string from the image
|
||||||
|
recipe_scanner: Optional recipe scanner instance for local LoRA lookup
|
||||||
|
civitai_client: Optional Civitai client for fetching model information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing parsed recipe data with standardized format
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeFormatParser(RecipeMetadataParser):
|
||||||
|
"""Parser for images with dedicated recipe metadata format"""
|
||||||
|
|
||||||
|
# Regular expression pattern for extracting recipe metadata
|
||||||
|
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
||||||
|
|
||||||
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
|
"""Check if the user comment matches the metadata format"""
|
||||||
|
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
||||||
|
|
||||||
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
|
"""Parse metadata from images with dedicated recipe metadata format"""
|
||||||
|
try:
|
||||||
|
# Extract recipe metadata from user comment
|
||||||
|
try:
|
||||||
|
# Look for recipe metadata section
|
||||||
|
recipe_match = re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL)
|
||||||
|
if not recipe_match:
|
||||||
|
recipe_metadata = None
|
||||||
|
else:
|
||||||
|
recipe_json = recipe_match.group(1)
|
||||||
|
recipe_metadata = json.loads(recipe_json)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting recipe metadata: {e}")
|
||||||
|
recipe_metadata = None
|
||||||
|
if not recipe_metadata:
|
||||||
|
return {"error": "No recipe metadata found", "loras": []}
|
||||||
|
|
||||||
|
# Process the recipe metadata
|
||||||
|
loras = []
|
||||||
|
for lora in recipe_metadata.get('loras', []):
|
||||||
|
# Convert recipe lora format to frontend format
|
||||||
|
lora_entry = {
|
||||||
|
'id': lora.get('modelVersionId', ''),
|
||||||
|
'name': lora.get('modelName', ''),
|
||||||
|
'version': lora.get('modelVersionName', ''),
|
||||||
|
'type': 'lora',
|
||||||
|
'weight': lora.get('strength', 1.0),
|
||||||
|
'file_name': lora.get('file_name', ''),
|
||||||
|
'hash': lora.get('hash', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if this LoRA exists locally by SHA256 hash
|
||||||
|
if lora.get('hash') and recipe_scanner:
|
||||||
|
lora_scanner = recipe_scanner._lora_scanner
|
||||||
|
exists_locally = lora_scanner.has_lora_hash(lora['hash'])
|
||||||
|
if exists_locally:
|
||||||
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
|
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||||
|
if lora_item:
|
||||||
|
lora_entry['existsLocally'] = True
|
||||||
|
lora_entry['localPath'] = lora_item['file_path']
|
||||||
|
lora_entry['file_name'] = lora_item['file_name']
|
||||||
|
lora_entry['size'] = lora_item['size']
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
lora_entry['existsLocally'] = False
|
||||||
|
lora_entry['localPath'] = None
|
||||||
|
|
||||||
|
# Try to get additional info from Civitai if we have a model version ID
|
||||||
|
if lora.get('modelVersionId') and civitai_client:
|
||||||
|
try:
|
||||||
|
civitai_info = await civitai_client.get_model_version_info(lora['modelVersionId'])
|
||||||
|
if civitai_info and civitai_info.get("error") != "Model not found":
|
||||||
|
# Check if this is an early access lora
|
||||||
|
if civitai_info.get('earlyAccessEndsAt'):
|
||||||
|
# Convert earlyAccessEndsAt to a human-readable date
|
||||||
|
early_access_date = civitai_info.get('earlyAccessEndsAt', '')
|
||||||
|
lora_entry['isEarlyAccess'] = True
|
||||||
|
lora_entry['earlyAccessEndsAt'] = early_access_date
|
||||||
|
|
||||||
|
# Get thumbnail URL from first image
|
||||||
|
if 'images' in civitai_info and civitai_info['images']:
|
||||||
|
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||||
|
|
||||||
|
# Get base model
|
||||||
|
lora_entry['baseModel'] = civitai_info.get('baseModel', '')
|
||||||
|
|
||||||
|
# Get download URL
|
||||||
|
lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
||||||
|
|
||||||
|
# Get size from files if available
|
||||||
|
if 'files' in civitai_info:
|
||||||
|
model_file = next((file for file in civitai_info.get('files', [])
|
||||||
|
if file.get('type') == 'Model'), None)
|
||||||
|
if model_file:
|
||||||
|
lora_entry['size'] = model_file.get('sizeKB', 0) * 1024
|
||||||
|
else:
|
||||||
|
lora_entry['isDeleted'] = True
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
|
|
||||||
|
loras.append(lora_entry)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
||||||
|
|
||||||
|
# Filter gen_params to only include recognized keys
|
||||||
|
filtered_gen_params = {}
|
||||||
|
if 'gen_params' in recipe_metadata:
|
||||||
|
for key, value in recipe_metadata['gen_params'].items():
|
||||||
|
if key in GEN_PARAM_KEYS:
|
||||||
|
filtered_gen_params[key] = value
|
||||||
|
|
||||||
|
return {
|
||||||
|
'base_model': recipe_metadata.get('base_model', ''),
|
||||||
|
'loras': loras,
|
||||||
|
'gen_params': filtered_gen_params,
|
||||||
|
'tags': recipe_metadata.get('tags', []),
|
||||||
|
'title': recipe_metadata.get('title', ''),
|
||||||
|
'from_recipe_metadata': True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
|
||||||
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
|
||||||
|
class StandardMetadataParser(RecipeMetadataParser):
|
||||||
|
"""Parser for images with standard civitai metadata format (prompt, negative prompt, etc.)"""
|
||||||
|
|
||||||
|
METADATA_MARKER = r'Civitai resources: '
|
||||||
|
|
||||||
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
|
"""Check if the user comment matches the metadata format"""
|
||||||
|
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
||||||
|
|
||||||
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
|
"""Parse metadata from images with standard metadata format"""
|
||||||
|
try:
|
||||||
|
# Parse the standard metadata
|
||||||
|
metadata = self._parse_recipe_metadata(user_comment)
|
||||||
|
|
||||||
|
# Look for Civitai resources in the metadata
|
||||||
|
civitai_resources = metadata.get('loras', [])
|
||||||
|
checkpoint = metadata.get('checkpoint')
|
||||||
|
|
||||||
|
if not civitai_resources and not checkpoint:
|
||||||
|
return {
|
||||||
|
"error": "No LoRA information found in this image",
|
||||||
|
"loras": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process LoRAs and collect base models
|
||||||
|
base_model_counts = {}
|
||||||
|
loras = []
|
||||||
|
|
||||||
|
# Process LoRAs
|
||||||
|
for resource in civitai_resources:
|
||||||
|
# Get model version ID
|
||||||
|
model_version_id = resource.get('modelVersionId')
|
||||||
|
if not model_version_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Initialize lora entry with default values
|
||||||
|
lora_entry = {
|
||||||
|
'id': model_version_id,
|
||||||
|
'name': resource.get('modelName', ''),
|
||||||
|
'version': resource.get('modelVersionName', ''),
|
||||||
|
'type': resource.get('type', 'lora'),
|
||||||
|
'weight': resource.get('weight', 1.0),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': '',
|
||||||
|
'hash': '',
|
||||||
|
'thumbnailUrl': '',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get additional info from Civitai if client is available
|
||||||
|
if civitai_client:
|
||||||
|
civitai_info = await civitai_client.get_model_version_info(model_version_id)
|
||||||
|
|
||||||
|
# Check if this LoRA exists locally by SHA256 hash
|
||||||
|
if civitai_info and civitai_info.get("error") != "Model not found":
|
||||||
|
# Check if this is an early access lora
|
||||||
|
if civitai_info.get('earlyAccessEndsAt'):
|
||||||
|
# Convert earlyAccessEndsAt to a human-readable date
|
||||||
|
early_access_date = civitai_info.get('earlyAccessEndsAt', '')
|
||||||
|
lora_entry['isEarlyAccess'] = True
|
||||||
|
lora_entry['earlyAccessEndsAt'] = early_access_date
|
||||||
|
|
||||||
|
# LoRA exists on Civitai, process its information
|
||||||
|
if 'files' in civitai_info:
|
||||||
|
# Find the model file (type="Model") in the files list
|
||||||
|
model_file = next((file for file in civitai_info.get('files', [])
|
||||||
|
if file.get('type') == 'Model'), None)
|
||||||
|
|
||||||
|
if model_file and recipe_scanner:
|
||||||
|
sha256 = model_file.get('hashes', {}).get('SHA256', '')
|
||||||
|
if sha256:
|
||||||
|
lora_scanner = recipe_scanner._lora_scanner
|
||||||
|
exists_locally = lora_scanner.has_lora_hash(sha256)
|
||||||
|
if exists_locally:
|
||||||
|
local_path = lora_scanner.get_lora_path_by_hash(sha256)
|
||||||
|
lora_entry['existsLocally'] = True
|
||||||
|
lora_entry['localPath'] = local_path
|
||||||
|
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
|
||||||
|
else:
|
||||||
|
# For missing LoRAs, get file_name from model_file.name
|
||||||
|
file_name = model_file.get('name', '')
|
||||||
|
lora_entry['file_name'] = os.path.splitext(file_name)[0] if file_name else ''
|
||||||
|
|
||||||
|
lora_entry['hash'] = sha256
|
||||||
|
lora_entry['size'] = model_file.get('sizeKB', 0) * 1024
|
||||||
|
|
||||||
|
# Get thumbnail URL from first image
|
||||||
|
if 'images' in civitai_info and civitai_info['images']:
|
||||||
|
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||||
|
|
||||||
|
# Get base model and update counts
|
||||||
|
current_base_model = civitai_info.get('baseModel', '')
|
||||||
|
lora_entry['baseModel'] = current_base_model
|
||||||
|
if current_base_model:
|
||||||
|
base_model_counts[current_base_model] = base_model_counts.get(current_base_model, 0) + 1
|
||||||
|
|
||||||
|
# Get download URL
|
||||||
|
lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
||||||
|
else:
|
||||||
|
# LoRA is deleted from Civitai or not found
|
||||||
|
lora_entry['isDeleted'] = True
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
|
|
||||||
|
loras.append(lora_entry)
|
||||||
|
|
||||||
|
# Set base_model to the most common one from civitai_info
|
||||||
|
base_model = None
|
||||||
|
if base_model_counts:
|
||||||
|
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
|
# Extract generation parameters for recipe metadata
|
||||||
|
gen_params = {}
|
||||||
|
for key in GEN_PARAM_KEYS:
|
||||||
|
if key in metadata:
|
||||||
|
gen_params[key] = metadata.get(key, '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'base_model': base_model,
|
||||||
|
'loras': loras,
|
||||||
|
'gen_params': gen_params,
|
||||||
|
'raw_metadata': metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing standard metadata: {e}", exc_info=True)
|
||||||
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
def _parse_recipe_metadata(self, user_comment: str) -> Dict[str, Any]:
|
||||||
|
"""Parse recipe metadata from UserComment"""
|
||||||
|
try:
|
||||||
|
# Split by 'Negative prompt:' to get the prompt
|
||||||
|
parts = user_comment.split('Negative prompt:', 1)
|
||||||
|
prompt = parts[0].strip()
|
||||||
|
|
||||||
|
# Initialize metadata with prompt
|
||||||
|
metadata = {"prompt": prompt, "loras": [], "checkpoint": None}
|
||||||
|
|
||||||
|
# Extract additional fields if available
|
||||||
|
if len(parts) > 1:
|
||||||
|
negative_and_params = parts[1]
|
||||||
|
|
||||||
|
# Extract negative prompt
|
||||||
|
if "Steps:" in negative_and_params:
|
||||||
|
neg_prompt = negative_and_params.split("Steps:", 1)[0].strip()
|
||||||
|
metadata["negative_prompt"] = neg_prompt
|
||||||
|
|
||||||
|
# Extract key-value parameters (Steps, Sampler, CFG scale, etc.)
|
||||||
|
param_pattern = r'([A-Za-z ]+): ([^,]+)'
|
||||||
|
params = re.findall(param_pattern, negative_and_params)
|
||||||
|
for key, value in params:
|
||||||
|
clean_key = key.strip().lower().replace(' ', '_')
|
||||||
|
metadata[clean_key] = value.strip()
|
||||||
|
|
||||||
|
# Extract Civitai resources
|
||||||
|
if 'Civitai resources:' in user_comment:
|
||||||
|
resources_part = user_comment.split('Civitai resources:', 1)[1]
|
||||||
|
if '],' in resources_part:
|
||||||
|
resources_json = resources_part.split('],', 1)[0] + ']'
|
||||||
|
try:
|
||||||
|
resources = json.loads(resources_json)
|
||||||
|
# Filter loras and checkpoints
|
||||||
|
for resource in resources:
|
||||||
|
if resource.get('type') == 'lora':
|
||||||
|
# 确保 weight 字段被正确保留
|
||||||
|
lora_entry = resource.copy()
|
||||||
|
# 如果找不到 weight,默认为 1.0
|
||||||
|
if 'weight' not in lora_entry:
|
||||||
|
lora_entry['weight'] = 1.0
|
||||||
|
# Ensure modelVersionName is included
|
||||||
|
if 'modelVersionName' not in lora_entry:
|
||||||
|
lora_entry['modelVersionName'] = ''
|
||||||
|
metadata['loras'].append(lora_entry)
|
||||||
|
elif resource.get('type') == 'checkpoint':
|
||||||
|
metadata['checkpoint'] = resource
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing recipe metadata: {e}")
|
||||||
|
return {"prompt": user_comment, "loras": [], "checkpoint": None}
|
||||||
|
|
||||||
|
|
||||||
|
class A1111MetadataParser(RecipeMetadataParser):
|
||||||
|
"""Parser for images with A1111 metadata format (Lora hashes)"""
|
||||||
|
|
||||||
|
METADATA_MARKER = r'Lora hashes:'
|
||||||
|
LORA_PATTERN = r'<lora:([^:]+):([^>]+)>'
|
||||||
|
LORA_HASH_PATTERN = r'([^:]+): ([a-f0-9]+)'
|
||||||
|
|
||||||
|
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||||
|
"""Check if the user comment matches the A1111 metadata format"""
|
||||||
|
return 'Lora hashes:' in user_comment
|
||||||
|
|
||||||
|
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
|
"""Parse metadata from images with A1111 metadata format"""
|
||||||
|
try:
|
||||||
|
# Extract prompt and negative prompt
|
||||||
|
parts = user_comment.split('Negative prompt:', 1)
|
||||||
|
prompt = parts[0].strip()
|
||||||
|
|
||||||
|
# Initialize metadata
|
||||||
|
metadata = {"prompt": prompt, "loras": []}
|
||||||
|
|
||||||
|
# Extract negative prompt and parameters
|
||||||
|
if len(parts) > 1:
|
||||||
|
negative_and_params = parts[1]
|
||||||
|
|
||||||
|
# Extract negative prompt
|
||||||
|
if "Steps:" in negative_and_params:
|
||||||
|
neg_prompt = negative_and_params.split("Steps:", 1)[0].strip()
|
||||||
|
metadata["negative_prompt"] = neg_prompt
|
||||||
|
|
||||||
|
# Extract key-value parameters (Steps, Sampler, CFG scale, etc.)
|
||||||
|
param_pattern = r'([A-Za-z ]+): ([^,]+)'
|
||||||
|
params = re.findall(param_pattern, negative_and_params)
|
||||||
|
for key, value in params:
|
||||||
|
clean_key = key.strip().lower().replace(' ', '_')
|
||||||
|
metadata[clean_key] = value.strip()
|
||||||
|
|
||||||
|
# Extract LoRA information from prompt
|
||||||
|
lora_weights = {}
|
||||||
|
lora_matches = re.findall(self.LORA_PATTERN, prompt)
|
||||||
|
for lora_name, weight in lora_matches:
|
||||||
|
lora_weights[lora_name.strip()] = float(weight.strip())
|
||||||
|
|
||||||
|
# Remove LoRA patterns from prompt
|
||||||
|
metadata["prompt"] = re.sub(self.LORA_PATTERN, '', prompt).strip()
|
||||||
|
|
||||||
|
# Extract LoRA hashes
|
||||||
|
lora_hashes = {}
|
||||||
|
if 'Lora hashes:' in user_comment:
|
||||||
|
lora_hash_section = user_comment.split('Lora hashes:', 1)[1].strip()
|
||||||
|
if lora_hash_section.startswith('"'):
|
||||||
|
lora_hash_section = lora_hash_section[1:].split('"', 1)[0]
|
||||||
|
hash_matches = re.findall(self.LORA_HASH_PATTERN, lora_hash_section)
|
||||||
|
for lora_name, hash_value in hash_matches:
|
||||||
|
# Remove any leading comma and space from lora name
|
||||||
|
clean_name = lora_name.strip().lstrip(',').strip()
|
||||||
|
lora_hashes[clean_name] = hash_value.strip()
|
||||||
|
|
||||||
|
# Process LoRAs and collect base models
|
||||||
|
base_model_counts = {}
|
||||||
|
loras = []
|
||||||
|
|
||||||
|
# Process each LoRA with hash and weight
|
||||||
|
for lora_name, hash_value in lora_hashes.items():
|
||||||
|
weight = lora_weights.get(lora_name, 1.0)
|
||||||
|
|
||||||
|
# Initialize lora entry with default values
|
||||||
|
lora_entry = {
|
||||||
|
'name': lora_name,
|
||||||
|
'type': 'lora',
|
||||||
|
'weight': weight,
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': lora_name,
|
||||||
|
'hash': hash_value,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get info from Civitai by hash
|
||||||
|
if civitai_client:
|
||||||
|
try:
|
||||||
|
civitai_info = await civitai_client.get_model_by_hash(hash_value)
|
||||||
|
if civitai_info and civitai_info.get("error") != "Model not found":
|
||||||
|
# Check if this is an early access lora
|
||||||
|
if civitai_info.get('earlyAccessEndsAt'):
|
||||||
|
# Convert earlyAccessEndsAt to a human-readable date
|
||||||
|
early_access_date = civitai_info.get('earlyAccessEndsAt', '')
|
||||||
|
lora_entry['isEarlyAccess'] = True
|
||||||
|
lora_entry['earlyAccessEndsAt'] = early_access_date
|
||||||
|
|
||||||
|
# Get model version ID
|
||||||
|
lora_entry['id'] = civitai_info.get('id', '')
|
||||||
|
|
||||||
|
# Get model name and version
|
||||||
|
lora_entry['name'] = civitai_info.get('model', {}).get('name', lora_name)
|
||||||
|
lora_entry['version'] = civitai_info.get('name', '')
|
||||||
|
|
||||||
|
# Get thumbnail URL
|
||||||
|
if 'images' in civitai_info and civitai_info['images']:
|
||||||
|
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||||
|
|
||||||
|
# Get base model and update counts
|
||||||
|
current_base_model = civitai_info.get('baseModel', '')
|
||||||
|
lora_entry['baseModel'] = current_base_model
|
||||||
|
if current_base_model:
|
||||||
|
base_model_counts[current_base_model] = base_model_counts.get(current_base_model, 0) + 1
|
||||||
|
|
||||||
|
# Get download URL
|
||||||
|
lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
||||||
|
|
||||||
|
# Get file name and size from Civitai
|
||||||
|
if 'files' in civitai_info:
|
||||||
|
model_file = next((file for file in civitai_info.get('files', [])
|
||||||
|
if file.get('type') == 'Model'), None)
|
||||||
|
if model_file:
|
||||||
|
file_name = model_file.get('name', '')
|
||||||
|
lora_entry['file_name'] = os.path.splitext(file_name)[0] if file_name else lora_name
|
||||||
|
lora_entry['size'] = model_file.get('sizeKB', 0) * 1024
|
||||||
|
# Update hash to sha256
|
||||||
|
lora_entry['hash'] = model_file.get('hashes', {}).get('SHA256', hash_value).lower()
|
||||||
|
|
||||||
|
# Check if exists locally with sha256 hash
|
||||||
|
if recipe_scanner and lora_entry['hash']:
|
||||||
|
lora_scanner = recipe_scanner._lora_scanner
|
||||||
|
exists_locally = lora_scanner.has_lora_hash(lora_entry['hash'])
|
||||||
|
if exists_locally:
|
||||||
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
|
lora_item = next((item for item in lora_cache.raw_data if item['sha256'] == lora_entry['hash']), None)
|
||||||
|
if lora_item:
|
||||||
|
lora_entry['existsLocally'] = True
|
||||||
|
lora_entry['localPath'] = lora_item['file_path']
|
||||||
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for LoRA hash {hash_value}: {e}")
|
||||||
|
|
||||||
|
loras.append(lora_entry)
|
||||||
|
|
||||||
|
# Set base_model to the most common one from civitai_info
|
||||||
|
base_model = None
|
||||||
|
if base_model_counts:
|
||||||
|
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
|
# Extract generation parameters for recipe metadata
|
||||||
|
gen_params = {}
|
||||||
|
for key in GEN_PARAM_KEYS:
|
||||||
|
if key in metadata:
|
||||||
|
gen_params[key] = metadata.get(key, '')
|
||||||
|
|
||||||
|
# Add model information if available
|
||||||
|
if 'model' in metadata:
|
||||||
|
gen_params['checkpoint'] = metadata['model']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'base_model': base_model,
|
||||||
|
'loras': loras,
|
||||||
|
'gen_params': gen_params,
|
||||||
|
'raw_metadata': metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing A1111 metadata: {e}", exc_info=True)
|
||||||
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeParserFactory:
|
||||||
|
"""Factory for creating recipe metadata parsers"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_parser(user_comment: str) -> RecipeMetadataParser:
|
||||||
|
"""
|
||||||
|
Create appropriate parser based on the user comment content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_comment: The EXIF UserComment string from the image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Appropriate RecipeMetadataParser implementation
|
||||||
|
"""
|
||||||
|
if RecipeFormatParser().is_metadata_matching(user_comment):
|
||||||
|
return RecipeFormatParser()
|
||||||
|
elif StandardMetadataParser().is_metadata_matching(user_comment):
|
||||||
|
return StandardMetadataParser()
|
||||||
|
elif A1111MetadataParser().is_metadata_matching(user_comment):
|
||||||
|
return A1111MetadataParser()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
78
py/utils/utils.py
Normal file
78
py/utils/utils.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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 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
|
||||||
149
py/workflow/README.md
Normal file
149
py/workflow/README.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# ComfyUI Workflow Parser
|
||||||
|
|
||||||
|
本模块提供了一个灵活的解析系统,可以从ComfyUI工作流中提取生成参数和LoRA信息。
|
||||||
|
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
工作流解析器基于以下设计原则:
|
||||||
|
|
||||||
|
1. **模块化**: 每种节点类型由独立的mapper处理
|
||||||
|
2. **可扩展性**: 通过扩展系统轻松添加新的节点类型支持
|
||||||
|
3. **回溯**: 通过工作流图的模型输入路径跟踪LoRA节点
|
||||||
|
4. **灵活性**: 适应不同的ComfyUI工作流结构
|
||||||
|
|
||||||
|
## 主要组件
|
||||||
|
|
||||||
|
### 1. NodeMapper
|
||||||
|
|
||||||
|
`NodeMapper`是所有节点映射器的基类,定义了如何从工作流中提取节点信息:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class NodeMapper:
|
||||||
|
def __init__(self, node_type: str, inputs_to_track: List[str]):
|
||||||
|
self.node_type = node_type
|
||||||
|
self.inputs_to_track = inputs_to_track
|
||||||
|
|
||||||
|
def process(self, node_id: str, node_data: Dict, workflow: Dict, parser) -> Any:
|
||||||
|
# 处理节点的通用逻辑
|
||||||
|
...
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Any:
|
||||||
|
# 由子类覆盖以提供特定转换
|
||||||
|
return inputs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. WorkflowParser
|
||||||
|
|
||||||
|
主要解析类,通过跟踪工作流图来提取参数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser = WorkflowParser()
|
||||||
|
result = parser.parse_workflow("workflow.json")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 扩展系统
|
||||||
|
|
||||||
|
允许通过添加新的自定义mapper来扩展支持的节点类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在py/workflow/ext/中添加自定义mapper模块
|
||||||
|
load_extensions() # 自动加载所有扩展
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```python
|
||||||
|
from workflow.parser import parse_workflow
|
||||||
|
|
||||||
|
# 解析工作流并保存结果
|
||||||
|
result = parse_workflow("workflow.json", "output.json")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义解析
|
||||||
|
|
||||||
|
```python
|
||||||
|
from workflow.parser import WorkflowParser
|
||||||
|
from workflow.mappers import register_mapper, load_extensions
|
||||||
|
|
||||||
|
# 加载扩展
|
||||||
|
load_extensions()
|
||||||
|
|
||||||
|
# 创建解析器
|
||||||
|
parser = WorkflowParser(load_extensions_on_init=False) # 不自动加载扩展
|
||||||
|
|
||||||
|
# 解析工作流
|
||||||
|
result = parser.parse_workflow(workflow_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展系统
|
||||||
|
|
||||||
|
### 添加新的节点映射器
|
||||||
|
|
||||||
|
在`py/workflow/ext/`目录中创建Python文件,定义从`NodeMapper`继承的类:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# example_mapper.py
|
||||||
|
from ..mappers import NodeMapper
|
||||||
|
|
||||||
|
class MyCustomNodeMapper(NodeMapper):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="MyCustomNode", # 节点的class_type
|
||||||
|
inputs_to_track=["param1", "param2"] # 要提取的参数
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Any:
|
||||||
|
# 处理提取的参数
|
||||||
|
return {
|
||||||
|
"custom_param": inputs.get("param1", "default")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展系统会自动加载和注册这些映射器。
|
||||||
|
|
||||||
|
### LoraManager节点说明
|
||||||
|
|
||||||
|
LoraManager相关节点的处理方式:
|
||||||
|
|
||||||
|
1. **Lora Loader**: 处理`loras`数组,过滤出`active=true`的条目,和`lora_stack`输入
|
||||||
|
2. **Lora Stacker**: 处理`loras`数组和已有的`lora_stack`,构建叠加的LoRA
|
||||||
|
3. **TriggerWord Toggle**: 从`toggle_trigger_words`中提取`active=true`的条目
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
解析器生成的输出格式如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "...",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"steps": "25",
|
||||||
|
"sampler": "dpmpp_2m",
|
||||||
|
"scheduler": "beta",
|
||||||
|
"cfg": "1",
|
||||||
|
"seed": "48",
|
||||||
|
"guidance": 3.5,
|
||||||
|
"size": "896x1152",
|
||||||
|
"clip_skip": "2"
|
||||||
|
},
|
||||||
|
"loras": "<lora:name1:0.9> <lora:name2:0.8>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 高级用法
|
||||||
|
|
||||||
|
### 直接注册映射器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from workflow.mappers import register_mapper
|
||||||
|
from workflow.mappers import NodeMapper
|
||||||
|
|
||||||
|
# 创建自定义映射器
|
||||||
|
class CustomMapper(NodeMapper):
|
||||||
|
# ...实现映射器
|
||||||
|
|
||||||
|
# 注册映射器
|
||||||
|
register_mapper(CustomMapper())
|
||||||
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
|
||||||
|
"""
|
||||||
54
py/workflow/ext/example_mapper.py
Normal file
54
py/workflow/ext/example_mapper.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Example extension mapper for demonstrating the extension system
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any
|
||||||
|
from ..mappers import NodeMapper
|
||||||
|
|
||||||
|
class ExampleNodeMapper(NodeMapper):
|
||||||
|
"""Example mapper for custom nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="ExampleCustomNode",
|
||||||
|
inputs_to_track=["param1", "param2", "image"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
"""Transform extracted inputs into the desired output format"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Extract interesting parameters
|
||||||
|
if "param1" in inputs:
|
||||||
|
result["example_param1"] = inputs["param1"]
|
||||||
|
|
||||||
|
if "param2" in inputs:
|
||||||
|
result["example_param2"] = inputs["param2"]
|
||||||
|
|
||||||
|
# You can process the data in any way needed
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class VAEMapperExtension(NodeMapper):
|
||||||
|
"""Extension mapper for VAE nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="VAELoader",
|
||||||
|
inputs_to_track=["vae_name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
"""Extract VAE information"""
|
||||||
|
vae_name = inputs.get("vae_name", "")
|
||||||
|
|
||||||
|
# Remove path prefix if present
|
||||||
|
if "/" in vae_name or "\\" in vae_name:
|
||||||
|
# Get just the filename without path or extension
|
||||||
|
vae_name = vae_name.replace("\\", "/").split("/")[-1]
|
||||||
|
vae_name = vae_name.split(".")[0] # Remove extension
|
||||||
|
|
||||||
|
return {"vae": vae_name}
|
||||||
|
|
||||||
|
|
||||||
|
# Note: No need to register manually - extensions are automatically registered
|
||||||
|
# when the extension system loads this file
|
||||||
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()
|
||||||
428
py/workflow/mappers.py
Normal file
428
py/workflow/mappers.py
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global mapper registry
|
||||||
|
_MAPPER_REGISTRY: Dict[str, 'NodeMapper'] = {}
|
||||||
|
|
||||||
|
class NodeMapper:
|
||||||
|
"""Base class for node mappers that define how to extract information from a specific node type"""
|
||||||
|
|
||||||
|
def __init__(self, node_type: str, inputs_to_track: List[str]):
|
||||||
|
self.node_type = node_type
|
||||||
|
self.inputs_to_track = inputs_to_track
|
||||||
|
|
||||||
|
def process(self, node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore
|
||||||
|
"""Process the node and extract relevant information"""
|
||||||
|
result = {}
|
||||||
|
for input_name in self.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:
|
||||||
|
# Format is [node_id, output_slot]
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Store the processed value
|
||||||
|
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}")
|
||||||
|
# If we couldn't process the reference, store the raw value
|
||||||
|
result[input_name] = input_value
|
||||||
|
else:
|
||||||
|
# Direct value
|
||||||
|
result[input_name] = input_value
|
||||||
|
|
||||||
|
# Apply any transformations
|
||||||
|
return self.transform(result)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Any:
|
||||||
|
"""Transform the extracted inputs - override in subclasses"""
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
class KSamplerMapper(NodeMapper):
|
||||||
|
"""Mapper for KSampler nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="KSampler",
|
||||||
|
inputs_to_track=["seed", "steps", "cfg", "sampler_name", "scheduler",
|
||||||
|
"denoise", "positive", "negative", "latent_image",
|
||||||
|
"model", "clip_skip"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
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", ""))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyLatentImageMapper(NodeMapper):
|
||||||
|
"""Mapper for EmptyLatentImage nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="EmptyLatentImage",
|
||||||
|
inputs_to_track=["width", "height", "batch_size"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
width = inputs.get("width", 0)
|
||||||
|
height = inputs.get("height", 0)
|
||||||
|
return {"width": width, "height": height, "size": f"{width}x{height}"}
|
||||||
|
|
||||||
|
|
||||||
|
class EmptySD3LatentImageMapper(NodeMapper):
|
||||||
|
"""Mapper for EmptySD3LatentImage nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="EmptySD3LatentImage",
|
||||||
|
inputs_to_track=["width", "height", "batch_size"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
width = inputs.get("width", 0)
|
||||||
|
height = inputs.get("height", 0)
|
||||||
|
return {"width": width, "height": height, "size": f"{width}x{height}"}
|
||||||
|
|
||||||
|
|
||||||
|
class CLIPTextEncodeMapper(NodeMapper):
|
||||||
|
"""Mapper for CLIPTextEncode nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="CLIPTextEncode",
|
||||||
|
inputs_to_track=["text", "clip"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Any:
|
||||||
|
# Simply return the text
|
||||||
|
return inputs.get("text", "")
|
||||||
|
|
||||||
|
|
||||||
|
class LoraLoaderMapper(NodeMapper):
|
||||||
|
"""Mapper for LoraLoader nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="Lora Loader (LoraManager)",
|
||||||
|
inputs_to_track=["loras", "lora_stack"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
# Fallback to loras array if text field doesn't exist or is invalid
|
||||||
|
loras_data = inputs.get("loras", [])
|
||||||
|
lora_stack = inputs.get("lora_stack", {}).get("lora_stack", [])
|
||||||
|
|
||||||
|
# Process loras array - filter active entries
|
||||||
|
lora_texts = []
|
||||||
|
|
||||||
|
# Check if loras_data is a list or a dict with __value__ key (new format)
|
||||||
|
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:
|
||||||
|
logger.info(f"Lora: {lora}, active: {lora.get('active')}")
|
||||||
|
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 it exists and is a valid format (list of tuples)
|
||||||
|
if lora_stack and isinstance(lora_stack, list):
|
||||||
|
# If lora_stack is a reference to another node ([node_id, output_slot]),
|
||||||
|
# we don't process it here as it's already been processed recursively
|
||||||
|
if len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int):
|
||||||
|
# This is a reference to another node, already processed
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Format each entry from the stack (assuming it's a list of tuples)
|
||||||
|
for stack_entry in lora_stack:
|
||||||
|
lora_name = stack_entry[0]
|
||||||
|
strength = stack_entry[1]
|
||||||
|
lora_texts.append(f"<lora:{lora_name}:{strength}>")
|
||||||
|
|
||||||
|
# Join with spaces
|
||||||
|
combined_text = " ".join(lora_texts)
|
||||||
|
|
||||||
|
return {"loras": combined_text}
|
||||||
|
|
||||||
|
|
||||||
|
class LoraStackerMapper(NodeMapper):
|
||||||
|
"""Mapper for LoraStacker nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="Lora Stacker (LoraManager)",
|
||||||
|
inputs_to_track=["loras", "lora_stack"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
loras_data = inputs.get("loras", [])
|
||||||
|
result_stack = []
|
||||||
|
|
||||||
|
# Handle existing stack entries
|
||||||
|
existing_stack = []
|
||||||
|
lora_stack_input = inputs.get("lora_stack", [])
|
||||||
|
|
||||||
|
# Handle different formats of lora_stack
|
||||||
|
if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input:
|
||||||
|
# Format from another LoraStacker node
|
||||||
|
existing_stack = lora_stack_input["lora_stack"]
|
||||||
|
elif isinstance(lora_stack_input, list):
|
||||||
|
# Direct list format or reference format [node_id, output_slot]
|
||||||
|
if len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and isinstance(lora_stack_input[1], int):
|
||||||
|
# This is likely a reference that was already processed
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Regular list of tuples/entries
|
||||||
|
existing_stack = lora_stack_input
|
||||||
|
|
||||||
|
# Add existing entries first
|
||||||
|
if existing_stack:
|
||||||
|
result_stack.extend(existing_stack)
|
||||||
|
|
||||||
|
# Process loras array - filter active entries
|
||||||
|
# Check if loras_data is a list or a dict with __value__ key (new format)
|
||||||
|
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 = float(lora.get("strength", 1.0))
|
||||||
|
result_stack.append((lora_name, strength))
|
||||||
|
|
||||||
|
return {"lora_stack": result_stack}
|
||||||
|
|
||||||
|
|
||||||
|
class JoinStringsMapper(NodeMapper):
|
||||||
|
"""Mapper for JoinStrings nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="JoinStrings",
|
||||||
|
inputs_to_track=["string1", "string2", "delimiter"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> str:
|
||||||
|
string1 = inputs.get("string1", "")
|
||||||
|
string2 = inputs.get("string2", "")
|
||||||
|
delimiter = inputs.get("delimiter", "")
|
||||||
|
return f"{string1}{delimiter}{string2}"
|
||||||
|
|
||||||
|
|
||||||
|
class StringConstantMapper(NodeMapper):
|
||||||
|
"""Mapper for StringConstant and StringConstantMultiline nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="StringConstantMultiline",
|
||||||
|
inputs_to_track=["string"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> str:
|
||||||
|
return inputs.get("string", "")
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerWordToggleMapper(NodeMapper):
|
||||||
|
"""Mapper for TriggerWordToggle nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="TriggerWord Toggle (LoraManager)",
|
||||||
|
inputs_to_track=["toggle_trigger_words"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> str:
|
||||||
|
toggle_data = inputs.get("toggle_trigger_words", [])
|
||||||
|
|
||||||
|
# check if toggle_words is a list or a dict with __value__ key (new format)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Join with commas
|
||||||
|
result = ", ".join(active_words)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class FluxGuidanceMapper(NodeMapper):
|
||||||
|
"""Mapper for FluxGuidance nodes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
node_type="FluxGuidance",
|
||||||
|
inputs_to_track=["guidance", "conditioning"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def transform(self, inputs: Dict) -> Dict:
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Handle guidance parameter
|
||||||
|
if "guidance" in inputs:
|
||||||
|
result["guidance"] = inputs["guidance"]
|
||||||
|
|
||||||
|
# Handle conditioning (the prompt text)
|
||||||
|
if "conditioning" in inputs:
|
||||||
|
conditioning = inputs["conditioning"]
|
||||||
|
if isinstance(conditioning, str):
|
||||||
|
result["prompt"] = conditioning
|
||||||
|
else:
|
||||||
|
result["prompt"] = "Unknown prompt"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mapper Registry Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def register_mapper(mapper: NodeMapper) -> 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[NodeMapper]:
|
||||||
|
"""Get a mapper for the specified node type"""
|
||||||
|
return _MAPPER_REGISTRY.get(node_type)
|
||||||
|
|
||||||
|
def get_all_mappers() -> Dict[str, NodeMapper]:
|
||||||
|
"""Get all registered mappers"""
|
||||||
|
return _MAPPER_REGISTRY.copy()
|
||||||
|
|
||||||
|
def register_default_mappers() -> None:
|
||||||
|
"""Register all default mappers"""
|
||||||
|
default_mappers = [
|
||||||
|
KSamplerMapper(),
|
||||||
|
EmptyLatentImageMapper(),
|
||||||
|
EmptySD3LatentImageMapper(),
|
||||||
|
CLIPTextEncodeMapper(),
|
||||||
|
LoraLoaderMapper(),
|
||||||
|
LoraStackerMapper(),
|
||||||
|
JoinStringsMapper(),
|
||||||
|
StringConstantMapper(),
|
||||||
|
TriggerWordToggleMapper(),
|
||||||
|
FluxGuidanceMapper()
|
||||||
|
]
|
||||||
|
|
||||||
|
for mapper in default_mappers:
|
||||||
|
register_mapper(mapper)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Extension Loading
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def load_extensions(ext_dir: str = None) -> None:
|
||||||
|
"""
|
||||||
|
Load mapper extensions from the specified directory
|
||||||
|
|
||||||
|
Each Python file in the directory will be loaded, and any NodeMapper subclasses
|
||||||
|
defined in those files will be automatically registered.
|
||||||
|
"""
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Find all NodeMapper subclasses in the module
|
||||||
|
for name, obj in inspect.getmembers(module):
|
||||||
|
if (inspect.isclass(obj) and issubclass(obj, NodeMapper)
|
||||||
|
and obj != NodeMapper and hasattr(obj, 'node_type')):
|
||||||
|
# Instantiate and register the mapper
|
||||||
|
mapper = obj()
|
||||||
|
register_mapper(mapper)
|
||||||
|
logger.info(f"Loaded extension mapper: {mapper.node_type} from {filename}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error loading extension {filename}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize the registry with default mappers
|
||||||
|
register_default_mappers()
|
||||||
196
py/workflow/parser.py
Normal file
196
py/workflow/parser.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
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, load_extensions_on_init: bool = True):
|
||||||
|
"""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 if requested
|
||||||
|
if load_extensions_on_init:
|
||||||
|
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
|
||||||
|
mapper = get_mapper(node_type)
|
||||||
|
if mapper:
|
||||||
|
try:
|
||||||
|
result = mapper.process(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 collect_loras_from_model(self, model_input: List, workflow: Dict) -> str:
|
||||||
|
"""Collect loras information from the model node chain"""
|
||||||
|
if not isinstance(model_input, list) or len(model_input) != 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
model_node_id, _ = model_input
|
||||||
|
# Convert node_id to string if it's an integer
|
||||||
|
if isinstance(model_node_id, int):
|
||||||
|
model_node_id = str(model_node_id)
|
||||||
|
|
||||||
|
# Process the model node
|
||||||
|
model_result = self.process_node(model_node_id, workflow)
|
||||||
|
|
||||||
|
# If this is a Lora Loader node, return the loras text
|
||||||
|
if model_result and isinstance(model_result, dict) and "loras" in model_result:
|
||||||
|
return model_result["loras"]
|
||||||
|
|
||||||
|
# If not a lora loader, check the node's inputs for a model connection
|
||||||
|
node_data = workflow.get(model_node_id, {})
|
||||||
|
inputs = node_data.get("inputs", {})
|
||||||
|
|
||||||
|
# If this node has a model input, follow that path
|
||||||
|
if "model" in inputs and isinstance(inputs["model"], list):
|
||||||
|
return self.collect_loras_from_model(inputs["model"], workflow)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
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 KSampler node
|
||||||
|
ksampler_node_id = find_node_by_type(workflow, "KSampler")
|
||||||
|
if not ksampler_node_id:
|
||||||
|
logger.warning("No KSampler node found in workflow")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Start parsing from the KSampler node
|
||||||
|
result = {
|
||||||
|
"gen_params": {},
|
||||||
|
"loras": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process KSampler node to extract parameters
|
||||||
|
ksampler_result = self.process_node(ksampler_node_id, workflow)
|
||||||
|
if ksampler_result:
|
||||||
|
# Process the result
|
||||||
|
for key, value in ksampler_result.items():
|
||||||
|
# Special handling for the positive prompt from FluxGuidance
|
||||||
|
if key == "positive" and isinstance(value, dict):
|
||||||
|
# Extract guidance value
|
||||||
|
if "guidance" in value:
|
||||||
|
result["gen_params"]["guidance"] = value["guidance"]
|
||||||
|
|
||||||
|
# Extract prompt
|
||||||
|
if "prompt" in value:
|
||||||
|
result["gen_params"]["prompt"] = value["prompt"]
|
||||||
|
else:
|
||||||
|
# Normal handling for other values
|
||||||
|
result["gen_params"][key] = value
|
||||||
|
|
||||||
|
# Process the positive prompt node if it exists and we don't have a prompt yet
|
||||||
|
if "prompt" not in result["gen_params"] and "positive" in ksampler_result:
|
||||||
|
positive_value = ksampler_result.get("positive")
|
||||||
|
if isinstance(positive_value, str):
|
||||||
|
result["gen_params"]["prompt"] = positive_value
|
||||||
|
|
||||||
|
# Manually check for FluxGuidance if we don't have guidance value
|
||||||
|
if "guidance" not in result["gen_params"]:
|
||||||
|
flux_node_id = find_node_by_type(workflow, "FluxGuidance")
|
||||||
|
if flux_node_id:
|
||||||
|
# Get the direct input from the node
|
||||||
|
node_inputs = workflow[flux_node_id].get("inputs", {})
|
||||||
|
if "guidance" in node_inputs:
|
||||||
|
result["gen_params"]["guidance"] = node_inputs["guidance"]
|
||||||
|
|
||||||
|
# Extract loras from the model input of KSampler
|
||||||
|
ksampler_node = workflow.get(ksampler_node_id, {})
|
||||||
|
ksampler_inputs = ksampler_node.get("inputs", {})
|
||||||
|
if "model" in ksampler_inputs and isinstance(ksampler_inputs["model"], list):
|
||||||
|
loras_text = self.collect_loras_from_model(ksampler_inputs["model"], workflow)
|
||||||
|
if loras_text:
|
||||||
|
result["loras"] = loras_text
|
||||||
|
|
||||||
|
# Handle standard ComfyUI names vs our output format
|
||||||
|
if "cfg" in result["gen_params"]:
|
||||||
|
result["gen_params"]["cfg_scale"] = result["gen_params"].pop("cfg")
|
||||||
|
|
||||||
|
# Add clip_skip = 2 to match reference output if not already present
|
||||||
|
if "clip_skip" not in result["gen_params"]:
|
||||||
|
result["gen_params"]["clip_skip"] = "2"
|
||||||
|
|
||||||
|
# Ensure the prompt is a string and not a nested dictionary
|
||||||
|
if "prompt" in result["gen_params"] and isinstance(result["gen_params"]["prompt"], dict):
|
||||||
|
if "prompt" in result["gen_params"]["prompt"]:
|
||||||
|
result["gen_params"]["prompt"] = result["gen_params"]["prompt"]["prompt"]
|
||||||
|
|
||||||
|
# Save the result if requested
|
||||||
|
if output_path:
|
||||||
|
save_output(result, output_path)
|
||||||
|
|
||||||
|
return 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]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
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."
|
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||||
version = "0.7.37"
|
version = "0.8.0"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"safetensors",
|
"safetensors",
|
||||||
"watchdog"
|
"watchdog",
|
||||||
|
"beautifulsoup4",
|
||||||
|
"piexif",
|
||||||
|
"Pillow",
|
||||||
|
"requests"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
101
refs/civitai_api_model_by_versionId.json
Normal file
101
refs/civitai_api_model_by_versionId.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
// more images here
|
||||||
|
],
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
refs/jpeg_civitai_exif_userComment_example
Normal file
29
refs/jpeg_civitai_exif_userComment_example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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: {}
|
||||||
|
|
||||||
|
<lora:ck-shadow-circuit-IL:0.78>,
|
||||||
|
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-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, steampunk aesthetic, mechanical monocle, long trench coat, leather gloves, brass accessories, intricate clockwork rifle, aiming at viewer, wind-blown scarf, high boots, fingerless gloves, pocket watch, corset, brown and gold color scheme, industrial cityscape, smoke and gears, atmospheric lighting, depth of field, dynamic pose, dramatic composition, detailed background, foreshortening, detailed background, dynamic pose, dynamic composition,dutch angle, detailed backgroud,foreshortening,blurry edges <lora:iLLMythAn1m3Style:1> MythAn1m3
|
||||||
|
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
|
||||||
|
Steps: 35, Sampler: DPM++ 2M SDE, Schedule type: Karras, CFG scale: 4, Seed: 3537159932, Size: 1072x1376, Model hash: c364bbdae9, Model: waiNSFWIllustrious_v110, Clip skip: 2, ADetailer model: face_yolov8n.pt, ADetailer confidence: 0.3, ADetailer dilate erode: 4, ADetailer mask blur: 4, ADetailer denoising strength: 0.4, ADetailer inpaint only masked: True, ADetailer inpaint padding: 32, ADetailer version: 24.8.0, Lora hashes: "iLLMythAn1m3Style: d3480076057b", Version: f2.0.1v1.10.1-previous-519-g44eb4ea8, Module 1: sdxl.vae
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1boy, solo, gothic horror, pale vampire lord in regal, intricately detailed robes, crimson eyes glowing under the dim candlelight of a grand but decayed castle hall, holding a silver goblet filled with an unknown substance, a massive stained-glass window shattered behind him, cold mist rolling in, dramatic lighting, dark yet elegant aesthetic, foreshortening, cinematic perspective.
|
||||||
|
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: 290117945770094, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 0.6, Lora_0 Strength clip: 0.7000000000000001, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
|
||||||
|
|
||||||
|
bo-exposure, An 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.
|
||||||
|
Negative prompt:
|
||||||
|
Steps: 25, Sampler: DPM++ 2M, CFG scale: 3.5, Seed: 1024252061321625, Size: 832x1216, Clip skip: 1, Model hash: , Model: flux_dev, Hashes: {"model": ""}, Version: ComfyUI
|
||||||
13
refs/output.json
Normal file
13
refs/output.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
297
refs/prompt.json
Normal file
297
refs/prompt.json
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
{
|
||||||
|
"3": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"ckpt_name": "il\\waiNSFWIllustrious_v110.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load Checkpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 832,
|
||||||
|
"height": 1216,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Empty Latent Image"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"text": [
|
||||||
|
"22",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"clip": [
|
||||||
|
"56",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP Text Encode (Prompt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"4",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE Decode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"inputs": {
|
||||||
|
"images": [
|
||||||
|
"8",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "PreviewImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Preview Image"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"inputs": {
|
||||||
|
"stop_at_clip_layer": -2,
|
||||||
|
"clip": [
|
||||||
|
"4",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPSetLastLayer",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP Set Last Layer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"inputs": {
|
||||||
|
"string1": [
|
||||||
|
"55",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"string2": [
|
||||||
|
"21",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"delimiter": ", "
|
||||||
|
},
|
||||||
|
"class_type": "JoinStrings",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Join Strings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"55": {
|
||||||
|
"inputs": {
|
||||||
|
"group_mode": true,
|
||||||
|
"toggle_trigger_words": [
|
||||||
|
{
|
||||||
|
"text": "in the style of ck-rw",
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "in the style of cksc",
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "artist:moriimee",
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "__dummy_item__",
|
||||||
|
"active": false,
|
||||||
|
"_isDummy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "__dummy_item__",
|
||||||
|
"active": false,
|
||||||
|
"_isDummy": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"orinalMessage": "in the style of ck-rw,, in the style of cksc,, artist:moriimee",
|
||||||
|
"trigger_words": [
|
||||||
|
"56",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "TriggerWord Toggle (LoraManager)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "TriggerWord Toggle (LoraManager)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"57": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "<lora:aorunIllstrious:1>",
|
||||||
|
"loras": [
|
||||||
|
{
|
||||||
|
"name": "aorunIllstrious",
|
||||||
|
"strength": "0.90",
|
||||||
|
"active": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
aiohttp
|
||||||
jinja2
|
jinja2
|
||||||
safetensors
|
safetensors
|
||||||
watchdog
|
watchdog
|
||||||
|
beautifulsoup4
|
||||||
|
piexif
|
||||||
|
Pillow
|
||||||
|
requests
|
||||||
25
simple_test.py
Normal file
25
simple_test.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
from py.workflow.parser import WorkflowParser
|
||||||
|
|
||||||
|
# Load workflow data
|
||||||
|
with open('refs/prompt.json', 'r') as f:
|
||||||
|
workflow_data = json.load(f)
|
||||||
|
|
||||||
|
# Parse workflow
|
||||||
|
parser = WorkflowParser()
|
||||||
|
try:
|
||||||
|
# Parse the workflow
|
||||||
|
result = parser.parse_workflow(workflow_data)
|
||||||
|
print("Parsing successful!")
|
||||||
|
|
||||||
|
# Print each component separately
|
||||||
|
print("\nGeneration Parameters:")
|
||||||
|
for k, v in result.get("gen_params", {}).items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
|
|
||||||
|
print("\nLoRAs:")
|
||||||
|
print(result.get("loras", ""))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing workflow: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/* 强制显示滚动条,防止页面跳动 */
|
html, body {
|
||||||
html {
|
margin: 0;
|
||||||
overflow-y: scroll;
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* Disable default scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Firefox */
|
/* 针对Firefox */
|
||||||
@@ -16,6 +18,7 @@ html {
|
|||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@@ -35,6 +38,7 @@ html {
|
|||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(95% 0.02 256);
|
--lora-text: oklch(95% 0.02 256);
|
||||||
--lora-error: oklch(75% 0.32 29);
|
--lora-error: oklch(75% 0.32 29);
|
||||||
|
--lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */
|
||||||
|
|
||||||
/* Spacing Scale */
|
/* Spacing Scale */
|
||||||
--space-1: calc(8px * 1);
|
--space-1: calc(8px * 1);
|
||||||
@@ -43,6 +47,7 @@ html {
|
|||||||
|
|
||||||
/* Z-index Scale */
|
/* Z-index Scale */
|
||||||
--z-base: 10;
|
--z-base: 10;
|
||||||
|
--z-header: 100;
|
||||||
--z-modal: 1000;
|
--z-modal: 1000;
|
||||||
--z-overlay: 2000;
|
--z-overlay: 2000;
|
||||||
|
|
||||||
@@ -64,11 +69,14 @@ html {
|
|||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(98% 0.02 256);
|
--lora-text: oklch(98% 0.02 256);
|
||||||
|
--lora-warning: oklch(75% 0.25 80); /* Add warning color for dark theme too */
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
font-family: 'Segoe UI', sans-serif;
|
font-family: 'Segoe UI', sans-serif;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 0; /* Remove the padding-top */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
aspect-ratio: 896/1152;
|
aspect-ratio: 896/1152;
|
||||||
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
||||||
margin: 0 auto;
|
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 {
|
.lora-card:hover {
|
||||||
@@ -274,4 +277,55 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
font-size: 0.85em;
|
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);
|
||||||
}
|
}
|
||||||
@@ -23,12 +23,6 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: var(--lora-error);
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Version List Styles */
|
/* Version List Styles */
|
||||||
.version-list {
|
.version-list {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
@@ -104,6 +98,7 @@
|
|||||||
.version-info {
|
.version-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-direction: row !important;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -130,50 +125,6 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.local-badge i {
|
|
||||||
margin-right: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.local-badge:hover .local-path {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Folder Browser Styles */
|
/* Folder Browser Styles */
|
||||||
.folder-browser {
|
.folder-browser {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -251,47 +202,4 @@
|
|||||||
.version-item.exists-locally {
|
.version-item.exists-locally {
|
||||||
background: oklch(var(--lora-accent) / 0.05);
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
border-left: 4px solid var(--lora-accent);
|
border-left: 4px solid var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.local-badge i {
|
|
||||||
margin-right: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.local-badge:hover .local-path {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
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;
|
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 {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
@@ -63,7 +110,8 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.lora-card,
|
.lora-card,
|
||||||
.progress-bar {
|
.progress-bar,
|
||||||
|
.current-item-bar {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,56 +593,59 @@
|
|||||||
|
|
||||||
/* Model name field styles - complete replacement */
|
/* Model name field styles - complete replacement */
|
||||||
.model-name-field {
|
.model-name-field {
|
||||||
display: flex;
|
display: none;
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
width: calc(100% - 40px); /* Reduce width to avoid overlap with close button */
|
|
||||||
position: relative; /* Add position relative for absolute positioning of save button */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-name-field h2 {
|
/* New Model Name Header Styles */
|
||||||
|
.model-name-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: calc(100% - 40px); /* Avoid overlap with close button */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name-content {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: background-color 0.2s;
|
font-size: 1.5em !important;
|
||||||
flex: 1;
|
font-weight: 600;
|
||||||
font-size: 1.5em !important; /* Increased and forced size */
|
|
||||||
font-weight: 600; /* Make it bolder */
|
|
||||||
min-height: 1.5em;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--text-color); /* Ensure correct color */
|
color: var(--text-color);
|
||||||
}
|
border: 1px solid transparent;
|
||||||
|
|
||||||
.model-name-field h2:hover {
|
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-name-field h2:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
background: var(--bg-color);
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name-content:focus {
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-name-field .save-btn {
|
.edit-model-name-btn {
|
||||||
position: absolute;
|
background: transparent;
|
||||||
right: 10px; /* Position closer to the end of the field */
|
border: none;
|
||||||
top: 50%;
|
color: var(--text-color);
|
||||||
transform: translateY(-50%);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
cursor: pointer;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-name-field:hover .save-btn,
|
.edit-model-name-btn.visible,
|
||||||
.model-name-field h2:focus ~ .save-btn {
|
.model-name-header:hover .edit-model-name-btn {
|
||||||
opacity: 1;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure close button is accessible */
|
.edit-model-name-btn:hover {
|
||||||
.modal-content .close {
|
opacity: 0.8 !important;
|
||||||
z-index: 10; /* Ensure close button is above other elements */
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .edit-model-name-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab System Styling */
|
/* Tab System Styling */
|
||||||
@@ -796,12 +799,6 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: var(--lora-error);
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-examples {
|
.no-examples {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
@@ -913,7 +910,6 @@
|
|||||||
/* Updated Model Tags styles - improved visibility in light theme */
|
/* Updated Model Tags styles - improved visibility in light theme */
|
||||||
.model-tags-container {
|
.model-tags-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-tags-compact {
|
.model-tags-compact {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 48px; /* Start below the header */
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: calc(100% - 48px); /* Adjust height to exclude header */
|
||||||
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
|
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
|
||||||
z-index: var(--z-modal);
|
z-index: var(--z-modal);
|
||||||
overflow: hidden; /* 改为 hidden,防止双滚动条 */
|
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当模态窗口打开时,禁止body滚动 */
|
/* 当模态窗口打开时,禁止body滚动 */
|
||||||
@@ -23,8 +23,8 @@ body.modal-open {
|
|||||||
position: relative;
|
position: relative;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 90vh;
|
max-height: calc(90vh - 48px); /* Adjust to account for header height */
|
||||||
margin: 2rem auto;
|
margin: 1rem auto; /* Keep reduced top margin */
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
@@ -443,4 +443,43 @@ input:checked + .toggle-slider:before {
|
|||||||
|
|
||||||
.nsfw-blur:hover {
|
.nsfw-blur:hover {
|
||||||
filter: blur(8px);
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add styles for delete preview image */
|
||||||
|
.delete-preview {
|
||||||
|
max-width: 150px;
|
||||||
|
margin: 0 auto var(--space-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 150px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-info h3 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-info p {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-note {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
497
static/css/components/recipe-modal.css
Normal file
497
static/css/components/recipe-modal.css
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
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-thumbnail {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-lora-thumbnail img {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
/* Search Container Styles */
|
/* Search Container Styles */
|
||||||
.search-container {
|
.search-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 250px;
|
width: 100%;
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0; /* 防止搜索框被压缩 */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -12,14 +10,14 @@
|
|||||||
/* 调整搜索框样式以匹配其他控件 */
|
/* 调整搜索框样式以匹配其他控件 */
|
||||||
.search-container input {
|
.search-container input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 75px 6px 12px; /* Increased right padding to accommodate both buttons */
|
padding: 6px 35px 6px 12px; /* Reduced right padding */
|
||||||
border: 1px solid oklch(65% 0.02 256); /* 更深的边框颜色,提高对比度 */
|
border: 1px solid oklch(65% 0.02 256);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
box-sizing: border-box; /* 确保padding不会增加总宽度 */
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container input:focus {
|
.search-container input:focus {
|
||||||
@@ -34,7 +32,7 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: oklch(var(--text-color) / 0.5);
|
color: oklch(var(--text-color) / 0.5);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
line-height: 1; /* 防止图标影响容器高度 */
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修改清空按钮样式 */
|
/* 修改清空按钮样式 */
|
||||||
@@ -47,8 +45,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 4px 8px; /* 增加点击区域 */
|
padding: 4px 8px;
|
||||||
display: none; /* 默认隐藏 */
|
display: none;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -144,19 +142,19 @@
|
|||||||
|
|
||||||
/* Filter Panel Styles */
|
/* Filter Panel Styles */
|
||||||
.filter-panel {
|
.filter-panel {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 140px; /* Adjust to be closer to the filter button */
|
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 300px;
|
top: 50px; /* Position below header */
|
||||||
|
width: 320px;
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||||
z-index: var(--z-overlay); /* Increase z-index to be above cards */
|
z-index: var(--z-overlay);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
max-height: calc(100vh - 160px);
|
max-height: calc(100vh - 70px); /* Adjusted for header height */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +310,7 @@
|
|||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
left: 20px;
|
left: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
top: 140px;
|
top: 160px; /* Adjusted for mobile layout */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,10 +349,10 @@
|
|||||||
|
|
||||||
/* Search Options Panel */
|
/* Search Options Panel */
|
||||||
.search-options-panel {
|
.search-options-panel {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 140px;
|
right: 20px;
|
||||||
right: 65px; /* Position it closer to the search options button */
|
top: 50px; /* Position below header */
|
||||||
width: 280px; /* Slightly wider to accommodate tags better */
|
width: 280px;
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
@@ -363,6 +361,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
|
display: block; /* Ensure it's block by default */
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-options-panel.hidden {
|
.search-options-panel.hidden {
|
||||||
@@ -507,4 +506,15 @@ input:checked + .slider:before {
|
|||||||
|
|
||||||
.slider.round:before {
|
.slider.round:before {
|
||||||
border-radius: 50%;
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Support Modal Styles */
|
/* Support Modal Styles */
|
||||||
.support-modal {
|
.support-modal {
|
||||||
max-width: 550px;
|
max-width: 570px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-header {
|
.support-header {
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
.support-toggle:hover {
|
.support-toggle:hover {
|
||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: var(--lora-error) !important;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,4 +120,63 @@
|
|||||||
|
|
||||||
.tooltip:hover::after {
|
.tooltip:hover::after {
|
||||||
opacity: 1;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -153,56 +153,43 @@
|
|||||||
border-top: 1px solid var(--lora-border);
|
border-top: 1px solid var(--lora-border);
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
padding-top: var(--space-2);
|
padding-top: var(--space-2);
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle switch styles */
|
|
||||||
.toggle-switch {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
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;
|
cursor: pointer;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input {
|
.update-preferences .toggle-slider {
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 40px;
|
width: 50px;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
background-color: var(--border-color);
|
|
||||||
border-radius: 20px;
|
|
||||||
transition: .4s;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-slider:before {
|
.update-preferences .toggle-label {
|
||||||
position: absolute;
|
margin-left: 0;
|
||||||
content: "";
|
white-space: nowrap;
|
||||||
height: 16px;
|
line-height: 24px;
|
||||||
width: 16px;
|
|
||||||
left: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: .4s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .toggle-slider {
|
@media (max-width: 480px) {
|
||||||
background-color: var(--lora-accent);
|
.update-preferences {
|
||||||
}
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
input:checked + .toggle-slider:before {
|
}
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
.update-preferences .toggle-label {
|
||||||
|
margin-top: 5px;
|
||||||
.toggle-label {
|
}
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
|
position: relative;
|
||||||
|
z-index: var(--z-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -14,69 +25,17 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search and filter styles moved to components/search-filter.css */
|
.action-buttons {
|
||||||
|
|
||||||
/* Update corner-controls for collapsible behavior */
|
|
||||||
.corner-controls {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: var(--z-overlay);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-controls-toggle {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: var(--space-2);
|
||||||
cursor: pointer;
|
flex-wrap: nowrap;
|
||||||
transition: all 0.2s ease;
|
|
||||||
z-index: 2;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-controls-toggle:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-controls-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px) scale(0.9);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded state */
|
|
||||||
.corner-controls.expanded .corner-controls-items {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded state - only expand on hover if not already expanded by click */
|
|
||||||
.corner-controls:hover:not(.expanded) .corner-controls-items {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure hidden class works properly */
|
/* Ensure hidden class works properly */
|
||||||
@@ -84,46 +43,6 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update toggle button styles */
|
|
||||||
.update-toggle {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color); /* Changed from var(--lora-accent) to match other toggles */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-toggle:hover {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Update badge styles */
|
|
||||||
.update-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -3px;
|
|
||||||
right: -3px;
|
|
||||||
background-color: var(--lora-error);
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 0 2px var(--card-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge on corner toggle */
|
|
||||||
.corner-badge {
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-tags-container {
|
.folder-tags-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -131,11 +50,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.folder-tags {
|
.folder-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
transition: max-height 0.3s ease, opacity 0.2s ease;
|
transition: max-height 0.3s ease, opacity 0.2s ease;
|
||||||
max-height: 150px; /* Limit height to prevent overflow */
|
max-height: 150px; /* Limit height to prevent overflow */
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
overflow-y: auto; /* Enable vertical scrolling */
|
overflow-y: auto; /* Enable vertical scrolling */
|
||||||
padding-right: 40px; /* Make space for the toggle button */
|
|
||||||
margin-bottom: 5px; /* Add margin below the tags */
|
margin-bottom: 5px; /* Add margin below the tags */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +66,15 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-folders-container {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle Folders Button */
|
/* Toggle Folders Button */
|
||||||
.toggle-folders-btn {
|
.toggle-folders-btn {
|
||||||
position: absolute;
|
|
||||||
bottom: 0; /* 固定在容器底部 */
|
|
||||||
right: 0; /* 固定在容器右侧 */
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -162,7 +86,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-folders-btn:hover {
|
.toggle-folders-btn:hover {
|
||||||
@@ -175,25 +98,18 @@
|
|||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 折叠状态样式 */
|
/* Icon-only button style */
|
||||||
.folder-tags.collapsed + .toggle-folders-btn {
|
.icon-only {
|
||||||
position: static;
|
min-width: unset !important;
|
||||||
margin-right: auto; /* 确保按钮在左侧 */
|
width: 36px !important;
|
||||||
transform: translateY(0);
|
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);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 文件夹标签样式 */
|
|
||||||
.folder-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add custom scrollbar for better visibility */
|
/* Add custom scrollbar for better visibility */
|
||||||
.folder-tags::-webkit-scrollbar {
|
.folder-tags::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -263,124 +179,32 @@
|
|||||||
transform: translateY(-2px);
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-error);
|
|
||||||
color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-toggle i {
|
|
||||||
font-size: 1.1em;
|
|
||||||
position: relative;
|
|
||||||
top: 1px;
|
|
||||||
left: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.action-buttons {
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 15px;
|
gap: var(--space-1);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-folders-container {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-tags-container {
|
.folder-tags-container {
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-folders-btn {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
transform: none; /* 移除transform,防止hover时的位移 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-folders-btn:hover {
|
.toggle-folders-btn:hover {
|
||||||
transform: none; /* 移动端下禁用hover效果 */
|
transform: none; /* 移动端下禁用hover效果 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-tags.collapsed + .toggle-folders-btn {
|
|
||||||
position: relative;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-controls {
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-controls-items {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner-controls.expanded .corner-controls-items {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top {
|
.back-to-top {
|
||||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@import 'layout.css';
|
@import 'layout.css';
|
||||||
|
|
||||||
/* Import Components */
|
/* Import Components */
|
||||||
|
@import 'components/header.css';
|
||||||
@import 'components/card.css';
|
@import 'components/card.css';
|
||||||
@import 'components/modal.css';
|
@import 'components/modal.css';
|
||||||
@import 'components/download-modal.css';
|
@import 'components/download-modal.css';
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
@import 'components/support-modal.css';
|
@import 'components/support-modal.css';
|
||||||
@import 'components/search-filter.css';
|
@import 'components/search-filter.css';
|
||||||
@import 'components/bulk.css';
|
@import 'components/bulk.css';
|
||||||
|
@import 'components/shared.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
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,65 +1,76 @@
|
|||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { createLoraCard } from '../components/LoraCard.js';
|
import { createLoraCard } from '../components/LoraCard.js';
|
||||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||||
import { showDeleteModal } from '../utils/modalUtils.js';
|
import { showDeleteModal } from '../utils/modalUtils.js';
|
||||||
import { toggleFolder } from '../utils/uiHelpers.js';
|
import { toggleFolder } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
export async function loadMoreLoras(boolUpdateFolders = false) {
|
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||||
if (state.isLoading || !state.hasMore) return;
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
state.isLoading = true;
|
if (pageState.isLoading || (!pageState.hasMore && !resetPage)) return;
|
||||||
|
|
||||||
|
pageState.isLoading = true;
|
||||||
try {
|
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({
|
const params = new URLSearchParams({
|
||||||
page: state.currentPage,
|
page: pageState.currentPage,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
sort_by: state.sortBy
|
sort_by: pageState.sortBy
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 state 中的 searchManager 获取递归搜索状态
|
if (pageState.activeFolder !== null) {
|
||||||
const isRecursiveSearch = state.searchManager?.isRecursiveSearch ?? false;
|
params.append('folder', pageState.activeFolder);
|
||||||
|
|
||||||
if (state.activeFolder !== null) {
|
|
||||||
params.append('folder', state.activeFolder);
|
|
||||||
params.append('recursive', isRecursiveSearch.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add search parameters if there's a search term
|
// Add search parameters if there's a search term
|
||||||
const searchInput = document.getElementById('searchInput');
|
if (pageState.filters?.search) {
|
||||||
if (searchInput && searchInput.value.trim()) {
|
params.append('search', pageState.filters.search);
|
||||||
params.append('search', searchInput.value.trim());
|
|
||||||
params.append('fuzzy', 'true');
|
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
|
// Add filter parameters if active
|
||||||
if (state.filters) {
|
if (pageState.filters) {
|
||||||
if (state.filters.tags && state.filters.tags.length > 0) {
|
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||||
// Convert the array of tags to a comma-separated string
|
// Convert the array of tags to a comma-separated string
|
||||||
params.append('tags', state.filters.tags.join(','));
|
params.append('tags', pageState.filters.tags.join(','));
|
||||||
}
|
}
|
||||||
if (state.filters.baseModel && state.filters.baseModel.length > 0) {
|
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||||
// Convert the array of base models to a comma-separated string
|
// Convert the array of base models to a comma-separated string
|
||||||
params.append('base_models', state.filters.baseModel.join(','));
|
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Loading loras with params:', params.toString());
|
|
||||||
|
|
||||||
const response = await fetch(`/api/loras?${params}`);
|
const response = await fetch(`/api/loras?${params}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch loras: ${response.statusText}`);
|
throw new Error(`Failed to fetch loras: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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');
|
const grid = document.getElementById('loraGrid');
|
||||||
grid.innerHTML = '<div class="no-results">No loras found in this folder</div>';
|
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) {
|
} else if (data.items.length > 0) {
|
||||||
state.hasMore = state.currentPage < data.total_pages;
|
pageState.hasMore = pageState.currentPage < data.total_pages;
|
||||||
state.currentPage++;
|
pageState.currentPage++;
|
||||||
appendLoraCards(data.items);
|
appendLoraCards(data.items);
|
||||||
|
|
||||||
const sentinel = document.getElementById('scroll-sentinel');
|
const sentinel = document.getElementById('scroll-sentinel');
|
||||||
@@ -67,10 +78,10 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
|||||||
state.observer.observe(sentinel);
|
state.observer.observe(sentinel);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.hasMore = false;
|
pageState.hasMore = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (boolUpdateFolders && data.folders) {
|
if (updateFolders && data.folders) {
|
||||||
updateFolderTags(data.folders);
|
updateFolderTags(data.folders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +89,7 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
|||||||
console.error('Error loading loras:', error);
|
console.error('Error loading loras:', error);
|
||||||
showToast('Failed to load loras: ' + error.message, 'error');
|
showToast('Failed to load loras: ' + error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.isLoading = false;
|
pageState.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +98,8 @@ function updateFolderTags(folders) {
|
|||||||
if (!folderTagsContainer) return;
|
if (!folderTagsContainer) return;
|
||||||
|
|
||||||
// Keep track of currently selected folder
|
// Keep track of currently selected folder
|
||||||
const currentFolder = state.activeFolder;
|
const pageState = getCurrentPageState();
|
||||||
|
const currentFolder = pageState.activeFolder;
|
||||||
|
|
||||||
// Create HTML for folder tags
|
// Create HTML for folder tags
|
||||||
const tagsHTML = folders.map(folder => {
|
const tagsHTML = folders.map(folder => {
|
||||||
@@ -260,31 +272,19 @@ export function appendLoraCards(loras) {
|
|||||||
|
|
||||||
loras.forEach(lora => {
|
loras.forEach(lora => {
|
||||||
const card = createLoraCard(lora);
|
const card = createLoraCard(lora);
|
||||||
if (sentinel) {
|
grid.appendChild(card);
|
||||||
grid.insertBefore(card, sentinel);
|
|
||||||
} else {
|
|
||||||
grid.appendChild(card);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetAndReload(boolUpdateFolders = false) {
|
export async function resetAndReload(updateFolders = false) {
|
||||||
console.log('Resetting with state:', { ...state });
|
const pageState = getCurrentPageState();
|
||||||
|
console.log('Resetting with state:', { ...pageState });
|
||||||
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);
|
|
||||||
|
|
||||||
|
// Initialize infinite scroll - will reset the observer
|
||||||
initializeInfiniteScroll();
|
initializeInfiniteScroll();
|
||||||
|
|
||||||
await loadMoreLoras(boolUpdateFolders);
|
// Load more loras with reset flag
|
||||||
|
await loadMoreLoras(true, updateFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshLoras() {
|
export async function refreshLoras() {
|
||||||
|
|||||||
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,6 +1,7 @@
|
|||||||
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
|
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
|
||||||
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
|
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||||
|
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class LoraContextMenu {
|
export class LoraContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -149,7 +150,7 @@ export class LoraContextMenu {
|
|||||||
|
|
||||||
updateCardBlurEffect(card, level) {
|
updateCardBlurEffect(card, level) {
|
||||||
// Get user settings for blur threshold
|
// Get user settings for blur threshold
|
||||||
const blurThreshold = parseInt(localStorage.getItem('nsfwBlurLevel') || '4');
|
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
|
||||||
|
|
||||||
// Get card preview container
|
// Get card preview container
|
||||||
const previewContainer = card.querySelector('.card-preview');
|
const previewContainer = card.querySelector('.card-preview');
|
||||||
|
|||||||
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,5 +1,6 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||||
|
|
||||||
export function showLoraModal(lora) {
|
export function showLoraModal(lora) {
|
||||||
@@ -10,10 +11,10 @@ export function showLoraModal(lora) {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<button class="close" onclick="modalManager.closeModal('loraModal')">×</button>
|
<button class="close" onclick="modalManager.closeModal('loraModal')">×</button>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<div class="editable-field model-name-field">
|
<div class="model-name-header">
|
||||||
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${lora.model_name}</h2>
|
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${lora.model_name}</h2>
|
||||||
<button class="save-btn" onclick="saveModelName('${lora.file_path}')">
|
<button class="edit-model-name-btn" title="Edit model name">
|
||||||
<i class="fas fa-save"></i>
|
<i class="fas fa-pencil-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
${renderCompactTags(lora.tags || [])}
|
${renderCompactTags(lora.tags || [])}
|
||||||
@@ -122,6 +123,7 @@ export function showLoraModal(lora) {
|
|||||||
setupTabSwitching();
|
setupTabSwitching();
|
||||||
setupTagTooltip();
|
setupTagTooltip();
|
||||||
setupTriggerWordsEditMode();
|
setupTriggerWordsEditMode();
|
||||||
|
setupModelNameEditing();
|
||||||
|
|
||||||
// If we have a model ID but no description, fetch it
|
// If we have a model ID but no description, fetch it
|
||||||
if (lora.civitai?.modelId && !lora.modelDescription) {
|
if (lora.civitai?.modelId && !lora.modelDescription) {
|
||||||
@@ -405,61 +407,18 @@ function setupEditableFields() {
|
|||||||
|
|
||||||
editableFields.forEach(field => {
|
editableFields.forEach(field => {
|
||||||
field.addEventListener('focus', function() {
|
field.addEventListener('focus', function() {
|
||||||
if (this.textContent === 'Add your notes here...' ||
|
if (this.textContent === 'Add your notes here...') {
|
||||||
this.textContent === 'Save usage tips here..') {
|
|
||||||
this.textContent = '';
|
this.textContent = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
field.addEventListener('blur', function() {
|
field.addEventListener('blur', function() {
|
||||||
if (this.textContent.trim() === '') {
|
if (this.textContent.trim() === '') {
|
||||||
if (this.classList.contains('model-name-content')) {
|
if (this.classList.contains('notes-content')) {
|
||||||
// Restore original model name if empty
|
|
||||||
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}"]`);
|
|
||||||
if (loraCard) {
|
|
||||||
this.textContent = loraCard.dataset.model_name;
|
|
||||||
}
|
|
||||||
} else if (this.classList.contains('usage-tips-content')) {
|
|
||||||
this.textContent = 'Save usage tips here..';
|
|
||||||
} else {
|
|
||||||
this.textContent = 'Add your notes here...';
|
this.textContent = 'Add your notes here...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add input validation for model name
|
|
||||||
if (field.classList.contains('model-name-content')) {
|
|
||||||
field.addEventListener('input', function() {
|
|
||||||
// Limit model name length
|
|
||||||
if (this.textContent.length > 100) {
|
|
||||||
this.textContent = this.textContent.substring(0, 100);
|
|
||||||
// Place cursor at the end
|
|
||||||
const range = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
range.setStart(this.childNodes[0], 100);
|
|
||||||
range.collapse(true);
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
|
|
||||||
showToast('Model name is limited to 100 characters', 'warning');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
field.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const filePath = document.querySelector('.modal-content')
|
|
||||||
.querySelector('.file-path').textContent +
|
|
||||||
document.querySelector('.modal-content')
|
|
||||||
.querySelector('#file-name').textContent + '.safetensors';
|
|
||||||
saveModelName(filePath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const presetSelector = document.getElementById('preset-selector');
|
const presetSelector = document.getElementById('preset-selector');
|
||||||
@@ -471,9 +430,9 @@ function setupEditableFields() {
|
|||||||
const selected = this.value;
|
const selected = this.value;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
presetValue.style.display = 'inline-block';
|
presetValue.style.display = 'inline-block';
|
||||||
presetValue.min = selected.includes('strength') ? 0 : 1;
|
presetValue.min = selected.includes('strength') ? -10 : 0;
|
||||||
presetValue.max = selected.includes('strength') ? 1 : 12;
|
presetValue.max = selected.includes('strength') ? 10 : 10;
|
||||||
presetValue.step = selected.includes('strength') ? 0.01 : 1;
|
presetValue.step = 0.5;
|
||||||
if (selected === 'clip_skip') {
|
if (selected === 'clip_skip') {
|
||||||
presetValue.type = 'number';
|
presetValue.type = 'number';
|
||||||
presetValue.step = 1;
|
presetValue.step = 1;
|
||||||
@@ -491,10 +450,10 @@ function setupEditableFields() {
|
|||||||
|
|
||||||
if (!key || !value) return;
|
if (!key || !value) return;
|
||||||
|
|
||||||
const filePath = document.querySelector('.modal-content')
|
const filePath = document.querySelector('#loraModal .modal-content')
|
||||||
.querySelector('.file-path').textContent +
|
.querySelector('.file-path').textContent +
|
||||||
document.querySelector('.modal-content')
|
document.querySelector('#loraModal .modal-content')
|
||||||
.querySelector('#file-name').textContent + '.safetensors';
|
.querySelector('#file-name').textContent + '.safetensors';
|
||||||
|
|
||||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||||
@@ -524,9 +483,9 @@ function setupEditableFields() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const filePath = document.querySelector('.modal-content')
|
const filePath = document.querySelector('#loraModal .modal-content')
|
||||||
.querySelector('.file-path').textContent +
|
.querySelector('.file-path').textContent +
|
||||||
document.querySelector('.modal-content')
|
document.querySelector('#loraModal .modal-content')
|
||||||
.querySelector('#file-name').textContent + '.safetensors';
|
.querySelector('#file-name').textContent + '.safetensors';
|
||||||
await saveNotes(filePath);
|
await saveNotes(filePath);
|
||||||
}
|
}
|
||||||
@@ -747,9 +706,10 @@ function initLazyLoading(container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupShowcaseScroll() {
|
export function setupShowcaseScroll() {
|
||||||
// Change from modal-content to window/document level
|
// Add event listener to document for wheel events
|
||||||
document.addEventListener('wheel', (event) => {
|
document.addEventListener('wheel', (event) => {
|
||||||
const modalContent = document.querySelector('.modal-content');
|
// Find the active modal content
|
||||||
|
const modalContent = document.querySelector('#loraModal .modal-content');
|
||||||
if (!modalContent) return;
|
if (!modalContent) return;
|
||||||
|
|
||||||
const showcase = modalContent.querySelector('.showcase-section');
|
const showcase = modalContent.querySelector('.showcase-section');
|
||||||
@@ -766,24 +726,52 @@ export function setupShowcaseScroll() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { passive: false }); // Add passive: false option here
|
}, { passive: false });
|
||||||
|
|
||||||
// Keep the existing scroll tracking code
|
// Use MutationObserver instead of deprecated DOMNodeInserted
|
||||||
const modalContent = document.querySelector('.modal-content');
|
const observer = new MutationObserver((mutations) => {
|
||||||
if (modalContent) {
|
for (const mutation of mutations) {
|
||||||
modalContent.addEventListener('scroll', () => {
|
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
||||||
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
// Check if loraModal content was added
|
||||||
if (backToTopBtn) {
|
const loraModal = document.getElementById('loraModal');
|
||||||
if (modalContent.scrollTop > 300) {
|
if (loraModal && loraModal.querySelector('.modal-content')) {
|
||||||
backToTopBtn.classList.add('visible');
|
setupBackToTopButton(loraModal.querySelector('.modal-content'));
|
||||||
} else {
|
|
||||||
backToTopBtn.classList.remove('visible');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the document body for changes
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Also try to set up the button immediately in case the modal is already open
|
||||||
|
const modalContent = document.querySelector('#loraModal .modal-content');
|
||||||
|
if (modalContent) {
|
||||||
|
setupBackToTopButton(modalContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New helper function to set up the back to top button
|
||||||
|
function setupBackToTopButton(modalContent) {
|
||||||
|
// Remove any existing scroll listeners to avoid duplicates
|
||||||
|
modalContent.onscroll = null;
|
||||||
|
|
||||||
|
// Add new scroll listener
|
||||||
|
modalContent.addEventListener('scroll', () => {
|
||||||
|
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
||||||
|
if (backToTopBtn) {
|
||||||
|
if (modalContent.scrollTop > 300) {
|
||||||
|
backToTopBtn.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
backToTopBtn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a scroll event to check initial position
|
||||||
|
modalContent.dispatchEvent(new Event('scroll'));
|
||||||
|
}
|
||||||
|
|
||||||
export function scrollToTop(button) {
|
export function scrollToTop(button) {
|
||||||
const modalContent = button.closest('.modal-content');
|
const modalContent = button.closest('.modal-content');
|
||||||
if (modalContent) {
|
if (modalContent) {
|
||||||
@@ -795,7 +783,7 @@ export function scrollToTop(button) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parsePresets(usageTips) {
|
function parsePresets(usageTips) {
|
||||||
if (!usageTips || usageTips === 'Save usage tips here..') return {};
|
if (!usageTips) return {};
|
||||||
try {
|
try {
|
||||||
return JSON.parse(usageTips);
|
return JSON.parse(usageTips);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -819,9 +807,9 @@ function formatPresetKey(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.removePreset = async function(key) {
|
window.removePreset = async function(key) {
|
||||||
const filePath = document.querySelector('.modal-content')
|
const filePath = document.querySelector('#loraModal .modal-content')
|
||||||
.querySelector('.file-path').textContent +
|
.querySelector('.file-path').textContent +
|
||||||
document.querySelector('.modal-content')
|
document.querySelector('#loraModal .modal-content')
|
||||||
.querySelector('#file-name').textContent + '.safetensors';
|
.querySelector('#file-name').textContent + '.safetensors';
|
||||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||||
@@ -852,17 +840,6 @@ function formatFileSize(bytes) {
|
|||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tag copy functionality
|
|
||||||
window.copyTag = async function(tag) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(tag);
|
|
||||||
showToast('Tag copied to clipboard', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
showToast('Copy failed', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// New function to render compact tags with tooltip
|
// New function to render compact tags with tooltip
|
||||||
function renderCompactTags(tags) {
|
function renderCompactTags(tags) {
|
||||||
if (!tags || tags.length === 0) return '';
|
if (!tags || tags.length === 0) return '';
|
||||||
@@ -1161,4 +1138,90 @@ window.copyTriggerWord = async function(word) {
|
|||||||
console.error('Copy failed:', err);
|
console.error('Copy failed:', err);
|
||||||
showToast('Copy failed', 'error');
|
showToast('Copy failed', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// New function to handle model name editing
|
||||||
|
function setupModelNameEditing() {
|
||||||
|
const modelNameContent = document.querySelector('.model-name-content');
|
||||||
|
const editBtn = document.querySelector('.edit-model-name-btn');
|
||||||
|
|
||||||
|
if (!modelNameContent || !editBtn) return;
|
||||||
|
|
||||||
|
// Show edit button on hover
|
||||||
|
const modelNameHeader = document.querySelector('.model-name-header');
|
||||||
|
modelNameHeader.addEventListener('mouseenter', () => {
|
||||||
|
editBtn.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
modelNameHeader.addEventListener('mouseleave', () => {
|
||||||
|
if (!modelNameContent.getAttribute('data-editing')) {
|
||||||
|
editBtn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle edit button click
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
modelNameContent.setAttribute('data-editing', 'true');
|
||||||
|
modelNameContent.focus();
|
||||||
|
|
||||||
|
// Place cursor at the end
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (modelNameContent.childNodes.length > 0) {
|
||||||
|
range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
editBtn.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle focus out
|
||||||
|
modelNameContent.addEventListener('blur', function() {
|
||||||
|
this.removeAttribute('data-editing');
|
||||||
|
editBtn.classList.remove('visible');
|
||||||
|
|
||||||
|
if (this.textContent.trim() === '') {
|
||||||
|
// Restore original model name if empty
|
||||||
|
const filePath = document.querySelector('#loraModal .modal-content')
|
||||||
|
.querySelector('.file-path').textContent +
|
||||||
|
document.querySelector('#loraModal .modal-content')
|
||||||
|
.querySelector('#file-name').textContent + '.safetensors';
|
||||||
|
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||||
|
if (loraCard) {
|
||||||
|
this.textContent = loraCard.dataset.model_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle enter key
|
||||||
|
modelNameContent.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const filePath = document.querySelector('#loraModal .modal-content')
|
||||||
|
.querySelector('.file-path').textContent +
|
||||||
|
document.querySelector('#loraModal .modal-content')
|
||||||
|
.querySelector('#file-name').textContent + '.safetensors';
|
||||||
|
saveModelName(filePath);
|
||||||
|
this.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit model name length
|
||||||
|
modelNameContent.addEventListener('input', function() {
|
||||||
|
// Limit model name length
|
||||||
|
if (this.textContent.length > 100) {
|
||||||
|
this.textContent = this.textContent.substring(0, 100);
|
||||||
|
// Place cursor at the end
|
||||||
|
const range = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
range.setStart(this.childNodes[0], 100);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
|
||||||
|
showToast('Model name is limited to 100 characters', 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
280
static/js/components/RecipeCard.js
Normal file
280
static/js/components/RecipeCard.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// 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).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 {
|
||||||
|
// Generate recipe syntax in the format <lora:file_name:strength> separated by spaces
|
||||||
|
const loras = this.recipe.loras || [];
|
||||||
|
if (loras.length === 0) {
|
||||||
|
showToast('No LoRAs in this recipe to copy', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntax = loras.map(lora => {
|
||||||
|
// Use file_name if available, otherwise use empty placeholder
|
||||||
|
const fileName = lora.file_name || '[missing-lora]';
|
||||||
|
const strength = lora.strength || 1.0;
|
||||||
|
return `<lora:${fileName}:${strength}>`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
navigator.clipboard.writeText(syntax)
|
||||||
|
.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 };
|
||||||
287
static/js/components/RecipeModal.js
Normal file
287
static/js/components/RecipeModal.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Recipe Modal Component
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
class RecipeModal {
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupCopyButtons();
|
||||||
|
// Set up tooltip positioning handlers after DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
this.setupTooltipPositioning();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips
|
||||||
|
setupTooltipPositioning() {
|
||||||
|
document.addEventListener('mouseover', (event) => {
|
||||||
|
// Check if we're hovering over a local-badge
|
||||||
|
if (event.target.closest('.local-badge')) {
|
||||||
|
const badge = event.target.closest('.local-badge');
|
||||||
|
const tooltip = badge.querySelector('.local-path');
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
// Get badge position
|
||||||
|
const badgeRect = badge.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Position the tooltip
|
||||||
|
tooltip.style.top = (badgeRect.bottom + 4) + 'px';
|
||||||
|
tooltip.style.left = (badgeRect.right - tooltip.offsetWidth) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
showRecipeDetails(recipe) {
|
||||||
|
// Set modal title
|
||||||
|
const modalTitle = document.getElementById('recipeModalTitle');
|
||||||
|
if (modalTitle) {
|
||||||
|
modalTitle.textContent = recipe.title || 'Recipe Details';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set recipe tags if they exist
|
||||||
|
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||||
|
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||||||
|
|
||||||
|
if (tagsCompactElement && tagsTooltipContent && recipe.tags && recipe.tags.length > 0) {
|
||||||
|
// Clear previous tags
|
||||||
|
tagsCompactElement.innerHTML = '';
|
||||||
|
tagsTooltipContent.innerHTML = '';
|
||||||
|
|
||||||
|
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||||
|
const maxVisibleTags = 5;
|
||||||
|
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
||||||
|
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
||||||
|
|
||||||
|
// Add visible tags
|
||||||
|
visibleTags.forEach(tag => {
|
||||||
|
const tagElement = document.createElement('div');
|
||||||
|
tagElement.className = 'recipe-tag-compact';
|
||||||
|
tagElement.textContent = tag;
|
||||||
|
tagsCompactElement.appendChild(tagElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add "more" button if needed
|
||||||
|
if (remainingTags.length > 0) {
|
||||||
|
const moreButton = document.createElement('div');
|
||||||
|
moreButton.className = 'recipe-tag-more';
|
||||||
|
moreButton.textContent = `+${remainingTags.length} more`;
|
||||||
|
tagsCompactElement.appendChild(moreButton);
|
||||||
|
|
||||||
|
// Add tooltip functionality
|
||||||
|
moreButton.addEventListener('mouseenter', () => {
|
||||||
|
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
moreButton.addEventListener('mouseleave', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||||||
|
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
||||||
|
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add all tags to tooltip
|
||||||
|
recipe.tags.forEach(tag => {
|
||||||
|
const tooltipTag = document.createElement('div');
|
||||||
|
tooltipTag.className = 'tooltip-tag';
|
||||||
|
tooltipTag.textContent = tag;
|
||||||
|
tagsTooltipContent.appendChild(tooltipTag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (tagsCompactElement) {
|
||||||
|
// No tags to display
|
||||||
|
tagsCompactElement.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set recipe image
|
||||||
|
const modalImage = document.getElementById('recipeModalImage');
|
||||||
|
if (modalImage) {
|
||||||
|
// Ensure file_url exists, fallback to file_path if needed
|
||||||
|
const imageUrl = recipe.file_url ||
|
||||||
|
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||||
|
'/loras_static/images/no-preview.png');
|
||||||
|
modalImage.src = imageUrl;
|
||||||
|
modalImage.alt = recipe.title || 'Recipe Preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set generation parameters
|
||||||
|
const promptElement = document.getElementById('recipePrompt');
|
||||||
|
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||||
|
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||||
|
|
||||||
|
if (recipe.gen_params) {
|
||||||
|
// Set prompt
|
||||||
|
if (promptElement && recipe.gen_params.prompt) {
|
||||||
|
promptElement.textContent = recipe.gen_params.prompt;
|
||||||
|
} else if (promptElement) {
|
||||||
|
promptElement.textContent = 'No prompt information available';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set negative prompt
|
||||||
|
if (negativePromptElement && recipe.gen_params.negative_prompt) {
|
||||||
|
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
|
||||||
|
} else if (negativePromptElement) {
|
||||||
|
negativePromptElement.textContent = 'No negative prompt information available';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set other parameters
|
||||||
|
if (otherParamsElement) {
|
||||||
|
// Clear previous params
|
||||||
|
otherParamsElement.innerHTML = '';
|
||||||
|
|
||||||
|
// Add all other parameters except prompt and negative_prompt
|
||||||
|
const excludedParams = ['prompt', 'negative_prompt'];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(recipe.gen_params)) {
|
||||||
|
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||||
|
const paramTag = document.createElement('div');
|
||||||
|
paramTag.className = 'param-tag';
|
||||||
|
paramTag.innerHTML = `
|
||||||
|
<span class="param-name">${key}:</span>
|
||||||
|
<span class="param-value">${value}</span>
|
||||||
|
`;
|
||||||
|
otherParamsElement.appendChild(paramTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no other params, show a message
|
||||||
|
if (otherParamsElement.children.length === 0) {
|
||||||
|
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No generation parameters available
|
||||||
|
if (promptElement) promptElement.textContent = 'No prompt information available';
|
||||||
|
if (negativePromptElement) negativePromptElement.textContent = 'No negative prompt information available';
|
||||||
|
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set LoRAs list and count
|
||||||
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
|
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||||
|
|
||||||
|
// 检查所有 LoRAs 是否都在库中
|
||||||
|
let allLorasAvailable = true;
|
||||||
|
let missingLorasCount = 0;
|
||||||
|
|
||||||
|
if (recipe.loras && recipe.loras.length > 0) {
|
||||||
|
recipe.loras.forEach(lora => {
|
||||||
|
if (!lora.inLibrary) {
|
||||||
|
allLorasAvailable = false;
|
||||||
|
missingLorasCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 LoRAs 计数和状态
|
||||||
|
if (lorasCountElement && recipe.loras) {
|
||||||
|
const totalCount = recipe.loras.length;
|
||||||
|
|
||||||
|
// 创建状态指示器
|
||||||
|
let statusHTML = '';
|
||||||
|
if (totalCount > 0) {
|
||||||
|
if (allLorasAvailable) {
|
||||||
|
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||||
|
} else {
|
||||||
|
statusHTML = `<div class="recipe-status missing"><i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
||||||
|
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
||||||
|
const existsLocally = lora.inLibrary;
|
||||||
|
const localPath = lora.localPath || '';
|
||||||
|
|
||||||
|
// Create local status badge with a more stable structure
|
||||||
|
const localStatus = existsLocally ?
|
||||||
|
`<div class="local-badge">
|
||||||
|
<i class="fas fa-check"></i> In Library
|
||||||
|
<div class="local-path">${localPath}</div>
|
||||||
|
</div>` :
|
||||||
|
`<div class="missing-badge">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="recipe-lora-item ${existsLocally ? 'exists-locally' : 'missing-locally'}">
|
||||||
|
<div class="recipe-lora-thumbnail">
|
||||||
|
<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">
|
||||||
|
</div>
|
||||||
|
<div class="recipe-lora-content">
|
||||||
|
<div class="recipe-lora-header">
|
||||||
|
<h4>${lora.modelName}</h4>
|
||||||
|
<div class="badge-container">${localStatus}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-lora-info">
|
||||||
|
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||||||
|
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||||
|
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Generate recipe syntax for copy button
|
||||||
|
this.recipeLorasSyntax = recipe.loras.map(lora =>
|
||||||
|
`<lora:${lora.file_name}:${lora.strength || 1.0}>`
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
} else if (lorasListElement) {
|
||||||
|
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||||
|
this.recipeLorasSyntax = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
modalManager.showModal('recipeModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup copy buttons for prompts and recipe syntax
|
||||||
|
setupCopyButtons() {
|
||||||
|
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||||
|
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||||
|
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||||||
|
|
||||||
|
if (copyPromptBtn) {
|
||||||
|
copyPromptBtn.addEventListener('click', () => {
|
||||||
|
const promptText = document.getElementById('recipePrompt').textContent;
|
||||||
|
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyNegativePromptBtn) {
|
||||||
|
copyNegativePromptBtn.addEventListener('click', () => {
|
||||||
|
const negativePromptText = document.getElementById('recipeNegativePrompt').textContent;
|
||||||
|
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyRecipeSyntaxBtn) {
|
||||||
|
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
||||||
|
this.copyToClipboard(this.recipeLorasSyntax, 'Recipe syntax copied to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to copy text to clipboard
|
||||||
|
copyToClipboard(text, successMessage) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast(successMessage, 'success');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
showToast('Failed to copy text', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RecipeModal };
|
||||||
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 };
|
||||||
106
static/js/loras.js
Normal file
106
static/js/loras.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
initializeEventListeners();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,115 +0,0 @@
|
|||||||
import { debounce } from './utils/debounce.js';
|
|
||||||
import { LoadingManager } from './managers/LoadingManager.js';
|
|
||||||
import { modalManager } from './managers/ModalManager.js';
|
|
||||||
import { updateService } from './managers/UpdateService.js';
|
|
||||||
import { state, initSettings } from './state/index.js';
|
|
||||||
import { showLoraModal } from './components/LoraModal.js';
|
|
||||||
import { toggleShowcase, scrollToTop } from './components/LoraModal.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';
|
|
||||||
import { FilterManager } from './managers/FilterManager.js';
|
|
||||||
import { createLoraCard, updateCardsForBulkMode } from './components/LoraCard.js';
|
|
||||||
import { bulkManager } from './managers/BulkManager.js';
|
|
||||||
|
|
||||||
// Add bulk mode to state
|
|
||||||
state.bulkMode = false;
|
|
||||||
state.selectedLoras = new Set();
|
|
||||||
|
|
||||||
// Export functions to global window object
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Export bulk manager methods to window
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Initialize everything when DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
// Ensure settings are initialized
|
|
||||||
initSettings();
|
|
||||||
|
|
||||||
state.loadingManager = new LoadingManager();
|
|
||||||
modalManager.initialize(); // Initialize modalManager after DOM is loaded
|
|
||||||
updateService.initialize(); // Initialize updateService after modalManager
|
|
||||||
window.downloadManager = new DownloadManager(); // Move this after modalManager initialization
|
|
||||||
window.filterManager = new FilterManager(); // Initialize filter manager
|
|
||||||
|
|
||||||
// Initialize state filters from filterManager if available
|
|
||||||
if (window.filterManager && window.filterManager.filters) {
|
|
||||||
state.filters = { ...window.filterManager.filters };
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeInfiniteScroll();
|
|
||||||
initializeEventListeners();
|
|
||||||
lazyLoadImages();
|
|
||||||
restoreFolderFilter();
|
|
||||||
initTheme();
|
|
||||||
initFolderTagsVisibility();
|
|
||||||
initBackToTop();
|
|
||||||
window.searchManager = new SearchManager();
|
|
||||||
new LoraContextMenu();
|
|
||||||
|
|
||||||
// Initialize cards for current bulk mode state (should be false initially)
|
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
|
||||||
|
|
||||||
// Initialize the bulk manager
|
|
||||||
bulkManager.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -91,16 +91,17 @@ export class BulkManager {
|
|||||||
// Set text content without the icon
|
// Set text content without the icon
|
||||||
countElement.textContent = `${state.selectedLoras.size} selected `;
|
countElement.textContent = `${state.selectedLoras.size} selected `;
|
||||||
|
|
||||||
// Re-add the caret icon with proper direction
|
// Update caret icon if it exists
|
||||||
const caretIcon = document.createElement('i');
|
const existingCaret = countElement.querySelector('.dropdown-caret');
|
||||||
// Use down arrow if strip is visible, up arrow if not
|
if (existingCaret) {
|
||||||
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||||
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
existingCaret.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
||||||
countElement.appendChild(caretIcon);
|
} else {
|
||||||
|
// Create new caret icon if it doesn't exist
|
||||||
// If there are no selections, hide the thumbnail strip
|
const caretIcon = document.createElement('i');
|
||||||
if (state.selectedLoras.size === 0) {
|
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||||
this.hideThumbnailStrip();
|
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
||||||
|
countElement.appendChild(caretIcon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,12 +253,20 @@ export class BulkManager {
|
|||||||
|
|
||||||
hideThumbnailStrip() {
|
hideThumbnailStrip() {
|
||||||
const strip = document.querySelector('.selected-thumbnails-strip');
|
const strip = document.querySelector('.selected-thumbnails-strip');
|
||||||
if (strip) {
|
if (strip && this.isStripVisible) { // Only hide if actually visible
|
||||||
strip.classList.remove('visible');
|
strip.classList.remove('visible');
|
||||||
|
|
||||||
// Update strip visibility state and caret direction
|
// Update strip visibility state
|
||||||
this.isStripVisible = false;
|
this.isStripVisible = false;
|
||||||
this.updateSelectedCount(); // Update caret
|
|
||||||
|
// 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
|
// Wait for animation to complete before removing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -340,4 +349,4 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a singleton instance
|
// Create a singleton instance
|
||||||
export const bulkManager = new BulkManager();
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,20 +120,42 @@ export class DownloadManager {
|
|||||||
versionList.innerHTML = this.versions.map(version => {
|
versionList.innerHTML = this.versions.map(version => {
|
||||||
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
const fileSize = (version.files[0]?.sizeKB / 1024).toFixed(2);
|
|
||||||
|
|
||||||
const existsLocally = version.files[0]?.existsLocally;
|
// Use version-level size or fallback to first file
|
||||||
const localPath = version.files[0]?.localPath;
|
const fileSize = version.modelSizeKB ?
|
||||||
|
(version.modelSizeKB / 1024).toFixed(2) :
|
||||||
|
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
||||||
|
|
||||||
// 更新本地状态指示器为badge样式
|
// 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 ?
|
const localStatus = existsLocally ?
|
||||||
`<div class="local-badge">
|
`<div class="local-badge">
|
||||||
<i class="fas fa-check"></i> In Library
|
<i class="fas fa-check"></i> In Library
|
||||||
<div class="local-path">${localPath}</div>
|
<div class="local-path">${localPath || ''}</div>
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''} ${existsLocally ? 'exists-locally' : ''}"
|
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||||
|
${existsLocally ? 'exists-locally' : ''}
|
||||||
|
${isEarlyAccess ? 'is-early-access' : ''}"
|
||||||
onclick="downloadManager.selectVersion('${version.id}')">
|
onclick="downloadManager.selectVersion('${version.id}')">
|
||||||
<div class="version-thumbnail">
|
<div class="version-thumbnail">
|
||||||
<img src="${thumbnailUrl}" alt="Version preview">
|
<img src="${thumbnailUrl}" alt="Version preview">
|
||||||
@@ -145,6 +167,7 @@ export class DownloadManager {
|
|||||||
</div>
|
</div>
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
|
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
|
||||||
|
${earlyAccessBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="version-meta">
|
<div class="version-meta">
|
||||||
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
||||||
@@ -177,12 +200,12 @@ export class DownloadManager {
|
|||||||
this.updateNextButtonState();
|
this.updateNextButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new method to update Next button state
|
// Update this method to use version-level existsLocally
|
||||||
updateNextButtonState() {
|
updateNextButtonState() {
|
||||||
const nextButton = document.querySelector('#versionStep .primary-btn');
|
const nextButton = document.querySelector('#versionStep .primary-btn');
|
||||||
if (!nextButton) return;
|
if (!nextButton) return;
|
||||||
|
|
||||||
const existsLocally = this.currentVersion?.files[0]?.existsLocally;
|
const existsLocally = this.currentVersion?.existsLocally;
|
||||||
|
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
nextButton.disabled = true;
|
nextButton.disabled = true;
|
||||||
@@ -202,7 +225,7 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Double-check if the version exists locally
|
// Double-check if the version exists locally
|
||||||
const existsLocally = this.currentVersion.files[0]?.existsLocally;
|
const existsLocally = this.currentVersion.existsLocally;
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
showToast('This version already exists in your library', 'info');
|
showToast('This version already exists in your library', 'info');
|
||||||
return;
|
return;
|
||||||
@@ -265,19 +288,37 @@ export class DownloadManager {
|
|||||||
throw new Error('No download URL available');
|
throw new Error('No download URL available');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading with progress bar for download
|
// Show enhanced loading with progress details
|
||||||
this.loadingManager.show('Downloading LoRA...', 0);
|
const updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||||
|
updateProgress(0, 0, this.currentVersion.name);
|
||||||
|
|
||||||
// Setup WebSocket for progress updates
|
// Setup WebSocket for progress updates
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
this.loadingManager.setProgress(data.progress);
|
// Update progress display with current progress
|
||||||
this.loadingManager.setStatus(`Downloading: ${data.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
|
// Start download
|
||||||
const response = await fetch('/api/download-lora', {
|
const response = await fetch('/api/download-lora', {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
|
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
|
||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||||
import { resetAndReload } from '../api/loraApi.js';
|
import { loadMoreLoras } from '../api/loraApi.js';
|
||||||
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
constructor() {
|
constructor(options = {}) {
|
||||||
this.filters = {
|
this.options = {
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
this.filters = pageState.filters || {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: []
|
tags: []
|
||||||
};
|
};
|
||||||
@@ -13,17 +21,32 @@ export class FilterManager {
|
|||||||
this.filterPanel = document.getElementById('filterPanel');
|
this.filterPanel = document.getElementById('filterPanel');
|
||||||
this.filterButton = document.getElementById('filterButton');
|
this.filterButton = document.getElementById('filterButton');
|
||||||
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||||
|
this.tagsLoaded = false;
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
|
// Store this instance in the state
|
||||||
|
if (pageState) {
|
||||||
|
pageState.filterManager = this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Create base model filter tags
|
// Create base model filter tags if they exist
|
||||||
this.createBaseModelTags();
|
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
|
// Close filter panel when clicking outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!this.filterPanel.contains(e.target) &&
|
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
|
||||||
e.target !== this.filterButton &&
|
e.target !== this.filterButton &&
|
||||||
!this.filterButton.contains(e.target) &&
|
!this.filterButton.contains(e.target) &&
|
||||||
!this.filterPanel.classList.contains('hidden')) {
|
!this.filterPanel.classList.contains('hidden')) {
|
||||||
@@ -39,15 +62,20 @@ export class FilterManager {
|
|||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
if (tagsContainer) {
|
if (!tagsContainer) return;
|
||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
|
||||||
|
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('/api/top-tags?limit=20');
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Top tags:', data);
|
|
||||||
if (data.success && data.tags) {
|
if (data.success && data.tags) {
|
||||||
this.createTagFilterElements(data.tags);
|
this.createTagFilterElements(data.tags);
|
||||||
|
|
||||||
@@ -72,14 +100,13 @@ export class FilterManager {
|
|||||||
tagsContainer.innerHTML = '';
|
tagsContainer.innerHTML = '';
|
||||||
|
|
||||||
if (!tags.length) {
|
if (!tags.length) {
|
||||||
tagsContainer.innerHTML = '<div class="no-tags">No tags available</div>';
|
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const tagEl = document.createElement('div');
|
const tagEl = document.createElement('div');
|
||||||
tagEl.className = 'filter-tag tag-filter';
|
tagEl.className = 'filter-tag tag-filter';
|
||||||
// {tag: "name", count: number}
|
|
||||||
const tagName = tag.tag;
|
const tagName = tag.tag;
|
||||||
tagEl.dataset.tag = tagName;
|
tagEl.dataset.tag = tagName;
|
||||||
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||||
@@ -110,50 +137,93 @@ export class FilterManager {
|
|||||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
baseModelTagsContainer.innerHTML = '';
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
Object.entries(BASE_MODELS).forEach(([key, value]) => {
|
// Fetch base models
|
||||||
const tag = document.createElement('div');
|
fetch(apiEndpoint)
|
||||||
tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
|
.then(response => response.json())
|
||||||
tag.dataset.baseModel = value;
|
.then(data => {
|
||||||
tag.innerHTML = value;
|
if (data.success && data.base_models) {
|
||||||
|
baseModelTagsContainer.innerHTML = '';
|
||||||
// Add click handler to toggle selection and automatically apply
|
|
||||||
tag.addEventListener('click', async () => {
|
data.base_models.forEach(model => {
|
||||||
tag.classList.toggle('active');
|
const tag = document.createElement('div');
|
||||||
|
// Add base model classes only for the loras page
|
||||||
if (tag.classList.contains('active')) {
|
const baseModelClass = (this.currentPage === 'loras' && BASE_MODEL_CLASSES[model.name])
|
||||||
if (!this.filters.baseModel.includes(value)) {
|
? BASE_MODEL_CLASSES[model.name]
|
||||||
this.filters.baseModel.push(value);
|
: '';
|
||||||
}
|
tag.className = `filter-tag base-model-tag ${baseModelClass}`;
|
||||||
} else {
|
tag.dataset.baseModel = model.name;
|
||||||
this.filters.baseModel = this.filters.baseModel.filter(model => model !== value);
|
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();
|
||||||
}
|
}
|
||||||
|
})
|
||||||
this.updateActiveFiltersCount();
|
.catch(error => {
|
||||||
|
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
||||||
// Auto-apply filter when tag is clicked
|
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
||||||
await this.applyFilters(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
baseModelTagsContainer.appendChild(tag);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFilterPanel() {
|
toggleFilterPanel() {
|
||||||
const wasHidden = this.filterPanel.classList.contains('hidden');
|
if (this.filterPanel) {
|
||||||
|
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||||
this.filterPanel.classList.toggle('hidden');
|
|
||||||
|
if (isHidden) {
|
||||||
// If the panel is being opened, load the top tags and update selections
|
// Update panel positions before showing
|
||||||
if (wasHidden) {
|
updatePanelPositions();
|
||||||
this.loadTopTags();
|
|
||||||
this.updateTagSelections();
|
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() {
|
closeFilterPanel() {
|
||||||
this.filterPanel.classList.add('hidden');
|
if (this.filterPanel) {
|
||||||
|
this.filterPanel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (this.filterButton) {
|
||||||
|
this.filterButton.classList.remove('active');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTagSelections() {
|
updateTagSelections() {
|
||||||
@@ -183,23 +253,35 @@ export class FilterManager {
|
|||||||
updateActiveFiltersCount() {
|
updateActiveFiltersCount() {
|
||||||
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
|
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
|
||||||
|
|
||||||
if (totalActiveFilters > 0) {
|
if (this.activeFiltersCount) {
|
||||||
this.activeFiltersCount.textContent = totalActiveFilters;
|
if (totalActiveFilters > 0) {
|
||||||
this.activeFiltersCount.style.display = 'inline-flex';
|
this.activeFiltersCount.textContent = totalActiveFilters;
|
||||||
} else {
|
this.activeFiltersCount.style.display = 'inline-flex';
|
||||||
this.activeFiltersCount.style.display = 'none';
|
} else {
|
||||||
|
this.activeFiltersCount.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyFilters(showToastNotification = true) {
|
async applyFilters(showToastNotification = true) {
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
|
||||||
// Save filters to localStorage
|
// Save filters to localStorage
|
||||||
localStorage.setItem('loraFilters', JSON.stringify(this.filters));
|
setStorageItem(storageKey, this.filters);
|
||||||
|
|
||||||
// Update state with current filters
|
// Update state with current filters
|
||||||
state.filters = { ...this.filters };
|
pageState.filters = { ...this.filters };
|
||||||
|
|
||||||
// Reload loras with filters applied
|
// Call the appropriate manager's load method based on page type
|
||||||
await resetAndReload();
|
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
|
// Update filter button to show active state
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
@@ -235,32 +317,48 @@ export class FilterManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
state.filters = { ...this.filters };
|
const pageState = getCurrentPageState();
|
||||||
|
pageState.filters = { ...this.filters };
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Remove from localStorage
|
// Remove from local Storage
|
||||||
localStorage.removeItem('loraFilters');
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
removeStorageItem(storageKey);
|
||||||
|
|
||||||
// Update UI and reload data
|
// Update UI
|
||||||
this.filterButton.classList.remove('active');
|
this.filterButton.classList.remove('active');
|
||||||
await resetAndReload();
|
|
||||||
|
// 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() {
|
loadFiltersFromStorage() {
|
||||||
const savedFilters = localStorage.getItem('loraFilters');
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
const savedFilters = getStorageItem(storageKey);
|
||||||
|
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
const parsedFilters = JSON.parse(savedFilters);
|
|
||||||
|
|
||||||
// Ensure backward compatibility with older filter format
|
// Ensure backward compatibility with older filter format
|
||||||
this.filters = {
|
this.filters = {
|
||||||
baseModel: parsedFilters.baseModel || [],
|
baseModel: savedFilters.baseModel || [],
|
||||||
tags: parsedFilters.tags || []
|
tags: savedFilters.tags || []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update state with loaded filters
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
pageState.filters = { ...this.filters };
|
||||||
|
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
@@ -268,7 +366,7 @@ export class FilterManager {
|
|||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading filters from storage:', error);
|
console.error(`Error loading ${this.currentPage} filters from storage:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1216
static/js/managers/ImportManager.js
Normal file
1216
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.overlay = document.getElementById('loading-overlay');
|
||||||
this.progressBar = this.overlay.querySelector('.progress-bar');
|
this.progressBar = this.overlay.querySelector('.progress-bar');
|
||||||
this.statusText = this.overlay.querySelector('.loading-status');
|
this.statusText = this.overlay.querySelector('.loading-status');
|
||||||
|
this.detailsContainer = null; // Will be created when needed
|
||||||
}
|
}
|
||||||
|
|
||||||
show(message = 'Loading...', progress = 0) {
|
show(message = 'Loading...', progress = 0) {
|
||||||
this.overlay.style.display = 'flex';
|
this.overlay.style.display = 'flex';
|
||||||
this.setProgress(progress);
|
this.setProgress(progress);
|
||||||
this.setStatus(message);
|
this.setStatus(message);
|
||||||
|
|
||||||
|
// Remove any existing details container
|
||||||
|
this.removeDetailsContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
this.overlay.style.display = 'none';
|
this.overlay.style.display = 'none';
|
||||||
this.reset();
|
this.reset();
|
||||||
|
this.removeDetailsContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(percent) {
|
setProgress(percent) {
|
||||||
@@ -29,6 +34,101 @@ export class LoadingManager {
|
|||||||
reset() {
|
reset() {
|
||||||
this.setProgress(0);
|
this.setProgress(0);
|
||||||
this.setStatus('');
|
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 = {}) {
|
async showWithProgress(callback, options = {}) {
|
||||||
|
|||||||
@@ -10,67 +10,114 @@ export class ModalManager {
|
|||||||
|
|
||||||
this.boundHandleEscape = this.handleEscape.bind(this);
|
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||||
|
|
||||||
// Register all modals
|
// Register all modals - only if they exist in the current page
|
||||||
this.registerModal('loraModal', {
|
const loraModal = document.getElementById('loraModal');
|
||||||
element: document.getElementById('loraModal'),
|
if (loraModal) {
|
||||||
onClose: () => {
|
this.registerModal('loraModal', {
|
||||||
this.getModal('loraModal').element.style.display = 'none';
|
element: loraModal,
|
||||||
document.body.classList.remove('modal-open');
|
onClose: () => {
|
||||||
}
|
this.getModal('loraModal').element.style.display = 'none';
|
||||||
});
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.registerModal('deleteModal', {
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
element: document.getElementById('deleteModal'),
|
if (deleteModal) {
|
||||||
onClose: () => {
|
this.registerModal('deleteModal', {
|
||||||
this.getModal('deleteModal').element.classList.remove('show');
|
element: deleteModal,
|
||||||
document.body.classList.remove('modal-open');
|
onClose: () => {
|
||||||
}
|
this.getModal('deleteModal').element.classList.remove('show');
|
||||||
});
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add downloadModal registration
|
// Add downloadModal registration
|
||||||
this.registerModal('downloadModal', {
|
const downloadModal = document.getElementById('downloadModal');
|
||||||
element: document.getElementById('downloadModal'),
|
if (downloadModal) {
|
||||||
onClose: () => {
|
this.registerModal('downloadModal', {
|
||||||
this.getModal('downloadModal').element.style.display = 'none';
|
element: downloadModal,
|
||||||
document.body.classList.remove('modal-open');
|
onClose: () => {
|
||||||
}
|
this.getModal('downloadModal').element.style.display = 'none';
|
||||||
});
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add settingsModal registration
|
// Add settingsModal registration
|
||||||
this.registerModal('settingsModal', {
|
const settingsModal = document.getElementById('settingsModal');
|
||||||
element: document.getElementById('settingsModal'),
|
if (settingsModal) {
|
||||||
onClose: () => {
|
this.registerModal('settingsModal', {
|
||||||
this.getModal('settingsModal').element.style.display = 'none';
|
element: settingsModal,
|
||||||
document.body.classList.remove('modal-open');
|
onClose: () => {
|
||||||
}
|
this.getModal('settingsModal').element.style.display = 'none';
|
||||||
});
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add moveModal registration
|
// Add moveModal registration
|
||||||
this.registerModal('moveModal', {
|
const moveModal = document.getElementById('moveModal');
|
||||||
element: document.getElementById('moveModal'),
|
if (moveModal) {
|
||||||
onClose: () => {
|
this.registerModal('moveModal', {
|
||||||
this.getModal('moveModal').element.style.display = 'none';
|
element: moveModal,
|
||||||
document.body.classList.remove('modal-open');
|
onClose: () => {
|
||||||
}
|
this.getModal('moveModal').element.style.display = 'none';
|
||||||
});
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add supportModal registration
|
// Add supportModal registration
|
||||||
this.registerModal('supportModal', {
|
const supportModal = document.getElementById('supportModal');
|
||||||
element: document.getElementById('supportModal'),
|
if (supportModal) {
|
||||||
onClose: () => {
|
this.registerModal('supportModal', {
|
||||||
this.getModal('supportModal').element.style.display = 'none';
|
element: supportModal,
|
||||||
document.body.classList.remove('modal-open');
|
onClose: () => {
|
||||||
}
|
this.getModal('supportModal').element.style.display = 'none';
|
||||||
});
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add updateModal registration
|
// Add updateModal registration
|
||||||
this.registerModal('updateModal', {
|
const updateModal = document.getElementById('updateModal');
|
||||||
element: document.getElementById('updateModal'),
|
if (updateModal) {
|
||||||
onClose: () => {
|
this.registerModal('updateModal', {
|
||||||
this.getModal('updateModal').element.style.display = 'none';
|
element: updateModal,
|
||||||
document.body.classList.remove('modal-open');
|
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
|
// Set up event listeners for modal toggles
|
||||||
const supportToggle = document.getElementById('supportToggleBtn');
|
const supportToggle = document.getElementById('supportToggleBtn');
|
||||||
@@ -89,8 +136,8 @@ export class ModalManager {
|
|||||||
isOpen: false
|
isOpen: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only add click outside handler if it's the lora modal
|
// Add click outside handler if specified in config
|
||||||
if (id == 'loraModal') {
|
if (config.closeOnOutsideClick) {
|
||||||
config.element.addEventListener('click', (e) => {
|
config.element.addEventListener('click', (e) => {
|
||||||
if (e.target === config.element) {
|
if (e.target === config.element) {
|
||||||
this.closeModal(id);
|
this.closeModal(id);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/loraApi.js';
|
import { resetAndReload } from '../api/loraApi.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
|
||||||
|
|||||||
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,7 +1,8 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state, saveSettings } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/loraApi.js';
|
import { resetAndReload } from '../api/loraApi.js';
|
||||||
|
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -9,9 +10,24 @@ export class SettingsManager {
|
|||||||
this.isOpen = false;
|
this.isOpen = false;
|
||||||
|
|
||||||
// Add initialization to sync with modal state
|
// Add initialization to sync with modal state
|
||||||
|
this.currentPage = document.body.dataset.page || 'loras';
|
||||||
|
|
||||||
|
// Ensure settings are loaded from localStorage
|
||||||
|
this.loadSettingsFromStorage();
|
||||||
|
|
||||||
this.initialize();
|
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() {
|
initialize() {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
@@ -41,13 +57,13 @@ export class SettingsManager {
|
|||||||
// Set frontend settings from state
|
// Set frontend settings from state
|
||||||
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
||||||
if (blurMatureContentCheckbox) {
|
if (blurMatureContentCheckbox) {
|
||||||
blurMatureContentCheckbox.checked = state.settings.blurMatureContent;
|
blurMatureContentCheckbox.checked = state.global.settings.blurMatureContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
|
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
|
||||||
if (showOnlySFWCheckbox) {
|
if (showOnlySFWCheckbox) {
|
||||||
// Sync with state (backend will set this via template)
|
// Sync with state (backend will set this via template)
|
||||||
state.settings.show_only_sfw = showOnlySFWCheckbox.checked;
|
state.global.settings.show_only_sfw = showOnlySFWCheckbox.checked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend settings are loaded from the template directly
|
// Backend settings are loaded from the template directly
|
||||||
@@ -71,9 +87,11 @@ export class SettingsManager {
|
|||||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||||
|
|
||||||
// Update frontend state and save to localStorage
|
// Update frontend state and save to localStorage
|
||||||
state.settings.blurMatureContent = blurMatureContent;
|
state.global.settings.blurMatureContent = blurMatureContent;
|
||||||
state.settings.show_only_sfw = showOnlySFW;
|
state.global.settings.show_only_sfw = showOnlySFW;
|
||||||
saveSettings();
|
|
||||||
|
// Save settings to localStorage
|
||||||
|
setStorageItem('settings', state.global.settings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save backend settings via API
|
// Save backend settings via API
|
||||||
@@ -98,8 +116,16 @@ export class SettingsManager {
|
|||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
this.applyFrontendSettings();
|
this.applyFrontendSettings();
|
||||||
|
|
||||||
// Reload the loras without updating folders
|
if (this.currentPage === 'loras') {
|
||||||
await resetAndReload(false);
|
// 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) {
|
} catch (error) {
|
||||||
showToast('Failed to save settings: ' + error.message, 'error');
|
showToast('Failed to save settings: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -107,7 +133,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
applyFrontendSettings() {
|
applyFrontendSettings() {
|
||||||
// Apply blur setting to existing content
|
// Apply blur setting to existing content
|
||||||
const blurSetting = state.settings.blurMatureContent;
|
const blurSetting = state.global.settings.blurMatureContent;
|
||||||
document.querySelectorAll('.lora-card[data-nsfw="true"] .card-image').forEach(img => {
|
document.querySelectorAll('.lora-card[data-nsfw="true"] .card-image').forEach(img => {
|
||||||
if (blurSetting) {
|
if (blurSetting) {
|
||||||
img.classList.add('nsfw-blur');
|
img.classList.add('nsfw-blur');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class UpdateService {
|
export class UpdateService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -7,22 +8,18 @@ export class UpdateService {
|
|||||||
this.latestVersion = "v0.0.0"; // Initialize with default values
|
this.latestVersion = "v0.0.0"; // Initialize with default values
|
||||||
this.updateInfo = null;
|
this.updateInfo = null;
|
||||||
this.updateAvailable = false;
|
this.updateAvailable = false;
|
||||||
this.updateNotificationsEnabled = localStorage.getItem('show_update_notifications') !== 'false';
|
this.updateNotificationsEnabled = getStorageItem('show_update_notifications');
|
||||||
this.lastCheckTime = parseInt(localStorage.getItem('last_update_check') || '0');
|
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Initialize update preferences from localStorage
|
|
||||||
const showUpdates = localStorage.getItem('show_update_notifications');
|
|
||||||
this.updateNotificationsEnabled = showUpdates === null || showUpdates === 'true';
|
|
||||||
|
|
||||||
// Register event listener for update notification toggle
|
// Register event listener for update notification toggle
|
||||||
const updateCheckbox = document.getElementById('updateNotifications');
|
const updateCheckbox = document.getElementById('updateNotifications');
|
||||||
if (updateCheckbox) {
|
if (updateCheckbox) {
|
||||||
updateCheckbox.checked = this.updateNotificationsEnabled;
|
updateCheckbox.checked = this.updateNotificationsEnabled;
|
||||||
updateCheckbox.addEventListener('change', (e) => {
|
updateCheckbox.addEventListener('change', (e) => {
|
||||||
this.updateNotificationsEnabled = e.target.checked;
|
this.updateNotificationsEnabled = e.target.checked;
|
||||||
localStorage.setItem('show_update_notifications', e.target.checked);
|
setStorageItem('show_update_notifications', e.target.checked);
|
||||||
this.updateBadgeVisibility();
|
this.updateBadgeVisibility();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,10 +31,10 @@ export class UpdateService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up event listener for update button
|
// Set up event listener for update button
|
||||||
const updateToggle = document.getElementById('updateToggleBtn');
|
// const updateToggle = document.getElementById('updateToggleBtn');
|
||||||
if (updateToggle) {
|
// if (updateToggle) {
|
||||||
updateToggle.addEventListener('click', () => this.toggleUpdateModal());
|
// updateToggle.addEventListener('click', () => this.toggleUpdateModal());
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Immediately update modal content with current values (even if from default)
|
// Immediately update modal content with current values (even if from default)
|
||||||
this.updateModalContent();
|
this.updateModalContent();
|
||||||
@@ -71,7 +68,7 @@ export class UpdateService {
|
|||||||
|
|
||||||
// Update last check time
|
// Update last check time
|
||||||
this.lastCheckTime = now;
|
this.lastCheckTime = now;
|
||||||
localStorage.setItem('last_update_check', now.toString());
|
setStorageItem('last_update_check', now.toString());
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
this.updateBadgeVisibility();
|
this.updateBadgeVisibility();
|
||||||
@@ -226,10 +223,6 @@ export class UpdateService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showUpdateModal() {
|
|
||||||
this.toggleUpdateModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
async manualCheckForUpdates() {
|
async manualCheckForUpdates() {
|
||||||
this.lastCheckTime = 0; // Reset last check time to force check
|
this.lastCheckTime = 0; // Reset last check time to force check
|
||||||
await this.checkForUpdates();
|
await this.checkForUpdates();
|
||||||
|
|||||||
185
static/js/recipes.js
Normal file
185
static/js/recipes.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -1,53 +1,154 @@
|
|||||||
|
// Create the new hierarchical state structure
|
||||||
|
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
// Load settings from localStorage or use defaults
|
||||||
|
const savedSettings = getStorageItem('settings', {
|
||||||
|
blurMatureContent: true,
|
||||||
|
show_only_sfw: false
|
||||||
|
});
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
currentPage: 1,
|
// Global state
|
||||||
isLoading: false,
|
global: {
|
||||||
hasMore: true,
|
settings: savedSettings,
|
||||||
sortBy: 'name',
|
loadingManager: null,
|
||||||
activeFolder: null,
|
observer: null,
|
||||||
loadingManager: null,
|
|
||||||
observer: null,
|
|
||||||
previewVersions: new Map(),
|
|
||||||
searchManager: null,
|
|
||||||
searchOptions: {
|
|
||||||
filename: true,
|
|
||||||
modelname: true,
|
|
||||||
tags: false,
|
|
||||||
recursive: false
|
|
||||||
},
|
},
|
||||||
filters: {
|
|
||||||
baseModel: [],
|
// Page-specific states
|
||||||
tags: []
|
pages: {
|
||||||
|
loras: {
|
||||||
|
currentPage: 1,
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
sortBy: 'name',
|
||||||
|
activeFolder: null,
|
||||||
|
previewVersions: new Map(),
|
||||||
|
searchManager: null,
|
||||||
|
searchOptions: {
|
||||||
|
filename: true,
|
||||||
|
modelname: true,
|
||||||
|
tags: false,
|
||||||
|
recursive: false
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
baseModel: [],
|
||||||
|
tags: []
|
||||||
|
},
|
||||||
|
bulkMode: false,
|
||||||
|
selectedLoras: new Set(),
|
||||||
|
loraMetadataCache: new Map(),
|
||||||
|
},
|
||||||
|
|
||||||
|
recipes: {
|
||||||
|
currentPage: 1,
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
sortBy: 'date',
|
||||||
|
searchManager: null,
|
||||||
|
searchOptions: {
|
||||||
|
title: true,
|
||||||
|
tags: true,
|
||||||
|
loraName: true,
|
||||||
|
loraModel: true
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
baseModel: [],
|
||||||
|
tags: [],
|
||||||
|
search: ''
|
||||||
|
},
|
||||||
|
pageSize: 20
|
||||||
|
},
|
||||||
|
|
||||||
|
checkpoints: {
|
||||||
|
currentPage: 1,
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
sortBy: 'name',
|
||||||
|
activeFolder: null,
|
||||||
|
searchManager: null,
|
||||||
|
searchOptions: {
|
||||||
|
filename: true,
|
||||||
|
modelname: true,
|
||||||
|
recursive: false
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
baseModel: [],
|
||||||
|
tags: []
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
bulkMode: false,
|
|
||||||
selectedLoras: new Set(),
|
// Current active page
|
||||||
loraMetadataCache: new Map(),
|
currentPageType: 'loras',
|
||||||
settings: {
|
|
||||||
blurMatureContent: true,
|
// Backward compatibility - proxy properties
|
||||||
show_only_sfw: false
|
get currentPage() { return this.pages[this.currentPageType].currentPage; },
|
||||||
}
|
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
|
||||||
|
|
||||||
|
get isLoading() { return this.pages[this.currentPageType].isLoading; },
|
||||||
|
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
|
||||||
|
|
||||||
|
get hasMore() { return this.pages[this.currentPageType].hasMore; },
|
||||||
|
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
|
||||||
|
|
||||||
|
get sortBy() { return this.pages[this.currentPageType].sortBy; },
|
||||||
|
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
|
||||||
|
|
||||||
|
get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
|
||||||
|
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
|
||||||
|
|
||||||
|
get loadingManager() { return this.global.loadingManager; },
|
||||||
|
set loadingManager(value) { this.global.loadingManager = value; },
|
||||||
|
|
||||||
|
get observer() { return this.global.observer; },
|
||||||
|
set observer(value) { this.global.observer = value; },
|
||||||
|
|
||||||
|
get previewVersions() { return this.pages.loras.previewVersions; },
|
||||||
|
set previewVersions(value) { this.pages.loras.previewVersions = value; },
|
||||||
|
|
||||||
|
get searchManager() { return this.pages[this.currentPageType].searchManager; },
|
||||||
|
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
|
||||||
|
|
||||||
|
get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
|
||||||
|
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
|
||||||
|
|
||||||
|
get filters() { return this.pages[this.currentPageType].filters; },
|
||||||
|
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
||||||
|
|
||||||
|
get bulkMode() { return this.pages.loras.bulkMode; },
|
||||||
|
set bulkMode(value) { this.pages.loras.bulkMode = value; },
|
||||||
|
|
||||||
|
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
||||||
|
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
||||||
|
|
||||||
|
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
||||||
|
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
||||||
|
|
||||||
|
get settings() { return this.global.settings; },
|
||||||
|
set settings(value) { this.global.settings = value; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize settings from localStorage if available
|
// Get the current page state
|
||||||
export function initSettings() {
|
export function getCurrentPageState() {
|
||||||
try {
|
return state.pages[state.currentPageType];
|
||||||
const savedSettings = localStorage.getItem('loraManagerSettings');
|
|
||||||
if (savedSettings) {
|
|
||||||
const parsedSettings = JSON.parse(savedSettings);
|
|
||||||
state.settings = { ...state.settings, ...parsedSettings };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading settings from localStorage:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save settings to localStorage
|
// Set the current page type
|
||||||
export function saveSettings() {
|
export function setCurrentPageType(pageType) {
|
||||||
try {
|
if (state.pages[pageType]) {
|
||||||
localStorage.setItem('loraManagerSettings', JSON.stringify(state.settings));
|
state.currentPageType = pageType;
|
||||||
} catch (error) {
|
return true;
|
||||||
console.error('Error saving settings to localStorage:', error);
|
|
||||||
}
|
}
|
||||||
|
console.warn(`Unknown page type: ${pageType}`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize settings on load
|
// Initialize page state when a page loads
|
||||||
initSettings();
|
export function initPageState(pageType) {
|
||||||
|
if (setCurrentPageType(pageType)) {
|
||||||
|
console.log(`Initialized state for page: ${pageType}`);
|
||||||
|
return getCurrentPageState();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export const BASE_MODELS = {
|
|||||||
LUMINA: "Lumina",
|
LUMINA: "Lumina",
|
||||||
KOLORS: "Kolors",
|
KOLORS: "Kolors",
|
||||||
NOOBAI: "NoobAI",
|
NOOBAI: "NoobAI",
|
||||||
IL: "IL",
|
ILLUSTRIOUS: "Illustrious",
|
||||||
PONY: "Pony",
|
PONY: "Pony",
|
||||||
|
|
||||||
// Video models
|
// Video models
|
||||||
@@ -82,7 +82,7 @@ export const BASE_MODEL_CLASSES = {
|
|||||||
[BASE_MODELS.LUMINA]: "lumina",
|
[BASE_MODELS.LUMINA]: "lumina",
|
||||||
[BASE_MODELS.KOLORS]: "kolors",
|
[BASE_MODELS.KOLORS]: "kolors",
|
||||||
[BASE_MODELS.NOOBAI]: "noobai",
|
[BASE_MODELS.NOOBAI]: "noobai",
|
||||||
[BASE_MODELS.IL]: "il",
|
[BASE_MODELS.ILLUSTRIOUS]: "il",
|
||||||
[BASE_MODELS.PONY]: "pony",
|
[BASE_MODELS.PONY]: "pony",
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
|
|||||||
@@ -1,29 +1,88 @@
|
|||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { loadMoreLoras } from '../api/loraApi.js';
|
import { loadMoreLoras } from '../api/loraApi.js';
|
||||||
|
import { debounce } from './debounce.js';
|
||||||
|
|
||||||
export function initializeInfiniteScroll() {
|
export function initializeInfiniteScroll(pageType = 'loras') {
|
||||||
if (state.observer) {
|
if (state.observer) {
|
||||||
state.observer.disconnect();
|
state.observer.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the current page type
|
||||||
|
state.currentPageType = pageType;
|
||||||
|
|
||||||
|
// Get the current page state
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
// Determine the load more function and grid ID based on page type
|
||||||
|
let loadMoreFunction;
|
||||||
|
let gridId;
|
||||||
|
|
||||||
|
switch (pageType) {
|
||||||
|
case 'recipes':
|
||||||
|
loadMoreFunction = () => {
|
||||||
|
if (!pageState.isLoading && pageState.hasMore) {
|
||||||
|
pageState.currentPage++;
|
||||||
|
window.recipeManager.loadRecipes(false); // false to not reset pagination
|
||||||
|
}
|
||||||
|
};
|
||||||
|
gridId = 'recipeGrid';
|
||||||
|
break;
|
||||||
|
case 'checkpoints':
|
||||||
|
loadMoreFunction = () => {
|
||||||
|
if (!pageState.isLoading && pageState.hasMore) {
|
||||||
|
pageState.currentPage++;
|
||||||
|
window.checkpointManager.loadCheckpoints(false); // false to not reset pagination
|
||||||
|
}
|
||||||
|
};
|
||||||
|
gridId = 'checkpointGrid';
|
||||||
|
break;
|
||||||
|
case 'loras':
|
||||||
|
default:
|
||||||
|
loadMoreFunction = () => loadMoreLoras(false); // false to not reset
|
||||||
|
gridId = 'loraGrid';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedLoadMore = debounce(loadMoreFunction, 100);
|
||||||
|
|
||||||
state.observer = new IntersectionObserver(
|
state.observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const target = entries[0];
|
const target = entries[0];
|
||||||
if (target.isIntersecting && !state.isLoading && state.hasMore) {
|
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
|
||||||
loadMoreLoras();
|
debouncedLoadMore();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 0.1 }
|
{ threshold: 0.1 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const grid = document.getElementById(gridId);
|
||||||
|
if (!grid) {
|
||||||
|
console.warn(`Grid with ID "${gridId}" not found for infinite scroll`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existingSentinel = document.getElementById('scroll-sentinel');
|
const existingSentinel = document.getElementById('scroll-sentinel');
|
||||||
if (existingSentinel) {
|
if (existingSentinel) {
|
||||||
state.observer.observe(existingSentinel);
|
state.observer.observe(existingSentinel);
|
||||||
} else {
|
} else {
|
||||||
|
// Create a wrapper div that will be placed after the grid
|
||||||
|
const sentinelWrapper = document.createElement('div');
|
||||||
|
sentinelWrapper.style.width = '100%';
|
||||||
|
sentinelWrapper.style.height = '10px';
|
||||||
|
sentinelWrapper.style.margin = '0';
|
||||||
|
sentinelWrapper.style.padding = '0';
|
||||||
|
|
||||||
|
// Create the actual sentinel element
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
sentinel.id = 'scroll-sentinel';
|
sentinel.id = 'scroll-sentinel';
|
||||||
sentinel.style.height = '10px';
|
sentinel.style.height = '10px';
|
||||||
document.getElementById('loraGrid').appendChild(sentinel);
|
|
||||||
|
// Add the sentinel to the wrapper
|
||||||
|
sentinelWrapper.appendChild(sentinel);
|
||||||
|
|
||||||
|
// Insert the wrapper after the grid instead of inside it
|
||||||
|
grid.parentNode.insertBefore(sentinelWrapper, grid.nextSibling);
|
||||||
|
|
||||||
state.observer.observe(sentinel);
|
state.observer.observe(sentinel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
53
static/js/utils/routes.js
Normal file
53
static/js/utils/routes.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// API routes configuration
|
||||||
|
export const apiRoutes = {
|
||||||
|
// LoRA routes
|
||||||
|
loras: {
|
||||||
|
list: '/api/loras',
|
||||||
|
detail: (id) => `/api/loras/${id}`,
|
||||||
|
delete: (id) => `/api/loras/${id}`,
|
||||||
|
update: (id) => `/api/loras/${id}`,
|
||||||
|
civitai: (id) => `/api/loras/${id}/civitai`,
|
||||||
|
download: '/api/download-lora',
|
||||||
|
move: '/api/move-lora',
|
||||||
|
scan: '/api/scan-loras'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Recipe routes
|
||||||
|
recipes: {
|
||||||
|
list: '/api/recipes',
|
||||||
|
detail: (id) => `/api/recipes/${id}`,
|
||||||
|
delete: (id) => `/api/recipes/${id}`,
|
||||||
|
update: (id) => `/api/recipes/${id}`,
|
||||||
|
analyze: '/api/analyze-recipe-image',
|
||||||
|
save: '/api/save-recipe'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Checkpoint routes
|
||||||
|
checkpoints: {
|
||||||
|
list: '/api/checkpoints',
|
||||||
|
detail: (id) => `/api/checkpoints/${id}`,
|
||||||
|
delete: (id) => `/api/checkpoints/${id}`,
|
||||||
|
update: (id) => `/api/checkpoints/${id}`
|
||||||
|
},
|
||||||
|
|
||||||
|
// WebSocket routes
|
||||||
|
ws: {
|
||||||
|
fetchProgress: (protocol) => `${protocol}://${window.location.host}/ws/fetch-progress`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page routes
|
||||||
|
export const pageRoutes = {
|
||||||
|
loras: '/loras',
|
||||||
|
recipes: '/loras/recipes',
|
||||||
|
checkpoints: '/checkpoints'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get current page type
|
||||||
|
export function getCurrentPageType() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path.includes('/loras/recipes')) return 'recipes';
|
||||||
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
|
if (path.includes('/loras')) return 'loras';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { appendLoraCards } from '../api/loraApi.js';
|
|
||||||
import { state } from '../state/index.js';
|
|
||||||
import { resetAndReload } from '../api/loraApi.js';
|
|
||||||
import { showToast } from './uiHelpers.js';
|
|
||||||
|
|
||||||
export class SearchManager {
|
|
||||||
constructor() {
|
|
||||||
// Initialize search manager
|
|
||||||
this.searchInput = document.getElementById('searchInput');
|
|
||||||
this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
|
|
||||||
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
|
|
||||||
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
|
|
||||||
this.searchDebounceTimeout = null;
|
|
||||||
this.currentSearchTerm = '';
|
|
||||||
this.isSearching = false;
|
|
||||||
|
|
||||||
// Add clear button
|
|
||||||
this.createClearButton();
|
|
||||||
|
|
||||||
// Add this instance to state
|
|
||||||
state.searchManager = this;
|
|
||||||
|
|
||||||
if (this.searchInput) {
|
|
||||||
this.searchInput.addEventListener('input', this.handleSearch.bind(this));
|
|
||||||
// Update clear button visibility on input
|
|
||||||
this.searchInput.addEventListener('input', () => {
|
|
||||||
this.updateClearButtonVisibility();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize search options
|
|
||||||
this.initSearchOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
initSearchOptions() {
|
|
||||||
// Load recursive search state from localStorage
|
|
||||||
state.searchOptions.recursive = localStorage.getItem('recursiveSearch') === 'true';
|
|
||||||
|
|
||||||
if (this.recursiveSearchToggle) {
|
|
||||||
this.recursiveSearchToggle.checked = state.searchOptions.recursive;
|
|
||||||
this.recursiveSearchToggle.addEventListener('change', (e) => {
|
|
||||||
state.searchOptions.recursive = e.target.checked;
|
|
||||||
localStorage.setItem('recursiveSearch', state.searchOptions.recursive);
|
|
||||||
|
|
||||||
// Rerun search if there's an active search term
|
|
||||||
if (this.currentSearchTerm) {
|
|
||||||
this.performSearch(this.currentSearchTerm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup search options toggle
|
|
||||||
if (this.searchOptionsToggle) {
|
|
||||||
this.searchOptionsToggle.addEventListener('click', () => {
|
|
||||||
this.toggleSearchOptionsPanel();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close button for search options panel
|
|
||||||
const closeButton = document.getElementById('closeSearchOptions');
|
|
||||||
if (closeButton) {
|
|
||||||
closeButton.addEventListener('click', () => {
|
|
||||||
this.closeSearchOptionsPanel();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup search option tags
|
|
||||||
const optionTags = document.querySelectorAll('.search-option-tag');
|
|
||||||
optionTags.forEach(tag => {
|
|
||||||
const option = tag.dataset.option;
|
|
||||||
|
|
||||||
// Initialize tag state from state
|
|
||||||
tag.classList.toggle('active', state.searchOptions[option]);
|
|
||||||
|
|
||||||
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 and show toast
|
|
||||||
showToast('At least one search option must be selected', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tag.classList.toggle('active');
|
|
||||||
state.searchOptions[option] = tag.classList.contains('active');
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem(`searchOption_${option}`, state.searchOptions[option]);
|
|
||||||
|
|
||||||
// Rerun search if there's an active search term
|
|
||||||
if (this.currentSearchTerm) {
|
|
||||||
this.performSearch(this.currentSearchTerm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load option state from localStorage or use default
|
|
||||||
const savedState = localStorage.getItem(`searchOption_${option}`);
|
|
||||||
if (savedState !== null) {
|
|
||||||
state.searchOptions[option] = savedState === 'true';
|
|
||||||
tag.classList.toggle('active', state.searchOptions[option]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure at least one search option is selected
|
|
||||||
this.validateSearchOptions();
|
|
||||||
|
|
||||||
// Close panel when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (this.searchOptionsPanel &&
|
|
||||||
!this.searchOptionsPanel.contains(e.target) &&
|
|
||||||
e.target !== this.searchOptionsToggle &&
|
|
||||||
!this.searchOptionsToggle.contains(e.target)) {
|
|
||||||
this.closeSearchOptionsPanel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add method to validate search options
|
|
||||||
validateSearchOptions() {
|
|
||||||
const hasActiveOption = Object.values(state.searchOptions)
|
|
||||||
.some(value => value === true && value !== state.searchOptions.recursive);
|
|
||||||
|
|
||||||
// If no search options are active, activate at least one default option
|
|
||||||
if (!hasActiveOption) {
|
|
||||||
state.searchOptions.filename = true;
|
|
||||||
localStorage.setItem('searchOption_filename', 'true');
|
|
||||||
|
|
||||||
// Update UI to match
|
|
||||||
const fileNameTag = document.querySelector('.search-option-tag[data-option="filename"]');
|
|
||||||
if (fileNameTag) {
|
|
||||||
fileNameTag.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSearchOptionsPanel() {
|
|
||||||
if (this.searchOptionsPanel) {
|
|
||||||
const isHidden = this.searchOptionsPanel.classList.contains('hidden');
|
|
||||||
if (isHidden) {
|
|
||||||
this.searchOptionsPanel.classList.remove('hidden');
|
|
||||||
this.searchOptionsToggle.classList.add('active');
|
|
||||||
} else {
|
|
||||||
this.closeSearchOptionsPanel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSearchOptionsPanel() {
|
|
||||||
if (this.searchOptionsPanel) {
|
|
||||||
this.searchOptionsPanel.classList.add('hidden');
|
|
||||||
this.searchOptionsToggle.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createClearButton() {
|
|
||||||
// Create clear button
|
|
||||||
const 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.currentSearchTerm = '';
|
|
||||||
this.updateClearButtonVisibility();
|
|
||||||
resetAndReload();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSearch(event) {
|
|
||||||
if (this.searchDebounceTimeout) {
|
|
||||||
clearTimeout(this.searchDebounceTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.searchDebounceTimeout = setTimeout(async () => {
|
|
||||||
const searchTerm = event.target.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (searchTerm !== this.currentSearchTerm && !this.isSearching) {
|
|
||||||
this.currentSearchTerm = searchTerm;
|
|
||||||
await this.performSearch(searchTerm);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
async performSearch(searchTerm) {
|
|
||||||
const grid = document.getElementById('loraGrid');
|
|
||||||
|
|
||||||
if (!searchTerm) {
|
|
||||||
state.currentPage = 1;
|
|
||||||
await resetAndReload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.isSearching = true;
|
|
||||||
state.loadingManager.showSimpleLoading('Searching...');
|
|
||||||
|
|
||||||
state.currentPage = 1;
|
|
||||||
state.hasMore = true;
|
|
||||||
|
|
||||||
const url = new URL('/api/loras', window.location.origin);
|
|
||||||
url.searchParams.set('page', '1');
|
|
||||||
url.searchParams.set('page_size', '20');
|
|
||||||
url.searchParams.set('sort_by', state.sortBy);
|
|
||||||
url.searchParams.set('search', searchTerm);
|
|
||||||
url.searchParams.set('fuzzy', 'true');
|
|
||||||
|
|
||||||
// Add search options
|
|
||||||
url.searchParams.set('search_filename', state.searchOptions.filename.toString());
|
|
||||||
url.searchParams.set('search_modelname', state.searchOptions.modelname.toString());
|
|
||||||
url.searchParams.set('search_tags', state.searchOptions.tags.toString());
|
|
||||||
|
|
||||||
// Always send folder parameter if there is an active folder
|
|
||||||
if (state.activeFolder) {
|
|
||||||
url.searchParams.set('folder', state.activeFolder);
|
|
||||||
// Add recursive parameter when recursive search is enabled
|
|
||||||
url.searchParams.set('recursive', state.searchOptions.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.innerHTML = '';
|
|
||||||
|
|
||||||
if (data.items.length === 0) {
|
|
||||||
grid.innerHTML = '<div class="no-results">No matching loras found</div>';
|
|
||||||
state.hasMore = false;
|
|
||||||
} else {
|
|
||||||
appendLoraCards(data.items);
|
|
||||||
state.hasMore = state.currentPage < data.total_pages;
|
|
||||||
state.currentPage++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
showToast('Search failed', 'error');
|
|
||||||
} finally {
|
|
||||||
this.isSearching = false;
|
|
||||||
state.loadingManager.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
127
static/js/utils/storageHelpers.js
Normal file
127
static/js/utils/storageHelpers.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for localStorage with namespacing to avoid conflicts
|
||||||
|
* with other ComfyUI extensions or the main application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Namespace prefix for all localStorage keys
|
||||||
|
const STORAGE_PREFIX = 'lora_manager_';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an item from localStorage with namespace support and fallback to legacy keys
|
||||||
|
* @param {string} key - The key without prefix
|
||||||
|
* @param {any} defaultValue - Default value if key doesn't exist
|
||||||
|
* @returns {any} The stored value or defaultValue
|
||||||
|
*/
|
||||||
|
export function getStorageItem(key, defaultValue = null) {
|
||||||
|
// Try with prefix first
|
||||||
|
const prefixedValue = localStorage.getItem(STORAGE_PREFIX + key);
|
||||||
|
|
||||||
|
if (prefixedValue !== null) {
|
||||||
|
// If it's a JSON string, parse it
|
||||||
|
try {
|
||||||
|
return JSON.parse(prefixedValue);
|
||||||
|
} catch (e) {
|
||||||
|
return prefixedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy key (without prefix)
|
||||||
|
const legacyValue = localStorage.getItem(key);
|
||||||
|
|
||||||
|
if (legacyValue !== null) {
|
||||||
|
// If found in legacy storage, migrate it to prefixed storage
|
||||||
|
try {
|
||||||
|
const parsedValue = JSON.parse(legacyValue);
|
||||||
|
setStorageItem(key, parsedValue);
|
||||||
|
return parsedValue;
|
||||||
|
} catch (e) {
|
||||||
|
setStorageItem(key, legacyValue);
|
||||||
|
return legacyValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default value if neither prefixed nor legacy key exists
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an item in localStorage with namespace prefix
|
||||||
|
* @param {string} key - The key without prefix
|
||||||
|
* @param {any} value - The value to store
|
||||||
|
*/
|
||||||
|
export function setStorageItem(key, value) {
|
||||||
|
const prefixedKey = STORAGE_PREFIX + key;
|
||||||
|
|
||||||
|
// Convert objects and arrays to JSON strings
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(prefixedKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item from localStorage (both prefixed and legacy)
|
||||||
|
* @param {string} key - The key without prefix
|
||||||
|
*/
|
||||||
|
export function removeStorageItem(key) {
|
||||||
|
localStorage.removeItem(STORAGE_PREFIX + key);
|
||||||
|
localStorage.removeItem(key); // Also remove legacy key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate all existing localStorage items to use the prefix
|
||||||
|
* This should be called once during application initialization
|
||||||
|
*/
|
||||||
|
export function migrateStorageItems() {
|
||||||
|
// Check if migration has already been performed
|
||||||
|
if (localStorage.getItem(STORAGE_PREFIX + 'migration_completed')) {
|
||||||
|
console.log('Lora Manager: Storage migration already completed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of known keys used in the application
|
||||||
|
const knownKeys = [
|
||||||
|
'nsfwBlurLevel',
|
||||||
|
'theme',
|
||||||
|
'activeFolder',
|
||||||
|
'folderTagsCollapsed',
|
||||||
|
'settings',
|
||||||
|
'loras_filters',
|
||||||
|
'recipes_filters',
|
||||||
|
'checkpoints_filters',
|
||||||
|
'loras_search_prefs',
|
||||||
|
'recipes_search_prefs',
|
||||||
|
'checkpoints_search_prefs',
|
||||||
|
'show_update_notifications',
|
||||||
|
'last_update_check'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Migrate each known key
|
||||||
|
knownKeys.forEach(key => {
|
||||||
|
const prefixedKey = STORAGE_PREFIX + key;
|
||||||
|
|
||||||
|
// Only migrate if the prefixed key doesn't already exist
|
||||||
|
if (localStorage.getItem(prefixedKey) === null) {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
if (value !== null) {
|
||||||
|
try {
|
||||||
|
// Try to parse as JSON first
|
||||||
|
const parsedValue = JSON.parse(value);
|
||||||
|
setStorageItem(key, parsedValue);
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, store as is
|
||||||
|
setStorageItem(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can optionally remove the old key after migration
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark migration as completed
|
||||||
|
localStorage.setItem(STORAGE_PREFIX + 'migration_completed', 'true');
|
||||||
|
|
||||||
|
console.log('Lora Manager: Storage migration completed');
|
||||||
|
}
|
||||||
@@ -1,15 +1,59 @@
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/loraApi.js';
|
import { resetAndReload } from '../api/loraApi.js';
|
||||||
|
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||||
|
|
||||||
export function showToast(message, type = 'info') {
|
export function showToast(message, type = 'info') {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
document.body.append(toast);
|
|
||||||
|
// Get or create toast container
|
||||||
|
let toastContainer = document.querySelector('.toast-container');
|
||||||
|
if (!toastContainer) {
|
||||||
|
toastContainer = document.createElement('div');
|
||||||
|
toastContainer.className = 'toast-container';
|
||||||
|
document.body.append(toastContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
toastContainer.append(toast);
|
||||||
|
|
||||||
|
// Calculate vertical position for stacked toasts
|
||||||
|
const existingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||||||
|
const toastIndex = existingToasts.indexOf(toast);
|
||||||
|
const topOffset = 20; // Base offset from top
|
||||||
|
const spacing = 10; // Space between toasts
|
||||||
|
|
||||||
|
// Set position based on existing toasts
|
||||||
|
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
toast.classList.add('show');
|
toast.classList.add('show');
|
||||||
setTimeout(() => toast.remove(), 2300);
|
|
||||||
|
// Set timeout based on type
|
||||||
|
let timeout = 2000; // Default (info)
|
||||||
|
if (type === 'warning' || type === 'error') {
|
||||||
|
timeout = 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
toast.addEventListener('transitionend', () => {
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
// Reposition remaining toasts
|
||||||
|
if (toastContainer) {
|
||||||
|
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||||||
|
remainingToasts.forEach((t, index) => {
|
||||||
|
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove container if empty
|
||||||
|
if (remainingToasts.length === 0) {
|
||||||
|
toastContainer.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, timeout);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +71,7 @@ export function lazyLoadImages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function restoreFolderFilter() {
|
export function restoreFolderFilter() {
|
||||||
const activeFolder = localStorage.getItem('activeFolder');
|
const activeFolder = getStorageItem('activeFolder');
|
||||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||||
if (folderTag) {
|
if (folderTag) {
|
||||||
folderTag.classList.add('active');
|
folderTag.classList.add('active');
|
||||||
@@ -36,13 +80,13 @@ export function restoreFolderFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initTheme() {
|
export function initTheme() {
|
||||||
document.body.dataset.theme = localStorage.getItem('theme') || 'dark';
|
document.body.dataset.theme = getStorageItem('theme') || 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleTheme() {
|
export function toggleTheme() {
|
||||||
const theme = document.body.dataset.theme === 'light' ? 'dark' : 'light';
|
const theme = document.body.dataset.theme === 'light' ? 'dark' : 'light';
|
||||||
document.body.dataset.theme = theme;
|
document.body.dataset.theme = theme;
|
||||||
localStorage.setItem('theme', theme);
|
setStorageItem('theme', theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleFolder(tag) {
|
export function toggleFolder(tag) {
|
||||||
@@ -98,26 +142,101 @@ export function openCivitai(modelName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically positions the search options panel and filter panel
|
||||||
|
* based on the current layout and folder tags container height
|
||||||
|
*/
|
||||||
|
export function updatePanelPositions() {
|
||||||
|
const searchOptionsPanel = document.getElementById('searchOptionsPanel');
|
||||||
|
const filterPanel = document.getElementById('filterPanel');
|
||||||
|
|
||||||
|
if (!searchOptionsPanel && !filterPanel) return;
|
||||||
|
|
||||||
|
// Get the header element
|
||||||
|
const header = document.querySelector('.app-header');
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
// Calculate the position based on the bottom of the header
|
||||||
|
const headerRect = header.getBoundingClientRect();
|
||||||
|
const topPosition = headerRect.bottom + 5; // Add 5px padding
|
||||||
|
|
||||||
|
// Set the positions
|
||||||
|
if (searchOptionsPanel) {
|
||||||
|
searchOptionsPanel.style.top = `${topPosition}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterPanel) {
|
||||||
|
filterPanel.style.top = `${topPosition}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust panel horizontal position based on the search container
|
||||||
|
const searchContainer = document.querySelector('.header-search');
|
||||||
|
if (searchContainer) {
|
||||||
|
const searchRect = searchContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Position the search options panel aligned with the search container
|
||||||
|
if (searchOptionsPanel) {
|
||||||
|
searchOptionsPanel.style.right = `${window.innerWidth - searchRect.right}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position the filter panel aligned with the filter button
|
||||||
|
if (filterPanel) {
|
||||||
|
const filterButton = document.getElementById('filterButton');
|
||||||
|
if (filterButton) {
|
||||||
|
const filterRect = filterButton.getBoundingClientRect();
|
||||||
|
filterPanel.style.right = `${window.innerWidth - filterRect.right}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the toggleFolderTags function
|
||||||
export function toggleFolderTags() {
|
export function toggleFolderTags() {
|
||||||
const folderTags = document.querySelector('.folder-tags');
|
const folderTags = document.querySelector('.folder-tags');
|
||||||
const btn = document.querySelector('.toggle-folders-btn');
|
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||||||
const isCollapsed = folderTags.classList.toggle('collapsed');
|
|
||||||
|
|
||||||
// 更新按钮提示文本
|
if (folderTags) {
|
||||||
btn.title = isCollapsed ? 'Expand folder tags' : 'Collapse folder tags';
|
folderTags.classList.toggle('collapsed');
|
||||||
|
|
||||||
// 保存状态到 localStorage
|
if (folderTags.classList.contains('collapsed')) {
|
||||||
localStorage.setItem('folderTagsCollapsed', isCollapsed);
|
// Change icon to indicate folders are hidden
|
||||||
|
toggleBtn.className = 'fas fa-folder-plus';
|
||||||
|
toggleBtn.parentElement.title = 'Show folder tags';
|
||||||
|
setStorageItem('folderTagsCollapsed', 'true');
|
||||||
|
} else {
|
||||||
|
// Change icon to indicate folders are visible
|
||||||
|
toggleBtn.className = 'fas fa-folder-minus';
|
||||||
|
toggleBtn.parentElement.title = 'Hide folder tags';
|
||||||
|
setStorageItem('folderTagsCollapsed', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update panel positions after toggling
|
||||||
|
// Use a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
updatePanelPositions();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add this to your existing initialization code
|
// Add this to your existing initialization code
|
||||||
export function initFolderTagsVisibility() {
|
export function initFolderTagsVisibility() {
|
||||||
const isCollapsed = localStorage.getItem('folderTagsCollapsed') === 'true';
|
const isCollapsed = getStorageItem('folderTagsCollapsed');
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
const folderTags = document.querySelector('.folder-tags');
|
const folderTags = document.querySelector('.folder-tags');
|
||||||
const btn = document.querySelector('.toggle-folders-btn');
|
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||||||
folderTags.classList.add('collapsed');
|
if (folderTags) {
|
||||||
btn.title = 'Expand folder tags';
|
folderTags.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.className = 'fas fa-folder-plus';
|
||||||
|
toggleBtn.parentElement.title = 'Show folder tags';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const toggleBtn = document.querySelector('.toggle-folders-btn i');
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.className = 'fas fa-folder-minus';
|
||||||
|
toggleBtn.parentElement.title = 'Hide folder tags';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +247,13 @@ export function initBackToTop() {
|
|||||||
button.title = 'Back to top';
|
button.title = 'Back to top';
|
||||||
document.body.appendChild(button);
|
document.body.appendChild(button);
|
||||||
|
|
||||||
|
// Get the scrollable container
|
||||||
|
const scrollContainer = document.querySelector('.page-content');
|
||||||
|
|
||||||
// Show/hide button based on scroll position
|
// Show/hide button based on scroll position
|
||||||
const toggleBackToTop = () => {
|
const toggleBackToTop = () => {
|
||||||
const scrollThreshold = window.innerHeight * 0.75;
|
const scrollThreshold = window.innerHeight * 0.3;
|
||||||
if (window.scrollY > scrollThreshold) {
|
if (scrollContainer.scrollTop > scrollThreshold) {
|
||||||
button.classList.add('visible');
|
button.classList.add('visible');
|
||||||
} else {
|
} else {
|
||||||
button.classList.remove('visible');
|
button.classList.remove('visible');
|
||||||
@@ -140,14 +262,14 @@ export function initBackToTop() {
|
|||||||
|
|
||||||
// Smooth scroll to top
|
// Smooth scroll to top
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
window.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for scroll events
|
// Listen for scroll events on the scrollable container
|
||||||
window.addEventListener('scroll', toggleBackToTop);
|
scrollContainer.addEventListener('scroll', toggleBackToTop);
|
||||||
|
|
||||||
// Initial check
|
// Initial check
|
||||||
toggleBackToTop();
|
toggleBackToTop();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user