From 426e84cfa3c83976e9635f300af07a546d0369f0 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Fri, 14 Mar 2025 16:37:52 +0800 Subject: [PATCH] checkpoint --- py/routes/recipe_routes.py | 304 ++++++++++- refs/civitai_api_model_by_versionId.json | 101 ++++ refs/jpeg_civitai_exif_userComment_example | 3 + static/css/components/import-modal.css | 475 +++++++++++++++++ static/js/managers/ImportManager.js | 565 +++++++++++++++++++++ static/js/managers/ModalManager.js | 9 + static/js/recipes.js | 26 + templates/components/import_modal.html | 107 ++++ templates/recipes.html | 2 + 9 files changed, 1591 insertions(+), 1 deletion(-) create mode 100644 refs/civitai_api_model_by_versionId.json create mode 100644 refs/jpeg_civitai_exif_userComment_example create mode 100644 static/css/components/import-modal.css create mode 100644 static/js/managers/ImportManager.js create mode 100644 templates/components/import_modal.html diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index 4f01957d..2c8cb127 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -3,10 +3,17 @@ import logging import sys from aiohttp import web from typing import Dict +import tempfile +import json +import aiohttp +import asyncio +from ..utils.exif_utils import ExifUtils +from ..services.civitai_client import CivitaiClient from ..services.recipe_scanner import RecipeScanner from ..services.lora_scanner import LoraScanner from ..config import config +import time # Add this import at the top logger = logging.getLogger(__name__) print("Recipe Routes module loaded", file=sys.stderr) @@ -17,6 +24,7 @@ class RecipeRoutes: def __init__(self): print("Initializing RecipeRoutes", file=sys.stderr) self.recipe_scanner = RecipeScanner(LoraScanner()) + self.civitai_client = CivitaiClient() # Pre-warm the cache self._init_cache_task = None @@ -28,6 +36,9 @@ class RecipeRoutes: routes = cls() app.router.add_get('/api/recipes', routes.get_recipes) app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail) + app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image) + app.router.add_post('/api/recipes/download-missing-loras', routes.download_missing_loras) + app.router.add_post('/api/recipes/save', routes.save_recipe) # Start cache initialization app.on_startup.append(routes._init_cache) @@ -143,4 +154,295 @@ class RecipeRoutes: 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') \ No newline at end of file + return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') + + async def analyze_recipe_image(self, request: web.Request) -> web.Response: + """Analyze an uploaded image for recipe metadata""" + temp_path = None + try: + reader = await request.multipart() + field = await reader.next() + + if field.name != 'image': + return web.json_response({ + "error": "No image field found", + "loras": [] + }, status=400) + + # Create a temporary file to store the uploaded image + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file: + while True: + chunk = await field.read_chunk() + if not chunk: + break + temp_file.write(chunk) + temp_path = temp_file.name + + # Extract metadata from the image using ExifUtils + user_comment = ExifUtils.extract_user_comment(temp_path) + print(f"User comment: {user_comment}", file=sys.stderr) + + # If no metadata found, return a more specific error + if not user_comment: + return web.json_response({ + "error": "No metadata found in this image", + "loras": [] # Return empty loras array to prevent client-side errors + }, status=200) # Return 200 instead of 400 to handle gracefully + + # Parse the recipe metadata + metadata = ExifUtils.parse_recipe_metadata(user_comment) + print(f"Metadata: {metadata}", file=sys.stderr) + + # Look for Civitai resources in the metadata + civitai_resources = metadata.get('loras', []) + checkpoint = metadata.get('checkpoint') + + if not civitai_resources and not checkpoint: + return web.json_response({ + "error": "No LoRA information found in this image", + "loras": [] # Return empty loras array + }, status=200) # Return 200 instead of 400 + + # Process the resources to get LoRA information + loras = [] + base_model = None + + # Set base model from checkpoint if available + if checkpoint: + base_model = checkpoint.get('modelName', '') + + # Process LoRAs + for resource in civitai_resources: + # Get model version ID + model_version_id = resource.get('modelVersionId') + if not model_version_id: + continue + + # Get additional info from Civitai + civitai_info = await self.civitai_client.get_model_version_info(model_version_id) + print(f"Civitai info: {civitai_info}", file=sys.stderr) + + # Check if this LoRA exists locally by SHA256 hash + exists_locally = False + local_path = "" + + if civitai_info and 'files' in civitai_info and civitai_info['files']: + sha256 = civitai_info['files'][0].get('hashes', {}).get('SHA256', '') + if sha256: + sha256 = sha256.lower() # Convert to lowercase for consistency + exists_locally = self.recipe_scanner._lora_scanner.has_lora_hash(sha256) + if exists_locally: + local_path = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(sha256) or "" + + # Create LoRA entry + lora_entry = { + 'id': model_version_id, + 'name': resource.get('modelName', ''), + 'version': resource.get('modelVersionName', ''), + 'type': resource.get('type', 'lora'), + 'weight': resource.get('weight', 1.0), + 'existsLocally': exists_locally, + 'localPath': local_path, + 'thumbnailUrl': '', + 'baseModel': '', + 'size': 0, + 'downloadUrl': '' + } + + # Add Civitai info if available + if civitai_info: + # Get thumbnail URL from first image + if 'images' in civitai_info and civitai_info['images']: + lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '') + + # Get base model + lora_entry['baseModel'] = civitai_info.get('baseModel', '') + + # Get file size + if 'files' in civitai_info and civitai_info['files']: + lora_entry['size'] = civitai_info['files'][0].get('sizeKB', 0) * 1024 + + # Get download URL + lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '') + + loras.append(lora_entry) + + return web.json_response({ + 'base_model': base_model, + 'loras': loras + }) + + except Exception as e: + logger.error(f"Error analyzing recipe image: {e}", exc_info=True) + return web.json_response({ + "error": str(e), + "loras": [] # Return empty loras array to prevent client-side errors + }, status=500) + finally: + # Clean up the temporary file in the finally block + if temp_path and os.path.exists(temp_path): + try: + os.unlink(temp_path) + except Exception as e: + logger.error(f"Error deleting temporary file: {e}") + + async def download_missing_loras(self, request: web.Request) -> web.Response: + """Download missing LoRAs for a recipe""" + try: + data = await request.json() + loras = data.get('loras', []) + lora_root = data.get('lora_root', '') + relative_path = data.get('relative_path', '') + + if not loras: + return web.json_response({"error": "No LoRAs specified"}, status=400) + + if not lora_root: + return web.json_response({"error": "No LoRA root directory specified"}, status=400) + + # Create target directory if it doesn't exist + target_dir = os.path.join(lora_root, relative_path) if relative_path else lora_root + os.makedirs(target_dir, exist_ok=True) + + # Download each LoRA + downloaded = [] + for lora in loras: + download_url = lora.get('downloadUrl') + if not download_url: + continue + + # Generate filename from LoRA name + filename = f"{lora.get('name', 'lora')}.safetensors" + filename = filename.replace(' ', '_').replace('/', '_').replace('\\', '_') + + # Download the file + target_path = os.path.join(target_dir, filename) + + async with aiohttp.ClientSession() as session: + async with session.get(download_url, allow_redirects=True) as response: + if response.status != 200: + continue + + with open(target_path, 'wb') as f: + while True: + chunk = await response.content.read(1024 * 1024) # 1MB chunks + if not chunk: + break + f.write(chunk) + + downloaded.append({ + 'id': lora.get('id'), + 'localPath': target_path + }) + + return web.json_response({ + 'downloaded': downloaded + }) + + except Exception as e: + logger.error(f"Error downloading missing LoRAs: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def save_recipe(self, request: web.Request) -> web.Response: + """Save a recipe to the recipes folder""" + try: + reader = await request.multipart() + + # Process form data + image = None + name = None + tags = [] + recipe_data = None + + while True: + field = await reader.next() + if field is None: + break + + if field.name == 'image': + # Read image data + image_data = b'' + while True: + chunk = await field.read_chunk() + if not chunk: + break + image_data += chunk + image = image_data + + elif field.name == 'name': + name = await field.text() + + elif field.name == 'tags': + tags_text = await field.text() + try: + tags = json.loads(tags_text) + except: + tags = [] + + elif field.name == 'recipe_data': + recipe_data_text = await field.text() + try: + recipe_data = json.loads(recipe_data_text) + except: + recipe_data = {} + + if not image or not name or not recipe_data: + return web.json_response({"error": "Missing required fields"}, status=400) + + # Create recipes directory if it doesn't exist + recipes_dir = os.path.join(config.loras_roots[0], "recipes") + os.makedirs(recipes_dir, exist_ok=True) + + # Generate filename from recipe name + filename = f"{name}.jpg" + filename = filename.replace(' ', '_').replace('/', '_').replace('\\', '_') + + # Ensure filename is unique + counter = 1 + base_name, ext = os.path.splitext(filename) + while os.path.exists(os.path.join(recipes_dir, filename)): + filename = f"{base_name}_{counter}{ext}" + counter += 1 + + # Save the image + target_path = os.path.join(recipes_dir, filename) + with open(target_path, 'wb') as f: + f.write(image) + + # Add metadata to the image + from PIL import Image + from PIL.ExifTags import TAGS + from piexif import dump, load + import piexif.helper + + # Prepare metadata + metadata = { + 'recipe_name': name, + 'recipe_tags': json.dumps(tags), + 'recipe_data': json.dumps(recipe_data), + 'created_date': str(time.time()) + } + + # Write metadata to image + img = Image.open(target_path) + exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} + + for key, value in metadata.items(): + exif_dict["0th"][piexif.ImageIFD.XPComment] = piexif.helper.UserComment.dump( + json.dumps({key: value}) + ) + + exif_bytes = dump(exif_dict) + img.save(target_path, exif=exif_bytes) + + # Force refresh the recipe cache + await self.recipe_scanner.get_cached_data(force_refresh=True) + + return web.json_response({ + 'success': True, + 'file_path': target_path + }) + + except Exception as e: + logger.error(f"Error saving recipe: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) \ No newline at end of file diff --git a/refs/civitai_api_model_by_versionId.json b/refs/civitai_api_model_by_versionId.json new file mode 100644 index 00000000..2e096654 --- /dev/null +++ b/refs/civitai_api_model_by_versionId.json @@ -0,0 +1,101 @@ +{ + "id": 1387174, + "modelId": 1231067, + "name": "v1.0", + "createdAt": "2025-02-08T11:15:47.197Z", + "updatedAt": "2025-02-08T11:29:04.526Z", + "status": "Published", + "publishedAt": "2025-02-08T11:29:04.487Z", + "trainedWords": [ + "ppstorybook" + ], + "trainingStatus": null, + "trainingDetails": null, + "baseModel": "Flux.1 D", + "baseModelType": null, + "earlyAccessEndsAt": null, + "earlyAccessConfig": null, + "description": null, + "uploadType": "Created", + "usageControl": "Download", + "air": "urn:air:flux1:lora:civitai:1231067@1387174", + "stats": { + "downloadCount": 1436, + "ratingCount": 0, + "rating": 0, + "thumbsUpCount": 316 + }, + "model": { + "name": "Vivid Impressions Storybook Style", + "type": "LORA", + "nsfw": false, + "poi": false + }, + "files": [ + { + "id": 1289799, + "sizeKB": 18829.1484375, + "name": "pp-storybook_rank2_bf16.safetensors", + "type": "Model", + "pickleScanResult": "Success", + "pickleScanMessage": "No Pickle imports", + "virusScanResult": "Success", + "virusScanMessage": null, + "scannedAt": "2025-02-08T11:21:04.247Z", + "metadata": { + "format": "SafeTensor", + "size": null, + "fp": null + }, + "hashes": { + "AutoV1": "F414C813", + "AutoV2": "9753338AB6", + "SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668", + "CRC32": "A65AE7B3", + "BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591", + "AutoV3": "34A22376739D" + }, + "primary": true, + "downloadUrl": "https://civitai.com/api/download/models/1387174" + } + ], + "images": [ + { + "url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg", + "nsfwLevel": 1, + "width": 832, + "height": 1216, + "hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC", + "type": "image", + "metadata": { + "hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC", + "size": 1361590, + "width": 832, + "height": 1216 + }, + "meta": { + "Size": "832x1216", + "seed": 1116375220995209, + "Model": "flux_dev_fp8", + "steps": 23, + "hashes": { + "model": "" + }, + "prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.", + "Version": "ComfyUI", + "sampler": "DPM++ 2M", + "cfgScale": 3.5, + "clipSkip": 1, + "resources": [], + "Model hash": "" + }, + "availability": "Public", + "hasMeta": true, + "hasPositivePrompt": true, + "onSite": false, + "remixOfId": null + } + // more images here + ], + "downloadUrl": "https://civitai.com/api/download/models/1387174" +} \ No newline at end of file diff --git a/refs/jpeg_civitai_exif_userComment_example b/refs/jpeg_civitai_exif_userComment_example new file mode 100644 index 00000000..f4301f0e --- /dev/null +++ b/refs/jpeg_civitai_exif_userComment_example @@ -0,0 +1,3 @@ +a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting. +Negative prompt: +Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {} \ No newline at end of file diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css new file mode 100644 index 00000000..fdd1318b --- /dev/null +++ b/static/css/components/import-modal.css @@ -0,0 +1,475 @@ +/* Import Modal Styles */ +.import-step { + margin: var(--space-2) 0; +} + +.input-group { + margin-bottom: var(--space-2); +} + +.input-group label { + display: block; + margin-bottom: 8px; + color: var(--text-color); +} + +.input-group input, +.input-group select { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); +} + +.error-message { + color: var(--lora-error); + font-size: 0.9em; + margin-top: 4px; +} + +/* Image Upload Styles */ +.image-upload-container { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.image-preview { + width: 100%; + height: 200px; + border: 2px dashed var(--border-color); + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: var(--bg-color); +} + +.image-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.image-preview .placeholder { + color: var(--text-color); + opacity: 0.5; + font-size: 0.9em; +} + +.file-input-wrapper { + position: relative; +} + +.file-input-wrapper input[type="file"] { + opacity: 0; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; +} + +.file-input-wrapper .file-input-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + background: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.file-input-wrapper:hover .file-input-button { + background: var(--lora-surface-hover); +} + +/* Recipe Details Styles */ +.recipe-details-container { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.recipe-name-container { + margin-bottom: var(--space-2); +} + +.recipe-name-container label { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +.recipe-name-container input { + width: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); +} + +.tags-section { + margin-bottom: var(--space-2); +} + +.tags-section label { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +.tag-input-container { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.tag-input-container input { + flex: 1; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + min-height: 32px; +} + +.recipe-tag { + display: flex; + align-items: center; + gap: 4px; + background: var(--lora-surface); + color: var(--text-color); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.9em; +} + +.recipe-tag i { + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; +} + +.recipe-tag i:hover { + opacity: 1; +} + +.empty-tags { + color: var(--text-color); + opacity: 0.5; + font-size: 0.9em; +} + +/* LoRAs List Styles */ +.loras-list { + max-height: 300px; + overflow-y: auto; + margin: var(--space-2) 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 1px; +} + +.lora-item { + display: flex; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: var(--bg-color); + margin: 1px; + position: relative; +} + +.lora-item.exists-locally { + background: oklch(var(--lora-accent) / 0.05); + border-left: 4px solid var(--lora-accent); +} + +.lora-item.missing-locally { + background: oklch(var(--lora-error) / 0.05); + border-left: 4px solid var(--lora-error); +} + +.lora-thumbnail { + width: 60px; + height: 60px; + flex-shrink: 0; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--bg-color); +} + +.lora-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.lora-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.lora-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2); +} + +.lora-content h3 { + margin: 0; + font-size: 1em; + color: var(--text-color); + flex: 1; +} + +.lora-info { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 0.9em; +} + +.lora-info .base-model { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); + padding: 2px 8px; + border-radius: var(--border-radius-xs); +} + +.weight-badge { + background: var(--lora-surface); + color: var(--text-color); + padding: 2px 8px; + border-radius: var(--border-radius-xs); +} + +.lora-meta { + display: flex; + gap: 12px; + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; +} + +.lora-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +/* Status Badges */ +.local-badge { + display: inline-flex; + align-items: center; + background: var(--lora-accent); + color: var(--lora-text); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + position: relative; +} + +.local-badge i { + margin-right: 4px; + font-size: 0.9em; +} + +.missing-badge { + display: inline-flex; + align-items: center; + background: var(--lora-error); + color: var(--lora-text); + padding: 4px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.missing-badge i { + margin-right: 4px; + font-size: 0.9em; +} + +.local-path { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + margin-top: 4px; + font-size: 0.9em; + color: var(--text-color); + white-space: normal; + word-break: break-all; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 1; + min-width: 200px; + max-width: 300px; +} + +.local-badge:hover .local-path { + display: block; +} + +/* Missing LoRAs List */ +.missing-loras-list { + max-height: 150px; + overflow-y: auto; + margin: var(--space-2) 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.missing-lora-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: oklch(var(--lora-error) / 0.05); + border-left: 4px solid var(--lora-error); + border-radius: var(--border-radius-xs); +} + +.lora-name { + font-weight: 500; +} + +.lora-type { + font-size: 0.9em; + opacity: 0.7; +} + +/* Folder Browser Styles */ +.folder-browser { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + max-height: 200px; + overflow-y: auto; +} + +/* Modal Header Styles - Updated to match download-modal */ +.modal-header { + display: flex; + justify-content: space-between; + /* align-items: center; */ + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-color); + position: relative; +} + +.close-modal { + font-size: 1.5rem; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; + position: absolute; + right: 0; + top: 0; + padding: 0 5px; + line-height: 1; +} + +.close-modal:hover { + opacity: 1; +} + +/* Recipe Details Layout */ +.recipe-details-layout { + display: flex; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.recipe-image-container { + flex: 0 0 200px; +} + +.recipe-image { + width: 100%; + height: 200px; + border-radius: var(--border-radius-sm); + overflow: hidden; + border: 1px solid var(--border-color); + background: var(--bg-color); +} + +.recipe-image img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.recipe-form-container { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* Simplify file input for step 1 */ +.file-input-wrapper { + margin: var(--space-3) auto; + max-width: 300px; +} + +/* Update LoRA item styles to include version */ +.lora-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.lora-version { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + margin-top: 2px; +} + +.lora-count-info { + font-size: 0.85em; + font-weight: normal; + color: var(--text-color); + opacity: 0.8; + margin-left: 8px; +} diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js new file mode 100644 index 00000000..34fc6c5c --- /dev/null +++ b/static/js/managers/ImportManager.js @@ -0,0 +1,565 @@ +import { modalManager } from './ModalManager.js'; +import { showToast } from '../utils/uiHelpers.js'; +import { LoadingManager } from './LoadingManager.js'; +import { state } from '../state/index.js'; +import { resetAndReload } from '../api/loraApi.js'; + +export class ImportManager { + constructor() { + this.recipeImage = null; + this.recipeData = null; + this.recipeName = ''; + this.recipeTags = []; + this.missingLoras = []; + + // Add initialization check + this.initialized = false; + this.selectedFolder = ''; + + // Add LoadingManager instance + this.loadingManager = new LoadingManager(); + this.folderClickHandler = null; + this.updateTargetPath = this.updateTargetPath.bind(this); + } + + showImportModal() { + console.log('Showing import modal...'); + if (!this.initialized) { + // Check if modal exists + const modal = document.getElementById('importModal'); + if (!modal) { + console.error('Import modal element not found'); + return; + } + this.initialized = true; + } + + modalManager.showModal('importModal', null, () => { + // Cleanup handler when modal closes + this.cleanupFolderBrowser(); + }); + this.resetSteps(); + } + + resetSteps() { + document.querySelectorAll('.import-step').forEach(step => step.style.display = 'none'); + document.getElementById('uploadStep').style.display = 'block'; + + // Reset file input + const fileInput = document.getElementById('recipeImageUpload'); + if (fileInput) { + fileInput.value = ''; + } + + // Reset error message + const errorElement = document.getElementById('uploadError'); + if (errorElement) { + errorElement.textContent = ''; + } + + // Reset preview + const previewElement = document.getElementById('imagePreview'); + if (previewElement) { + previewElement.innerHTML = '
Image preview will appear here
'; + } + + // Reset state variables + this.recipeImage = null; + this.recipeData = null; + this.recipeName = ''; + this.recipeTags = []; + this.missingLoras = []; + + // Clear selected folder and remove selection from UI + this.selectedFolder = ''; + const folderBrowser = document.getElementById('importFolderBrowser'); + if (folderBrowser) { + folderBrowser.querySelectorAll('.folder-item').forEach(f => + f.classList.remove('selected')); + } + } + + handleImageUpload(event) { + const file = event.target.files[0]; + const errorElement = document.getElementById('uploadError'); + + if (!file) { + return; + } + + // Validate file type + if (!file.type.match('image.*')) { + errorElement.textContent = 'Please select an image file'; + return; + } + + // Reset error + errorElement.textContent = ''; + this.recipeImage = file; + + // Auto-proceed to next step if file is selected + this.uploadAndAnalyzeImage(); + } + + async uploadAndAnalyzeImage() { + if (!this.recipeImage) { + showToast('Please select an image first', 'error'); + return; + } + + try { + this.loadingManager.showSimpleLoading('Analyzing image metadata...'); + + // Create form data for upload + const formData = new FormData(); + formData.append('image', this.recipeImage); + + // Upload image for analysis + const response = await fetch('/api/recipes/analyze-image', { + method: 'POST', + body: formData + }); + + // Get recipe data from response + this.recipeData = await response.json(); + + // Check if we have an error message + if (this.recipeData.error) { + throw new Error(this.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Find missing LoRAs + this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); + + // Proceed to recipe details step + this.showRecipeDetailsStep(); + + } catch (error) { + document.getElementById('uploadError').textContent = error.message; + } finally { + this.loadingManager.hide(); + } + } + + showRecipeDetailsStep() { + document.getElementById('uploadStep').style.display = 'none'; + document.getElementById('detailsStep').style.display = 'block'; + + // Set default recipe name from image filename + const recipeName = document.getElementById('recipeName'); + if (this.recipeImage && !recipeName.value) { + const fileName = this.recipeImage.name.split('.')[0]; + recipeName.value = fileName; + this.recipeName = fileName; + } + + // Display the uploaded image in the preview + const imagePreview = document.getElementById('recipeImagePreview'); + if (imagePreview && this.recipeImage) { + const reader = new FileReader(); + reader.onload = (e) => { + imagePreview.innerHTML = `Recipe preview`; + }; + reader.readAsDataURL(this.recipeImage); + } + + // Update LoRA count information + const totalLoras = this.recipeData.loras.length; + const existingLoras = this.recipeData.loras.filter(lora => lora.existsLocally).length; + const loraCountInfo = document.getElementById('loraCountInfo'); + if (loraCountInfo) { + loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`; + } + + // Display LoRAs list + const lorasList = document.getElementById('lorasList'); + if (lorasList) { + lorasList.innerHTML = this.recipeData.loras.map(lora => { + const existsLocally = lora.existsLocally; + const localPath = lora.localPath || ''; + + // Create local status badge + const localStatus = existsLocally ? + `
+ In Library +
${localPath}
+
` : + `
+ Not in Library +
`; + + return ` +
+
+ LoRA preview +
+
+
+

${lora.name}

+ ${localStatus} +
+ ${lora.version ? `
${lora.version}
` : ''} +
+ ${lora.baseModel ? `
${lora.baseModel}
` : ''} +
Weight: ${lora.weight || 1.0}
+
+
+
+ `; + }).join(''); + } + + // Update Next button state based on missing LoRAs + this.updateNextButtonState(); + } + + updateNextButtonState() { + const nextButton = document.querySelector('#detailsStep .primary-btn'); + if (!nextButton) return; + + // If we have missing LoRAs, show "Download Missing LoRAs" + // Otherwise show "Save Recipe" + if (this.missingLoras.length > 0) { + nextButton.textContent = 'Download Missing LoRAs'; + } else { + nextButton.textContent = 'Save Recipe'; + } + } + + handleRecipeNameChange(event) { + this.recipeName = event.target.value.trim(); + } + + addTag() { + const tagInput = document.getElementById('tagInput'); + const tag = tagInput.value.trim(); + + if (!tag) return; + + if (!this.recipeTags.includes(tag)) { + this.recipeTags.push(tag); + this.updateTagsDisplay(); + } + + tagInput.value = ''; + } + + removeTag(tag) { + this.recipeTags = this.recipeTags.filter(t => t !== tag); + this.updateTagsDisplay(); + } + + updateTagsDisplay() { + const tagsContainer = document.getElementById('tagsContainer'); + + if (this.recipeTags.length === 0) { + tagsContainer.innerHTML = '
No tags added
'; + return; + } + + tagsContainer.innerHTML = this.recipeTags.map(tag => ` +
+ ${tag} + +
+ `).join(''); + } + + proceedFromDetails() { + // Validate recipe name + if (!this.recipeName) { + showToast('Please enter a recipe name', 'error'); + return; + } + + // If we have missing LoRAs, go to location step + if (this.missingLoras.length > 0) { + this.proceedToLocation(); + } else { + // Otherwise, save the recipe directly + this.saveRecipe(); + } + } + + async proceedToLocation() { + document.getElementById('detailsStep').style.display = 'none'; + document.getElementById('locationStep').style.display = 'block'; + + try { + this.loadingManager.showSimpleLoading('Loading download options...'); + + const response = await fetch('/api/lora-roots'); + if (!response.ok) { + throw new Error('Failed to fetch LoRA roots'); + } + + const data = await response.json(); + const loraRoot = document.getElementById('importLoraRoot'); + + // Check if we have roots + if (!data.roots || data.roots.length === 0) { + throw new Error('No LoRA root directories configured'); + } + + // Populate roots dropdown + loraRoot.innerHTML = data.roots.map(root => + `` + ).join(''); + + // Initialize folder browser after loading roots + await this.initializeFolderBrowser(); + + // Display missing LoRAs + const missingLorasList = document.getElementById('missingLorasList'); + if (missingLorasList) { + missingLorasList.innerHTML = this.missingLoras.map(lora => ` +
+
${lora.name}
+
${lora.type || 'lora'}
+
+ `).join(''); + } + + // Update target path display + this.updateTargetPath(); + + } catch (error) { + console.error('Error in proceedToLocation:', error); + showToast(error.message, 'error'); + // Go back to details step on error + this.backToDetails(); + } finally { + this.loadingManager.hide(); + } + } + + backToUpload() { + document.getElementById('detailsStep').style.display = 'none'; + document.getElementById('uploadStep').style.display = 'block'; + } + + backToDetails() { + document.getElementById('locationStep').style.display = 'none'; + document.getElementById('detailsStep').style.display = 'block'; + } + + async saveRecipe() { + try { + // If we're in the location step, we need to download missing LoRAs first + if (document.getElementById('locationStep').style.display !== 'none') { + const loraRoot = document.getElementById('importLoraRoot').value; + const newFolder = document.getElementById('importNewFolder').value.trim(); + + if (!loraRoot) { + showToast('Please select a LoRA root directory', 'error'); + return; + } + + // Construct relative path + let targetFolder = ''; + if (this.selectedFolder) { + targetFolder = this.selectedFolder; + } + if (newFolder) { + targetFolder = targetFolder ? + `${targetFolder}/${newFolder}` : newFolder; + } + + // Show loading with progress bar for download + this.loadingManager.show('Downloading missing LoRAs...', 0); + + // Setup WebSocket for progress updates + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.status === 'progress') { + this.loadingManager.setProgress(data.progress); + this.loadingManager.setStatus(`Downloading: ${data.progress}%`); + } + }; + + // Download missing LoRAs + const downloadResponse = await fetch('/api/recipes/download-missing-loras', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + loras: this.missingLoras, + lora_root: loraRoot, + relative_path: targetFolder + }) + }); + + if (!downloadResponse.ok) { + throw new Error(await downloadResponse.text()); + } + + // Update missing LoRAs with downloaded paths + const downloadResult = await downloadResponse.json(); + this.recipeData.loras = this.recipeData.loras.map(lora => { + const downloaded = downloadResult.downloaded.find(d => d.id === lora.id); + if (downloaded) { + return { + ...lora, + existsLocally: true, + localPath: downloaded.localPath + }; + } + return lora; + }); + } + + // Now save the recipe + this.loadingManager.showSimpleLoading('Saving recipe...'); + + // Create form data for recipe save + const formData = new FormData(); + formData.append('image', this.recipeImage); + formData.append('name', this.recipeName); + formData.append('tags', JSON.stringify(this.recipeTags)); + formData.append('recipe_data', JSON.stringify(this.recipeData)); + + // Save recipe + const saveResponse = await fetch('/api/recipes/save', { + method: 'POST', + body: formData + }); + + if (!saveResponse.ok) { + throw new Error(await saveResponse.text()); + } + + showToast('Recipe saved successfully', 'success'); + modalManager.closeModal('importModal'); + + // Reload recipes + window.location.reload(); + + } catch (error) { + showToast(error.message, 'error'); + } finally { + this.loadingManager.hide(); + } + } + + // Add new method to handle folder selection + async initializeFolderBrowser() { + const folderBrowser = document.getElementById('importFolderBrowser'); + if (!folderBrowser) return; + + // Cleanup existing handler if any + this.cleanupFolderBrowser(); + + try { + // Get the selected root + const loraRoot = document.getElementById('importLoraRoot').value; + if (!loraRoot) { + folderBrowser.innerHTML = '
Please select a LoRA root directory
'; + return; + } + + // Fetch folders for the selected root + const response = await fetch(`/api/folders?root=${encodeURIComponent(loraRoot)}`); + if (!response.ok) { + throw new Error('Failed to fetch folders'); + } + + const data = await response.json(); + + // Display folders + if (data.folders && data.folders.length > 0) { + folderBrowser.innerHTML = data.folders.map(folder => ` +
+ ${folder} +
+ `).join(''); + } else { + folderBrowser.innerHTML = '
No folders found
'; + } + + // Create new handler + this.folderClickHandler = (event) => { + const folderItem = event.target.closest('.folder-item'); + if (!folderItem) return; + + if (folderItem.classList.contains('selected')) { + folderItem.classList.remove('selected'); + this.selectedFolder = ''; + } else { + folderBrowser.querySelectorAll('.folder-item').forEach(f => + f.classList.remove('selected')); + folderItem.classList.add('selected'); + this.selectedFolder = folderItem.dataset.folder; + } + + // Update path display after folder selection + this.updateTargetPath(); + }; + + // Add the new handler + folderBrowser.addEventListener('click', this.folderClickHandler); + + } catch (error) { + console.error('Error initializing folder browser:', error); + folderBrowser.innerHTML = `
Error: ${error.message}
`; + } + + // Add event listeners for path updates + const loraRoot = document.getElementById('importLoraRoot'); + const newFolder = document.getElementById('importNewFolder'); + + loraRoot.addEventListener('change', async () => { + await this.initializeFolderBrowser(); + this.updateTargetPath(); + }); + + newFolder.addEventListener('input', this.updateTargetPath); + + // Update initial path + this.updateTargetPath(); + } + + cleanupFolderBrowser() { + if (this.folderClickHandler) { + const folderBrowser = document.getElementById('importFolderBrowser'); + if (folderBrowser) { + folderBrowser.removeEventListener('click', this.folderClickHandler); + this.folderClickHandler = null; + } + } + + // Remove path update listeners + const loraRoot = document.getElementById('importLoraRoot'); + const newFolder = document.getElementById('importNewFolder'); + + if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); + } + + // Add new method to update target path + updateTargetPath() { + const pathDisplay = document.getElementById('importTargetPathDisplay'); + if (!pathDisplay) return; + + const loraRoot = document.getElementById('importLoraRoot')?.value || ''; + const newFolder = document.getElementById('importNewFolder')?.value.trim() || ''; + + let fullPath = loraRoot || 'Select a LoRA root directory'; + + if (loraRoot) { + if (this.selectedFolder) { + fullPath += '/' + this.selectedFolder; + } + if (newFolder) { + fullPath += '/' + newFolder; + } + } + + pathDisplay.innerHTML = `${fullPath}`; + } +} \ No newline at end of file diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 448b45fc..c6bcf3ad 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -72,6 +72,15 @@ export class ModalManager { } }); + // Add importModal registration + this.registerModal('importModal', { + element: document.getElementById('importModal'), + onClose: () => { + this.getModal('importModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + }); + // Set up event listeners for modal toggles const supportToggle = document.getElementById('supportToggleBtn'); if (supportToggle) { diff --git a/static/js/recipes.js b/static/js/recipes.js index d1c15391..3be9e683 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -2,6 +2,7 @@ import { showToast } from './utils/uiHelpers.js'; import { state } from './state/index.js'; import { initializeCommonComponents } from './common.js'; +import { ImportManager } from './managers/ImportManager.js'; class RecipeManager { constructor() { @@ -10,6 +11,9 @@ class RecipeManager { this.sortBy = 'date'; this.filterParams = {}; + // Initialize ImportManager + this.importManager = new ImportManager(); + this.init(); } @@ -43,6 +47,15 @@ class RecipeManager { }, 300); }); } + + // Import button + const importButton = document.querySelector('button[onclick="importRecipes()"]'); + if (importButton) { + importButton.onclick = (e) => { + e.preventDefault(); + this.importManager.showImportModal(); + }; + } } async loadRecipes() { @@ -198,12 +211,25 @@ class RecipeManager { // - Recipe details view // - Recipe tag filtering // - Recipe search and filters + + // Add a method to handle recipe import + importRecipes() { + this.importManager.showImportModal(); + } } // Initialize components document.addEventListener('DOMContentLoaded', () => { initializeCommonComponents(); window.recipeManager = new RecipeManager(); + + // Make importRecipes function available globally + window.importRecipes = () => { + window.recipeManager.importRecipes(); + }; + + // Expose ImportManager instance globally for the import modal event handlers + window.importManager = window.recipeManager.importManager; }); // Export for use in other modules diff --git a/templates/components/import_modal.html b/templates/components/import_modal.html new file mode 100644 index 00000000..90c4e17e --- /dev/null +++ b/templates/components/import_modal.html @@ -0,0 +1,107 @@ + \ No newline at end of file diff --git a/templates/recipes.html b/templates/recipes.html index 222125b5..b4935984 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -5,6 +5,7 @@ + @@ -113,6 +114,7 @@ {% include 'components/modals.html' %} {% include 'components/loading.html' %} {% include 'components/context_menu.html' %} + {% include 'components/import_modal.html' %}
{% if is_initializing %}