mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
feat: add folder-based recipe organization and navigation
- Add new API endpoints for folder operations: get_folders, get_folder_tree, and get_unified_folder_tree - Extend recipe listing handler to support folder and recursive filtering parameters - Register new folder-related routes in route definitions - Enable users to organize and browse recipes using folder structures
This commit is contained in:
@@ -56,6 +56,9 @@ class RecipeHandlerSet:
|
|||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
|
"get_folders": self.query.get_folders,
|
||||||
|
"get_folder_tree": self.query.get_folder_tree,
|
||||||
|
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
||||||
"share_recipe": self.sharing.share_recipe,
|
"share_recipe": self.sharing.share_recipe,
|
||||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
@@ -149,6 +152,8 @@ class RecipeListingHandler:
|
|||||||
page_size = int(request.query.get("page_size", "20"))
|
page_size = int(request.query.get("page_size", "20"))
|
||||||
sort_by = request.query.get("sort_by", "date")
|
sort_by = request.query.get("sort_by", "date")
|
||||||
search = request.query.get("search")
|
search = request.query.get("search")
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
"title": request.query.get("search_title", "true").lower() == "true",
|
"title": request.query.get("search_title", "true").lower() == "true",
|
||||||
@@ -193,6 +198,8 @@ class RecipeListingHandler:
|
|||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
lora_hash=lora_hash,
|
lora_hash=lora_hash,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
for item in result.get("items", []):
|
||||||
@@ -299,6 +306,45 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folders(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folders = await recipe_scanner.get_folders()
|
||||||
|
return web.json_response({"success": True, "folders": folders})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from natsort import natsorted
|
|||||||
@dataclass
|
@dataclass
|
||||||
class RecipeCache:
|
class RecipeCache:
|
||||||
"""Cache structure for Recipe data"""
|
"""Cache structure for Recipe data"""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict]
|
||||||
sorted_by_name: List[Dict]
|
sorted_by_name: List[Dict]
|
||||||
sorted_by_date: List[Dict]
|
sorted_by_date: List[Dict]
|
||||||
|
folders: List[str] | None = None
|
||||||
|
folder_tree: Dict | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Normalize optional metadata containers
|
||||||
|
self.folders = self.folders or []
|
||||||
|
self.folder_tree = self.folder_tree or {}
|
||||||
|
|
||||||
async def resort(self, name_only: bool = False):
|
async def resort(self, name_only: bool = False):
|
||||||
"""Resort all cached data views"""
|
"""Resort all cached data views"""
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -117,7 +119,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark as initializing to prevent concurrent initializations
|
# Mark as initializing to prevent concurrent initializations
|
||||||
@@ -218,6 +222,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Update cache with the collected data
|
# Update cache with the collected data
|
||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
# Create a simplified resort function that doesn't use await
|
# Create a simplified resort function that doesn't use await
|
||||||
if hasattr(self._cache, "resort"):
|
if hasattr(self._cache, "resort"):
|
||||||
@@ -336,6 +341,9 @@ class RecipeScanner:
|
|||||||
if not self._cache:
|
if not self._cache:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Keep folder metadata up to date alongside sort order
|
||||||
|
self._update_folder_metadata()
|
||||||
|
|
||||||
async def _resort_wrapper() -> None:
|
async def _resort_wrapper() -> None:
|
||||||
try:
|
try:
|
||||||
await self._cache.resort(name_only=name_only)
|
await self._cache.resort(name_only=name_only)
|
||||||
@@ -346,6 +354,75 @@ class RecipeScanner:
|
|||||||
self._resort_tasks.add(task)
|
self._resort_tasks.add(task)
|
||||||
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
||||||
|
|
||||||
|
def _calculate_folder(self, recipe_path: str) -> str:
|
||||||
|
"""Calculate a normalized folder path relative to ``recipes_dir``."""
|
||||||
|
|
||||||
|
recipes_dir = self.recipes_dir
|
||||||
|
if not recipes_dir:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_dir = os.path.dirname(os.path.normpath(recipe_path))
|
||||||
|
relative_dir = os.path.relpath(recipe_dir, recipes_dir)
|
||||||
|
if relative_dir in (".", ""):
|
||||||
|
return ""
|
||||||
|
return relative_dir.replace(os.path.sep, "/")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _build_folder_tree(self, folders: list[str]) -> dict:
|
||||||
|
"""Build a nested folder tree structure from relative folder paths."""
|
||||||
|
|
||||||
|
tree: dict[str, dict] = {}
|
||||||
|
for folder in folders:
|
||||||
|
if not folder:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = folder.split("/")
|
||||||
|
current_level = tree
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if part not in current_level:
|
||||||
|
current_level[part] = {}
|
||||||
|
current_level = current_level[part]
|
||||||
|
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def _update_folder_metadata(self, cache: RecipeCache | None = None) -> None:
|
||||||
|
"""Ensure folder lists and tree metadata are synchronized with cache contents."""
|
||||||
|
|
||||||
|
cache = cache or self._cache
|
||||||
|
if cache is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
folders: set[str] = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
folder_value = item.get("folder", "")
|
||||||
|
if folder_value is None:
|
||||||
|
folder_value = ""
|
||||||
|
if folder_value == ".":
|
||||||
|
folder_value = ""
|
||||||
|
normalized = str(folder_value).replace("\\", "/")
|
||||||
|
item["folder"] = normalized
|
||||||
|
folders.add(normalized)
|
||||||
|
|
||||||
|
cache.folders = sorted(folders, key=lambda entry: entry.lower())
|
||||||
|
cache.folder_tree = self._build_folder_tree(cache.folders)
|
||||||
|
|
||||||
|
async def get_folders(self) -> list[str]:
|
||||||
|
"""Return a sorted list of recipe folders relative to the recipes root."""
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
|
return cache.folders
|
||||||
|
|
||||||
|
async def get_folder_tree(self) -> dict:
|
||||||
|
"""Return a hierarchical tree of recipe folders for sidebar navigation."""
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
|
return cache.folder_tree
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
@@ -362,11 +439,14 @@ class RecipeScanner:
|
|||||||
"""Get cached recipe data, refresh if needed"""
|
"""Get cached recipe data, refresh if needed"""
|
||||||
# If cache is already initialized and no refresh is needed, return it immediately
|
# If cache is already initialized and no refresh is needed, return it immediately
|
||||||
if self._cache is not None and not force_refresh:
|
if self._cache is not None and not force_refresh:
|
||||||
|
self._update_folder_metadata()
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
# If another initialization is already in progress, wait for it to complete
|
# If another initialization is already in progress, wait for it to complete
|
||||||
if self._is_initializing and not force_refresh:
|
if self._is_initializing and not force_refresh:
|
||||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
return self._cache or RecipeCache(
|
||||||
|
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
|
||||||
|
)
|
||||||
|
|
||||||
# If force refresh is requested, initialize the cache directly
|
# If force refresh is requested, initialize the cache directly
|
||||||
if force_refresh:
|
if force_refresh:
|
||||||
@@ -384,11 +464,14 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resort cache
|
# Resort cache
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
@@ -398,7 +481,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
return self._cache
|
return self._cache
|
||||||
finally:
|
finally:
|
||||||
@@ -409,7 +494,9 @@ class RecipeScanner:
|
|||||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||||
|
|
||||||
# Return the cache (may be empty or partially initialized)
|
# Return the cache (may be empty or partially initialized)
|
||||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
return self._cache or RecipeCache(
|
||||||
|
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
|
||||||
|
)
|
||||||
|
|
||||||
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
||||||
"""Public helper to refresh or return the recipe cache."""
|
"""Public helper to refresh or return the recipe cache."""
|
||||||
@@ -424,6 +511,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
await cache.add_recipe(recipe_data, resort=False)
|
await cache.add_recipe(recipe_data, resort=False)
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||||
@@ -437,6 +525,7 @@ class RecipeScanner:
|
|||||||
if removed is None:
|
if removed is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -522,6 +611,9 @@ class RecipeScanner:
|
|||||||
if path_updated:
|
if path_updated:
|
||||||
self._write_recipe_file(recipe_path, recipe_data)
|
self._write_recipe_file(recipe_path, recipe_data)
|
||||||
|
|
||||||
|
# Track folder placement relative to recipes directory
|
||||||
|
recipe_data['folder'] = recipe_data.get('folder') or self._calculate_folder(recipe_path)
|
||||||
|
|
||||||
# Ensure loras array exists
|
# Ensure loras array exists
|
||||||
if 'loras' not in recipe_data:
|
if 'loras' not in recipe_data:
|
||||||
recipe_data['loras'] = []
|
recipe_data['loras'] = []
|
||||||
@@ -914,7 +1006,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return await self._lora_scanner.get_model_info_by_name(name)
|
return await self._lora_scanner.get_model_info_by_name(name)
|
||||||
|
|
||||||
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, lora_hash: str = None, bypass_filters: bool = True):
|
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, lora_hash: str = None, bypass_filters: bool = True, folder: str | None = None, recursive: bool = True):
|
||||||
"""Get paginated and filtered recipe data
|
"""Get paginated and filtered recipe data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -926,6 +1018,8 @@ class RecipeScanner:
|
|||||||
search_options: Dictionary of search options to apply
|
search_options: Dictionary of search options to apply
|
||||||
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
||||||
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
||||||
|
folder: Optional folder filter relative to recipes directory
|
||||||
|
recursive: Whether to include recipes in subfolders of the selected folder
|
||||||
"""
|
"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -961,6 +1055,22 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
||||||
if not (lora_hash and bypass_filters):
|
if not (lora_hash and bypass_filters):
|
||||||
|
# Apply folder filter before other criteria
|
||||||
|
normalized_folder = (folder or "").strip("/")
|
||||||
|
if normalized_folder:
|
||||||
|
def matches_folder(item_folder: str) -> bool:
|
||||||
|
item_path = (item_folder or "").strip("/")
|
||||||
|
if not item_path:
|
||||||
|
return False
|
||||||
|
if recursive:
|
||||||
|
return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/")
|
||||||
|
return item_path == normalized_folder
|
||||||
|
|
||||||
|
filtered_data = [
|
||||||
|
item for item in filtered_data
|
||||||
|
if matches_folder(item.get('folder', ''))
|
||||||
|
]
|
||||||
|
|
||||||
# Apply search filter
|
# Apply search filter
|
||||||
if search:
|
if search:
|
||||||
# Default search options if none provided
|
# Default search options if none provided
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { RecipeCard } from '../components/RecipeCard.js';
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
const RECIPE_ENDPOINTS = {
|
||||||
|
list: '/api/lm/recipes',
|
||||||
|
detail: '/api/lm/recipe',
|
||||||
|
scan: '/api/lm/recipes/scan',
|
||||||
|
update: '/api/lm/recipe',
|
||||||
|
folders: '/api/lm/recipes/folders',
|
||||||
|
folderTree: '/api/lm/recipes/folder-tree',
|
||||||
|
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECIPE_SIDEBAR_CONFIG = {
|
||||||
|
config: {
|
||||||
|
displayName: 'Recipes',
|
||||||
|
supportsMove: false,
|
||||||
|
},
|
||||||
|
endpoints: RECIPE_ENDPOINTS,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch recipes with pagination for virtual scrolling
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
* @param {number} page - Page number to fetch
|
* @param {number} page - Page number to fetch
|
||||||
@@ -18,10 +36,17 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
sort_by: pageState.sortBy
|
sort_by: pageState.sortBy
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pageState.activeFolder) {
|
||||||
|
params.append('folder', pageState.activeFolder);
|
||||||
|
params.append('recursive', pageState.searchOptions?.recursive !== false);
|
||||||
|
} else if (pageState.searchOptions?.recursive !== undefined) {
|
||||||
|
params.append('recursive', pageState.searchOptions.recursive);
|
||||||
|
}
|
||||||
|
|
||||||
// If we have a specific recipe ID to load
|
// If we have a specific recipe ID to load
|
||||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||||
// Special case: load specific recipe
|
// Special case: load specific recipe
|
||||||
const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`);
|
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
@@ -78,7 +103,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recipes
|
// Fetch recipes
|
||||||
const response = await fetch(`/api/lm/recipes?${params.toString()}`);
|
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||||
@@ -213,7 +238,7 @@ export async function refreshRecipes() {
|
|||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
// Call the API endpoint to rebuild the recipe cache
|
||||||
const response = await fetch('/api/lm/recipes/scan');
|
const response = await fetch(RECIPE_ENDPOINTS.scan);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -280,7 +305,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
|
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
|
||||||
|
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
|
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -306,3 +331,33 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RecipeSidebarApiClient {
|
||||||
|
constructor() {
|
||||||
|
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUnifiedFolderTree() {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch recipe folder tree');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchModelFolders() {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch recipe folders');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveBulkModels() {
|
||||||
|
throw new Error('Recipe move operations are not supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveSingleModel() {
|
||||||
|
throw new Error('Recipe move operations are not supported.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export class SidebarManager {
|
|||||||
this.pageControls = pageControls;
|
this.pageControls = pageControls;
|
||||||
this.pageType = pageControls.pageType;
|
this.pageType = pageControls.pageType;
|
||||||
this.lastPageControls = pageControls;
|
this.lastPageControls = pageControls;
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = pageControls?.getSidebarApiClient?.()
|
||||||
|
|| pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
@@ -205,6 +207,10 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeDragAndDrop() {
|
initializeDragAndDrop() {
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.dragHandlersInitialized) {
|
if (!this.dragHandlersInitialized) {
|
||||||
document.addEventListener('dragstart', this.handleCardDragStart);
|
document.addEventListener('dragstart', this.handleCardDragStart);
|
||||||
document.addEventListener('dragend', this.handleCardDragEnd);
|
document.addEventListener('dragend', this.handleCardDragEnd);
|
||||||
@@ -416,7 +422,14 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.apiClient) {
|
if (!this.apiClient) {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
||||||
@@ -470,7 +483,9 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
|
|||||||
@@ -2,18 +2,47 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { ImportManager } from './managers/ImportManager.js';
|
import { ImportManager } from './managers/ImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { getCurrentPageState } from './state/index.js';
|
import { state, getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
|
||||||
|
class RecipePageControls {
|
||||||
|
constructor() {
|
||||||
|
this.pageType = 'recipes';
|
||||||
|
this.pageState = getCurrentPageState();
|
||||||
|
this.sidebarApiClient = new RecipeSidebarApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetAndReload() {
|
||||||
|
refreshVirtualScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshModels(fullRebuild = false) {
|
||||||
|
if (fullRebuild) {
|
||||||
|
await refreshRecipes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshVirtualScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSidebarApiClient() {
|
||||||
|
return this.sidebarApiClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Get page state
|
// Get page state
|
||||||
this.pageState = getCurrentPageState();
|
this.pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
// Page controls for shared sidebar behaviors
|
||||||
|
this.pageControls = new RecipePageControls();
|
||||||
|
|
||||||
// Initialize ImportManager
|
// Initialize ImportManager
|
||||||
this.importManager = new ImportManager();
|
this.importManager = new ImportManager();
|
||||||
|
|
||||||
@@ -52,10 +81,23 @@ class RecipeManager {
|
|||||||
// Expose necessary functions to the page
|
// Expose necessary functions to the page
|
||||||
this._exposeGlobalFunctions();
|
this._exposeGlobalFunctions();
|
||||||
|
|
||||||
|
// Initialize sidebar navigation
|
||||||
|
await this._initSidebar();
|
||||||
|
|
||||||
// Initialize common page features
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _initSidebar() {
|
||||||
|
try {
|
||||||
|
sidebarManager.setHostPageControls(this.pageControls);
|
||||||
|
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
|
||||||
|
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize recipe sidebar:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_initSearchOptions() {
|
_initSearchOptions() {
|
||||||
// Ensure recipes search options are properly initialized
|
// Ensure recipes search options are properly initialized
|
||||||
if (!this.pageState.searchOptions) {
|
if (!this.pageState.searchOptions) {
|
||||||
@@ -63,7 +105,8 @@ class RecipeManager {
|
|||||||
title: true, // Recipe title
|
title: true, // Recipe title
|
||||||
tags: true, // Recipe tags
|
tags: true, // Recipe tags
|
||||||
loraName: true, // LoRA file name
|
loraName: true, // LoRA file name
|
||||||
loraModel: true // LoRA model name
|
loraModel: true, // LoRA model name
|
||||||
|
recursive: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,12 +96,14 @@ export const state = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
sortBy: 'date',
|
sortBy: 'date',
|
||||||
|
activeFolder: getStorageItem('recipes_activeFolder'),
|
||||||
searchManager: null,
|
searchManager: null,
|
||||||
searchOptions: {
|
searchOptions: {
|
||||||
title: true,
|
title: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
loraName: true,
|
loraName: true,
|
||||||
loraModel: true
|
loraModel: true,
|
||||||
|
recursive: getStorageItem('recipes_recursiveSearch', true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Recipe grid -->
|
||||||
<div class="card-grid" id="recipeGrid">
|
<div class="card-grid" id="recipeGrid">
|
||||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||||
|
|||||||
Reference in New Issue
Block a user