diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index edf56ed9..7493087e 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -60,6 +60,9 @@ class RecipeRoutes: app.on_startup.append(routes._init_cache) app.router.add_post('/api/recipes/save-from-widget', routes.save_recipe_from_widget) + + # Add route to get recipes for a specific Lora + app.router.add_get('/api/recipes/for-lora', routes.get_recipes_for_lora) async def _init_cache(self, app): """Initialize cache on startup""" @@ -98,6 +101,10 @@ class RecipeRoutes: base_models = request.query.get('base_models', None) tags = request.query.get('tags', None) + # New parameter: get LoRA hash filter + lora_hash = request.query.get('lora_hash', None) + bypass_filters = request.query.get('bypass_filters', 'false').lower() == 'true' + # Parse filter parameters filters = {} if base_models: @@ -113,14 +120,16 @@ class RecipeRoutes: 'lora_model': search_lora_model } - # Get paginated data + # Get paginated data with the new lora_hash parameter 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 + search_options=search_options, + lora_hash=lora_hash, + bypass_filters=bypass_filters ) # Format the response data with static URLs for file paths @@ -148,20 +157,14 @@ class RecipeRoutes: """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) + # Use the new get_recipe_by_id method from recipe_scanner + recipe = await self.recipe_scanner.get_recipe_by_id(recipe_id) 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) + return web.json_response(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) @@ -1134,3 +1137,49 @@ class RecipeRoutes: except Exception as e: logger.error(f"Error reconnecting LoRA: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) + + async def get_recipes_for_lora(self, request: web.Request) -> web.Response: + """Get recipes that use a specific Lora""" + try: + lora_hash = request.query.get('hash') + + # Hash is required + if not lora_hash: + return web.json_response({'success': False, 'error': 'Lora hash is required'}, status=400) + + # Log the search parameters + logger.info(f"Getting recipes for Lora by hash: {lora_hash}") + + # Get all recipes from cache + cache = await self.recipe_scanner.get_cached_data() + + # Filter recipes that use this Lora by hash + matching_recipes = [] + for recipe in cache.raw_data: + # Check if any of the recipe's loras match this hash + loras = recipe.get('loras', []) + for lora in loras: + if lora.get('hash', '').lower() == lora_hash.lower(): + matching_recipes.append(recipe) + break # No need to check other loras in this recipe + + # Process the recipes similar to get_paginated_data to ensure all needed data is available + for recipe in matching_recipes: + # Add inLibrary information for each lora + if 'loras' in recipe: + for lora in recipe['loras']: + if 'hash' in lora and lora['hash']: + lora['inLibrary'] = self.recipe_scanner._lora_scanner.has_lora_hash(lora['hash'].lower()) + lora['preview_url'] = self.recipe_scanner._lora_scanner.get_preview_url_by_hash(lora['hash'].lower()) + lora['localPath'] = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(lora['hash'].lower()) + + # Ensure file_url is set (needed by frontend) + if 'file_path' in recipe: + recipe['file_url'] = self._format_recipe_file_url(recipe['file_path']) + else: + recipe['file_url'] = '/loras_static/images/no-preview.png' + + return web.json_response({'success': True, 'recipes': matching_recipes}) + except Exception as e: + logger.error(f"Error getting recipes for Lora: {str(e)}") + return web.json_response({'success': False, 'error': str(e)}, status=500) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 51590a3e..ef4644aa 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -330,7 +330,7 @@ class RecipeScanner: 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): + 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 = False): """Get paginated and filtered recipe data Args: @@ -340,69 +340,89 @@ class RecipeScanner: search: Search term filters: Dictionary of filters to apply 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 """ 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 - } + # Special case: Filter by LoRA hash (takes precedence if bypass_filters is True) + if lora_hash: + # Filter recipes that contain this LoRA hash + filtered_data = [ + item for item in filtered_data + if 'loras' in item and any( + lora.get('hash', '').lower() == lora_hash.lower() + for lora in item['loras'] + ) + ] - # 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)] + if bypass_filters: + # Skip other filters if bypass_filters is True + pass + # Otherwise continue with normal filtering after applying LoRA hash filter - # 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'] - ] + # Skip further filtering if we're only filtering by LoRA hash with bypass enabled + if not (lora_hash and bypass_filters): + # 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)] - # 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']) - ] + # 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) @@ -430,6 +450,74 @@ class RecipeScanner: } return result + + async def get_recipe_by_id(self, recipe_id: str) -> dict: + """Get a single recipe by ID with all metadata and formatted URLs + + Args: + recipe_id: The ID of the recipe to retrieve + + Returns: + Dict containing the recipe data or None if not found + """ + if not recipe_id: + return None + + # Get all recipes from cache + cache = await self.get_cached_data() + + # Find the recipe with the specified ID + recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None) + + if not recipe: + return None + + # Format the recipe with all needed information + formatted_recipe = {**recipe} # Copy all fields + + # Format file path to URL + if 'file_path' in formatted_recipe: + formatted_recipe['file_url'] = self._format_file_url(formatted_recipe['file_path']) + + # Format dates for display + for date_field in ['created_date', 'modified']: + if date_field in formatted_recipe: + formatted_recipe[f"{date_field}_formatted"] = self._format_timestamp(formatted_recipe[date_field]) + + # Add lora metadata + if 'loras' in formatted_recipe: + for lora in formatted_recipe['loras']: + if 'hash' in lora and lora['hash']: + lora_hash = lora['hash'].lower() + lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora_hash) + lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora_hash) + lora['localPath'] = self._lora_scanner.get_lora_path_by_hash(lora_hash) + + return formatted_recipe + + def _format_file_url(self, file_path: str) -> str: + """Format file path as URL for serving in web UI""" + if not file_path: + return '/loras_static/images/no-preview.png' + + try: + # Format file path as a URL that will work with static file serving + 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 name + 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 file URL: {e}") + return '/loras_static/images/no-preview.png' + + 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 update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool: """Update recipe metadata (like title and tags) in both file system and cache diff --git a/static/css/components/filter-indicator.css b/static/css/components/filter-indicator.css new file mode 100644 index 00000000..aaace6cc --- /dev/null +++ b/static/css/components/filter-indicator.css @@ -0,0 +1,65 @@ +/* Filter indicator styles */ +.control-group .filter-active { + display: flex; + align-items: center; + gap: 8px; + background: var(--lora-accent); + color: white; + border-radius: 20px; + padding: 6px 12px; + transition: all 0.3s ease; + border: none; + cursor: pointer; +} + +.control-group .filter-active:hover { + background: var(--lora-accent); + opacity: 0.9; +} + +.control-group .filter-active i.fa-filter { + font-size: 0.9em; + margin-right: 2px; +} + +.control-group .filter-active i.clear-filter { + transition: transform 0.2s ease; + cursor: pointer; + margin-left: 4px; + border-radius: 50%; + font-size: 0.9em; +} + +.control-group .filter-active i.clear-filter:hover { + transform: scale(1.2); +} + +.control-group .filter-active .lora-name { + font-weight: 500; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Animation for filter indicator */ +@keyframes filterPulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +.filter-active.animate { + animation: filterPulse 0.6s ease; +} + +/* Make responsive */ +@media (max-width: 576px) { + .control-group .filter-active { + padding: 6px 10px; + } + + .control-group .filter-active .lora-name { + max-width: 100px; + } +} \ No newline at end of file diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 66f43a92..07fa221f 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -863,7 +863,7 @@ } .model-description-content blockquote { - border-left: 3px solid var(--lora-accent); + border-left: 3px solid var (--lora-accent); padding-left: 1em; margin-left: 0; margin-right: 0; @@ -1280,4 +1280,47 @@ font-size: 1.1em; color: var(--lora-accent); opacity: 0.8; +} + +.view-all-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + background-color: var(--lora-accent); + color: var(--lora-text); + border: none; + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: background-color 0.2s; + font-size: 13px; +} + +.view-all-btn:hover { + opacity: 0.9; +} + +/* Loading, error and empty states */ +.recipes-loading, +.recipes-error, +.recipes-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; + min-height: 200px; +} + +.recipes-loading i, +.recipes-error i, +.recipes-empty i { + font-size: 32px; + margin-bottom: 15px; + color: var(--lora-accent); +} + +.recipes-error i { + color: var(--lora-error); } \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index b48ee6c2..400f7d07 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -18,6 +18,7 @@ @import 'components/search-filter.css'; @import 'components/bulk.css'; @import 'components/shared.css'; +@import 'components/filter-indicator.css'; .initialization-notice { display: flex; diff --git a/static/js/components/loraModal/RecipeTab.js b/static/js/components/loraModal/RecipeTab.js new file mode 100644 index 00000000..129b95b2 --- /dev/null +++ b/static/js/components/loraModal/RecipeTab.js @@ -0,0 +1,244 @@ +/** + * RecipeTab - Handles the recipes tab in the Lora Modal + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; + +/** + * Loads recipes that use the specified Lora and renders them in the tab + * @param {string} loraName - The display name of the Lora + * @param {string} sha256 - The SHA256 hash of the Lora + */ +export function loadRecipesForLora(loraName, sha256) { + const recipeTab = document.getElementById('recipes-tab'); + if (!recipeTab) return; + + // Show loading state + recipeTab.innerHTML = ` +
+ Loading recipes... +
+ `; + + // Fetch recipes that use this Lora by hash + fetch(`/api/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`) + .then(response => response.json()) + .then(data => { + if (!data.success) { + throw new Error(data.error || 'Failed to load recipes'); + } + + renderRecipes(recipeTab, data.recipes, loraName, sha256); + }) + .catch(error => { + console.error('Error loading recipes for Lora:', error); + recipeTab.innerHTML = ` +
+ +

Failed to load recipes. Please try again later.

+
+ `; + }); +} + +/** + * Renders the recipe cards in the tab + * @param {HTMLElement} tabElement - The tab element to render into + * @param {Array} recipes - Array of recipe objects + * @param {string} loraName - The display name of the Lora + * @param {string} loraHash - The hash of the Lora + */ +function renderRecipes(tabElement, recipes, loraName, loraHash) { + if (!recipes || recipes.length === 0) { + tabElement.innerHTML = ` +
+ +

No recipes found that use this Lora.

+
+ `; + + return; + } + + // Create header with count and view all button + const headerElement = document.createElement('div'); + headerElement.className = 'recipes-header'; + headerElement.innerHTML = ` +

Found ${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora

+ + `; + + // Add click handler for "View All" button + headerElement.querySelector('.view-all-btn').addEventListener('click', () => { + navigateToRecipesPage(loraName, loraHash); + }); + + // Create grid container for recipe cards + const cardGrid = document.createElement('div'); + cardGrid.className = 'card-grid'; + + // Create recipe cards matching the structure in recipes.html + recipes.forEach(recipe => { + // Get basic info + const baseModel = recipe.base_model || ''; + const loras = recipe.loras || []; + const lorasCount = loras.length; + const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length; + const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; + + // Ensure file_url exists, fallback to file_path if needed + const imageUrl = recipe.file_url || + (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : + '/loras_static/images/no-preview.png'); + + // Create card element matching the structure in recipes.html + const card = document.createElement('div'); + card.className = 'lora-card'; + card.dataset.filePath = recipe.file_path || ''; + card.dataset.title = recipe.title || ''; + card.dataset.created = recipe.created_date || ''; + card.dataset.id = recipe.id || ''; + + card.innerHTML = ` +
R
+
+ ${recipe.title} +
+
+ ${baseModel ? `${baseModel}` : ''} +
+
+ +
+
+ +
+ `; + + // Add event listeners for action buttons + card.querySelector('.fa-copy').addEventListener('click', (e) => { + e.stopPropagation(); + copyRecipeSyntax(recipe.id); + }); + + // Add click handler for the entire card + card.addEventListener('click', () => { + navigateToRecipeDetails(recipe.id); + }); + + // Add card to grid + cardGrid.appendChild(card); + }); + + // Clear loading indicator and append content + tabElement.innerHTML = ''; + tabElement.appendChild(headerElement); + tabElement.appendChild(cardGrid); +} + +/** + * Returns a descriptive title for the LoRA status indicator + * @param {number} totalCount - Total number of LoRAs in recipe + * @param {number} missingCount - Number of missing LoRAs + * @returns {string} Status title text + */ +function 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`; +} + +/** + * Copies recipe syntax to clipboard + * @param {string} recipeId - The recipe ID + */ +function copyRecipeSyntax(recipeId) { + if (!recipeId) { + showToast('Cannot copy recipe syntax: Missing recipe ID', 'error'); + return; + } + + fetch(`/api/recipe/${recipeId}/syntax`) + .then(response => response.json()) + .then(data => { + if (data.success && data.syntax) { + return navigator.clipboard.writeText(data.syntax); + } else { + throw new Error(data.error || 'No syntax returned'); + } + }) + .then(() => { + showToast('Recipe syntax copied to clipboard', 'success'); + }) + .catch(err => { + console.error('Failed to copy: ', err); + showToast('Failed to copy recipe syntax', 'error'); + }); +} + +/** + * Navigates to the recipes page with filter for the current Lora + * @param {string} loraName - The Lora display name to filter by + * @param {string} loraHash - The hash of the Lora to filter by + * @param {boolean} createNew - Whether to open the create recipe dialog + */ +function navigateToRecipesPage(loraName, loraHash, createNew = false) { + // Close the current modal + if (window.modalManager) { + modalManager.closeModal('loraModal'); + } + + // Clear any previous filters first + removeSessionItem('filterLoraName'); + removeSessionItem('filterLoraHash'); + removeSessionItem('bypassExistingFilters'); + removeSessionItem('viewRecipeId'); + + // Store the LoRA name and hash filter in sessionStorage + setSessionItem('filterLoraName', loraName); + setSessionItem('filterLoraHash', loraHash); + + // Set a flag to indicate we're navigating with a specific filter + setSessionItem('bypassExistingFilters', 'true'); + + // Set flag to open create dialog if requested + if (createNew) { + setSessionItem('openCreateRecipeDialog', 'true'); + } + + // Directly navigate to recipes page + window.location.href = '/loras/recipes'; +} + +/** + * Navigates directly to a specific recipe's details + * @param {string} recipeId - The recipe ID to view + */ +function navigateToRecipeDetails(recipeId) { + // Close the current modal + if (window.modalManager) { + modalManager.closeModal('loraModal'); + } + + // Clear any previous filters first + removeSessionItem('filterLoraName'); + removeSessionItem('filterLoraHash'); + removeSessionItem('bypassExistingFilters'); + removeSessionItem('viewRecipeId'); + + // Store the recipe ID in sessionStorage to load on recipes page + setSessionItem('viewRecipeId', recipeId); + + // Directly navigate to recipes page + window.location.href = '/loras/recipes'; +} \ No newline at end of file diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js index 051113fe..0cc44203 100644 --- a/static/js/components/loraModal/index.js +++ b/static/js/components/loraModal/index.js @@ -10,6 +10,7 @@ import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; +import { loadRecipesForLora } from './RecipeTab.js'; // Add import for recipe tab import { setupModelNameEditing, setupBaseModelEditing, @@ -116,6 +117,7 @@ export function showLoraModal(lora) {
+
@@ -133,6 +135,12 @@ export function showLoraModal(lora) {
+ +
+
+ Loading recipes... +
+
+ +