diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 35469eaf..f6751c4a 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -56,6 +56,9 @@ class RecipeHandlerSet: "delete_recipe": self.management.delete_recipe, "get_top_tags": self.query.get_top_tags, "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, "download_shared_recipe": self.sharing.download_shared_recipe, "get_recipe_syntax": self.query.get_recipe_syntax, @@ -149,6 +152,8 @@ class RecipeListingHandler: page_size = int(request.query.get("page_size", "20")) sort_by = request.query.get("sort_by", "date") search = request.query.get("search") + folder = request.query.get("folder") + recursive = request.query.get("recursive", "true").lower() == "true" search_options = { "title": request.query.get("search_title", "true").lower() == "true", @@ -193,6 +198,8 @@ class RecipeListingHandler: filters=filters, search_options=search_options, lora_hash=lora_hash, + folder=folder, + recursive=recursive, ) for item in result.get("items", []): @@ -299,6 +306,45 @@ class RecipeQueryHandler: self._logger.error("Error retrieving base models: %s", exc, exc_info=True) 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: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 18bf4cba..22f18d88 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -27,6 +27,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), 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/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/download", "download_shared_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), diff --git a/py/services/recipe_cache.py b/py/services/recipe_cache.py index ac28b3aa..279c9e37 100644 --- a/py/services/recipe_cache.py +++ b/py/services/recipe_cache.py @@ -7,12 +7,18 @@ from natsort import natsorted @dataclass class RecipeCache: """Cache structure for Recipe data""" + raw_data: List[Dict] sorted_by_name: List[Dict] sorted_by_date: List[Dict] + folders: List[str] | None = None + folder_tree: Dict | None = None def __post_init__(self): 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): """Resort all cached data views""" diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index c7ac2842..efa77119 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -1,7 +1,9 @@ -import os -import logging +from __future__ import annotations + import asyncio import json +import logging +import os import time from typing import Any, Dict, Iterable, List, Optional, Set, Tuple from ..config import config @@ -117,7 +119,9 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=[], sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) # Mark as initializing to prevent concurrent initializations @@ -218,6 +222,7 @@ class RecipeScanner: # Update cache with the collected data self._cache.raw_data = recipes + self._update_folder_metadata(self._cache) # Create a simplified resort function that doesn't use await if hasattr(self._cache, "resort"): @@ -336,6 +341,9 @@ class RecipeScanner: if not self._cache: return + # Keep folder metadata up to date alongside sort order + self._update_folder_metadata() + async def _resort_wrapper() -> None: try: await self._cache.resort(name_only=name_only) @@ -346,6 +354,75 @@ class RecipeScanner: self._resort_tasks.add(task) 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 def recipes_dir(self) -> str: """Get path to recipes directory""" @@ -362,11 +439,14 @@ class RecipeScanner: """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: + self._update_folder_metadata() 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=[]) + 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: @@ -384,11 +464,14 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=raw_data, sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) - + # Resort cache await self._cache.resort() + self._update_folder_metadata(self._cache) return self._cache @@ -398,7 +481,9 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=[], sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) return self._cache finally: @@ -409,7 +494,9 @@ class RecipeScanner: logger.error(f"Unexpected error in get_cached_data: {e}") # 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: """Public helper to refresh or return the recipe cache.""" @@ -424,6 +511,7 @@ class RecipeScanner: cache = await self.get_cached_data() await cache.add_recipe(recipe_data, resort=False) + self._update_folder_metadata(cache) self._schedule_resort() async def remove_recipe(self, recipe_id: str) -> bool: @@ -437,6 +525,7 @@ class RecipeScanner: if removed is None: return False + self._update_folder_metadata(cache) self._schedule_resort() return True @@ -521,6 +610,9 @@ class RecipeScanner: if path_updated: 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 if 'loras' not in recipe_data: @@ -914,7 +1006,7 @@ class RecipeScanner: 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 Args: @@ -926,6 +1018,8 @@ class RecipeScanner: search_options: Dictionary of search options to apply 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 + 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() @@ -961,6 +1055,22 @@ class RecipeScanner: # Skip further filtering if we're only filtering by LoRA hash with bypass enabled 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 if search: # Default search options if none provided diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index ece9938f..1421e569 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -2,6 +2,24 @@ import { RecipeCard } from '../components/RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.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 * @param {number} page - Page number to fetch @@ -17,11 +35,18 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { page_size: pageSize || pageState.pageSize || 20, 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 (pageState.customFilter?.active && pageState.customFilter?.recipeId) { // 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) { throw new Error(`Failed to load recipe: ${response.statusText}`); @@ -78,7 +103,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } // Fetch recipes - const response = await fetch(`/api/lm/recipes?${params.toString()}`); + const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`); if (!response.ok) { throw new Error(`Failed to load recipes: ${response.statusText}`); @@ -213,7 +238,7 @@ export async function refreshRecipes() { state.loadingManager.showSimpleLoading('Refreshing recipes...'); // 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) { const data = await response.json(); @@ -280,7 +305,7 @@ export async function updateRecipeMetadata(filePath, updates) { const basename = filePath.split('/').pop().split('\\').pop(); 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', headers: { 'Content-Type': 'application/json', @@ -306,3 +331,33 @@ export async function updateRecipeMetadata(filePath, updates) { 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.'); + } +} diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index f4ed13bb..8c21643a 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -77,7 +77,9 @@ export class SidebarManager { this.pageControls = pageControls; this.pageType = pageControls.pageType; this.lastPageControls = pageControls; - this.apiClient = getModelApiClient(); + this.apiClient = pageControls?.getSidebarApiClient?.() + || pageControls?.sidebarApiClient + || getModelApiClient(); // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); @@ -205,6 +207,10 @@ export class SidebarManager { } initializeDragAndDrop() { + if (this.apiClient?.apiConfig?.config?.supportsMove === false) { + return; + } + if (!this.dragHandlersInitialized) { document.addEventListener('dragstart', this.handleCardDragStart); document.addEventListener('dragend', this.handleCardDragEnd); @@ -416,7 +422,14 @@ export class SidebarManager { } 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, '/') : ''; @@ -470,7 +483,9 @@ export class SidebarManager { } async init() { - this.apiClient = getModelApiClient(); + this.apiClient = this.pageControls?.getSidebarApiClient?.() + || this.pageControls?.sidebarApiClient + || getModelApiClient(); // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); diff --git a/static/js/recipes.js b/static/js/recipes.js index 293bb8d9..99327c75 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -2,17 +2,46 @@ import { appCore } from './core.js'; import { ImportManager } from './managers/ImportManager.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 { RecipeContextMenu } from './components/ContextMenu/index.js'; import { DuplicatesManager } from './components/DuplicatesManager.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 { constructor() { // Get page state this.pageState = getCurrentPageState(); + + // Page controls for shared sidebar behaviors + this.pageControls = new RecipePageControls(); // Initialize ImportManager this.importManager = new ImportManager(); @@ -51,10 +80,23 @@ class RecipeManager { // Expose necessary functions to the page this._exposeGlobalFunctions(); + + // Initialize sidebar navigation + await this._initSidebar(); // Initialize common page features 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() { // Ensure recipes search options are properly initialized @@ -63,7 +105,8 @@ class RecipeManager { title: true, // Recipe title tags: true, // Recipe tags loraName: true, // LoRA file name - loraModel: true // LoRA model name + loraModel: true, // LoRA model name + recursive: true }; } } diff --git a/static/js/state/index.js b/static/js/state/index.js index 4ee59b55..69d05d5b 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -96,12 +96,14 @@ export const state = { isLoading: false, hasMore: true, sortBy: 'date', + activeFolder: getStorageItem('recipes_activeFolder'), searchManager: null, searchOptions: { title: true, tags: true, loraName: true, - loraModel: true + loraModel: true, + recursive: getStorageItem('recipes_recursiveSearch', true), }, filters: { baseModel: [], diff --git a/templates/recipes.html b/templates/recipes.html index 202791a2..aba5ce63 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -75,7 +75,9 @@ - + + {% include 'components/folder_sidebar.html' %} +