diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index d43e1972..dc649eba 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -550,7 +550,7 @@ class RecipeRoutes: with open(image_path, 'wb') as f: f.write(optimized_image) - # Create the recipe JSON + # Create the recipe data structure current_time = time.time() # Format loras data according to the recipe.json format @@ -606,6 +606,10 @@ class RecipeRoutes: if tags: recipe_data["tags"] = tags + # Add source_path if provided in metadata + if metadata.get("source_path"): + recipe_data["source_path"] = metadata.get("source_path") + # Save the recipe JSON json_filename = f"{recipe_id}.recipe.json" json_path = os.path.join(recipes_dir, json_filename) @@ -1165,9 +1169,9 @@ class RecipeRoutes: data = await request.json() # Validate required fields - if 'title' not in data and 'tags' not in data: + if 'title' not in data and 'tags' not in data and 'source_path' not in data: return web.json_response({ - "error": "At least one field to update must be provided (title or tags)" + "error": "At least one field to update must be provided (title or tags or source_path)" }, status=400) # Use the recipe scanner's update method diff --git a/py/utils/lora_metadata.py b/py/utils/lora_metadata.py index f221562d..3dcecd75 100644 --- a/py/utils/lora_metadata.py +++ b/py/utils/lora_metadata.py @@ -2,6 +2,9 @@ from safetensors import safe_open from typing import Dict from .model_utils import determine_base_model import os +import logging + +logger = logging.getLogger(__name__) async def extract_lora_metadata(file_path: str) -> Dict: """Extract essential metadata from safetensors file""" diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 399ce01d..6fd1ed7e 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -229,8 +229,10 @@ background: var(--lora-surface); border: 1px solid var(--border-color); display: flex; + flex-direction: column; align-items: center; justify-content: center; + position: relative; } .recipe-preview-container img, @@ -246,6 +248,133 @@ object-fit: contain; } +/* Source URL container */ +.source-url-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.5); + padding: 8px 12px; + display: flex; + justify-content: space-between; + align-items: center; + transition: transform 0.3s ease; + transform: translateY(100%); +} + +.recipe-preview-container:hover .source-url-container { + transform: translateY(0); +} + +.source-url-container.active { + transform: translateY(0); +} + +.source-url-content { + display: flex; + align-items: center; + color: #fff; + flex: 1; + overflow: hidden; + font-size: 0.85em; +} + +.source-url-icon { + margin-right: 8px; + flex-shrink: 0; +} + +.source-url-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + flex: 1; +} + +.source-url-edit-btn { + background: none; + border: none; + color: #fff; + cursor: pointer; + padding: 4px; + margin-left: 8px; + border-radius: var(--border-radius-xs); + opacity: 0.7; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.source-url-edit-btn:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.1); +} + +/* Source URL editor */ +.source-url-editor { + display: none; + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-color); + border-top: 1px solid var(--border-color); + padding: 12px; + flex-direction: column; + gap: 10px; + z-index: 5; +} + +.source-url-editor.active { + display: flex; +} + +.source-url-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9em; +} + +.source-url-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.source-url-cancel-btn, +.source-url-save-btn { + padding: 6px 12px; + border-radius: var(--border-radius-xs); + font-size: 0.85em; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.source-url-cancel-btn { + background: var(--bg-color); + color: var(--text-color); + border: 1px solid var(--border-color); +} + +.source-url-save-btn { + background: var(--lora-accent); + color: white; +} + +.source-url-cancel-btn:hover { + background: var(--lora-surface); +} + +.source-url-save-btn:hover { + background: color-mix(in oklch, var(--lora-accent), black 10%); +} + /* Generation Parameters */ .recipe-gen-params { height: 360px; diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index c87d52bb..29ea95c4 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -7,6 +7,9 @@ class RecipeCard { this.recipe = recipe; this.clickHandler = clickHandler; this.element = this.createCardElement(); + + // Store reference to this instance on the DOM element for updates + this.element._recipeCardInstance = this; } createCardElement() { diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index e05e43bc..8e7dbb09 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -2,6 +2,7 @@ import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; +import { updateRecipeCard } from '../utils/cardUpdater.js'; class RecipeModal { constructor() { @@ -82,7 +83,7 @@ class RecipeModal { showRecipeDetails(recipe) { // Store the full recipe for editing - this.currentRecipe = JSON.parse(JSON.stringify(recipe)); // 深拷贝以避免对原始对象的修改 + this.currentRecipe = recipe; // Set modal title with edit icon const modalTitle = document.getElementById('recipeModalTitle'); @@ -245,6 +246,45 @@ class RecipeModal { imgElement.alt = recipe.title || 'Recipe Preview'; mediaContainer.appendChild(imgElement); } + + // Add source URL container if the recipe has a source_path + const sourceUrlContainer = document.createElement('div'); + sourceUrlContainer.className = 'source-url-container'; + const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0; + const sourceUrl = hasSourceUrl ? recipe.source_path : ''; + const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://')); + + sourceUrlContainer.innerHTML = ` +
+ + ${ + hasSourceUrl ? sourceUrl : 'No source URL' + } +
+ + `; + + // Add source URL editor + const sourceUrlEditor = document.createElement('div'); + sourceUrlEditor.className = 'source-url-editor'; + sourceUrlEditor.innerHTML = ` + +
+ + +
+ `; + + // Append both containers to the media container + mediaContainer.appendChild(sourceUrlContainer); + mediaContainer.appendChild(sourceUrlEditor); + + // Set up event listeners for source URL functionality + setTimeout(() => { + this.setupSourceUrlHandlers(); + }, 50); } // Set generation parameters @@ -646,50 +686,8 @@ class RecipeModal { // 更新当前recipe对象的属性 Object.assign(this.currentRecipe, updates); - // 确保这个更新也传播到卡片视图 - // 尝试找到可能显示这个recipe的卡片并更新它 - try { - const recipeCards = document.querySelectorAll('.recipe-card'); - recipeCards.forEach(card => { - if (card.dataset.recipeId === this.recipeId) { - // 更新卡片标题 - if (updates.title) { - const titleElement = card.querySelector('.recipe-title'); - if (titleElement) { - titleElement.textContent = updates.title; - } - } - - // 更新卡片标签 - if (updates.tags) { - const tagsElement = card.querySelector('.recipe-tags'); - if (tagsElement) { - if (updates.tags.length > 0) { - tagsElement.innerHTML = updates.tags.map( - tag => `
${tag}
` - ).join(''); - } else { - tagsElement.innerHTML = ''; - } - } - } - } - }); - } catch (err) { - console.log("Non-critical error updating recipe cards:", err); - } - - // 重要:强制刷新recipes列表,确保从服务器获取最新数据 - try { - if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { - // 异步刷新recipes列表,不阻塞用户界面 - setTimeout(() => { - window.recipeManager.loadRecipes(true); - }, 500); - } - } catch (err) { - console.log("Error refreshing recipes list:", err); - } + // Update the recipe card in the UI + updateRecipeCard(this.recipeId, updates); } else { showToast(`Failed to update recipe: ${data.error}`, 'error'); } @@ -1067,6 +1065,56 @@ class RecipeModal { }); }); } + + // New method to set up source URL handlers + setupSourceUrlHandlers() { + const sourceUrlContainer = document.querySelector('.source-url-container'); + const sourceUrlEditor = document.querySelector('.source-url-editor'); + const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); + const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn'); + const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn'); + const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn'); + const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); + + // Show editor on edit button click + sourceUrlEditBtn.addEventListener('click', () => { + sourceUrlContainer.classList.add('hide'); + sourceUrlEditor.classList.add('active'); + sourceUrlInput.focus(); + }); + + // Cancel editing + sourceUrlCancelBtn.addEventListener('click', () => { + sourceUrlEditor.classList.remove('active'); + sourceUrlContainer.classList.remove('hide'); + sourceUrlInput.value = this.currentRecipe.source_path || ''; + }); + + // Save new source URL + sourceUrlSaveBtn.addEventListener('click', () => { + const newSourceUrl = sourceUrlInput.value.trim(); + if (newSourceUrl && newSourceUrl !== this.currentRecipe.source_path) { + // Update source URL in the UI + sourceUrlText.textContent = newSourceUrl; + sourceUrlText.title = newSourceUrl.startsWith('http://') || newSourceUrl.startsWith('https://') ? 'Click to open source URL' : 'No valid URL'; + + // Update the recipe on the server + this.updateRecipeMetadata({ source_path: newSourceUrl }); + } + + // Hide editor + sourceUrlEditor.classList.remove('active'); + sourceUrlContainer.classList.remove('hide'); + }); + + // Open source URL in a new tab if it's valid + sourceUrlText.addEventListener('click', () => { + const url = sourceUrlText.textContent.trim(); + if (url.startsWith('http://') || url.startsWith('https://')) { + window.open(url, '_blank'); + } + }); + } } export { RecipeModal }; \ No newline at end of file diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index db28b4aa..ae39a141 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -907,17 +907,17 @@ export class ImportManager { } try { - this.loadingManager.showSimpleLoading(isDownloadOnly ? 'Preparing download...' : 'Saving recipe...'); + // Show progress indicator + this.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...'); - // If we're only downloading LoRAs for an existing recipe, skip the recipe save step + // Only send the complete recipe to save if not in download-only mode if (!isDownloadOnly) { - // First save the recipe - // Create form data for save request + // Create FormData object for saving recipe const formData = new FormData(); - // Handle image data - either from file upload or from URL mode + // Add image data - depends on import mode if (this.recipeImage) { - // File upload mode + // Direct upload formData.append('image', this.recipeImage); } else if (this.recipeData && this.recipeData.image_base64) { // URL mode with base64 data @@ -945,6 +945,15 @@ export class ImportManager { raw_metadata: this.recipeData.raw_metadata || {} }; + // Add source_path to metadata to track where the recipe was imported from + if (this.importMode === 'url') { + const urlInput = document.getElementById('imageUrlInput'); + console.log("urlInput.value", urlInput.value); + if (urlInput && urlInput.value) { + completeMetadata.source_path = urlInput.value; + } + } + formData.append('metadata', JSON.stringify(completeMetadata)); // Send save request diff --git a/static/js/utils/cardUpdater.js b/static/js/utils/cardUpdater.js index ce099f98..06c08735 100644 --- a/static/js/utils/cardUpdater.js +++ b/static/js/utils/cardUpdater.js @@ -125,4 +125,65 @@ export function updateLoraCard(filePath, updates, newFilePath) { }); return loraCard; // Return the updated card element for chaining +} + +/** + * Update the recipe card after metadata edits in the modal + * @param {string} recipeId - ID of the recipe to update + * @param {Object} updates - Object containing the updates (title, tags, source_path) + */ +export function updateRecipeCard(recipeId, updates) { + // Find the card with matching recipe ID + const recipeCard = document.querySelector(`.lora-card[data-id="${recipeId}"]`); + if (!recipeCard) return; + + // Get the recipe card component instance + const recipeCardInstance = recipeCard._recipeCardInstance; + + // Update card dataset and visual elements based on the updates object + Object.entries(updates).forEach(([key, value]) => { + // Update dataset + recipeCard.dataset[key] = value; + + // Update visual elements based on the property + switch(key) { + case 'title': + // Update the title in the recipe object + if (recipeCardInstance && recipeCardInstance.recipe) { + recipeCardInstance.recipe.title = value; + } + + // Update the title shown in the card + const modelNameElement = recipeCard.querySelector('.model-name'); + if (modelNameElement) modelNameElement.textContent = value; + break; + + case 'tags': + // Update tags in the recipe object (not displayed on card UI) + if (recipeCardInstance && recipeCardInstance.recipe) { + recipeCardInstance.recipe.tags = value; + } + + // Store in dataset as JSON string + try { + if (typeof value === 'string') { + recipeCard.dataset.tags = value; + } else { + recipeCard.dataset.tags = JSON.stringify(value); + } + } catch (e) { + console.error('Failed to update recipe tags:', e); + } + break; + + case 'source_path': + // Update source_path in the recipe object (not displayed on card UI) + if (recipeCardInstance && recipeCardInstance.recipe) { + recipeCardInstance.recipe.source_path = value; + } + break; + } + }); + + return recipeCard; // Return the updated card element for chaining } \ No newline at end of file diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html index 79d0ce82..fb1b74cf 100644 --- a/templates/components/recipe_modal.html +++ b/templates/components/recipe_modal.html @@ -18,6 +18,7 @@
Recipe Preview +