checkpoint

This commit is contained in:
Will Miao
2025-03-14 16:37:52 +08:00
parent b77df8f89f
commit 426e84cfa3
9 changed files with 1591 additions and 1 deletions

View File

@@ -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')
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)

View File

@@ -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"
}

View File

@@ -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: {}

View File

@@ -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;
}

View File

@@ -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 = '<div class="placeholder">Image preview will appear here</div>';
}
// 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 = `<img src="${e.target.result}" alt="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 ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath}</div>
</div>` :
`<div class="missing-badge">
<i class="fas fa-exclamation-triangle"></i> Not in Library
</div>`;
return `
<div class="lora-item ${existsLocally ? 'exists-locally' : 'missing-locally'}">
<div class="lora-thumbnail">
<img src="${lora.thumbnailUrl || '/loras_static/images/no-preview.png'}" alt="LoRA preview">
</div>
<div class="lora-content">
<div class="lora-header">
<h3>${lora.name}</h3>
${localStatus}
</div>
${lora.version ? `<div class="lora-version">${lora.version}</div>` : ''}
<div class="lora-info">
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
<div class="weight-badge">Weight: ${lora.weight || 1.0}</div>
</div>
</div>
</div>
`;
}).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 = '<div class="empty-tags">No tags added</div>';
return;
}
tagsContainer.innerHTML = this.recipeTags.map(tag => `
<div class="recipe-tag">
${tag}
<i class="fas fa-times" onclick="importManager.removeTag('${tag}')"></i>
</div>
`).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 =>
`<option value="${root}">${root}</option>`
).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 => `
<div class="missing-lora-item">
<div class="lora-name">${lora.name}</div>
<div class="lora-type">${lora.type || 'lora'}</div>
</div>
`).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 = '<div class="empty-folder">Please select a LoRA root directory</div>';
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 => `
<div class="folder-item" data-folder="${folder}">
<i class="fas fa-folder"></i> ${folder}
</div>
`).join('');
} else {
folderBrowser.innerHTML = '<div class="empty-folder">No folders found</div>';
}
// 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 = `<div class="error-message">Error: ${error.message}</div>`;
}
// 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 = `<span class="path-text">${fullPath}</span>`;
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -0,0 +1,107 @@
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Import Recipe</h2>
<span class="close-modal" onclick="modalManager.closeModal('importModal')">&times;</span>
</div>
<div class="modal-body">
<!-- Step 1: Upload Image -->
<div id="uploadStep" class="import-step">
<p>Upload an image with LoRA metadata to import as a recipe.</p>
<div class="file-input-wrapper">
<input type="file" id="recipeImageUpload" accept="image/*" onchange="importManager.handleImageUpload(event)">
<div class="file-input-button">
<i class="fas fa-upload"></i> Select Image
</div>
</div>
<div id="uploadError" class="error-message"></div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('importModal')">Cancel</button>
</div>
</div>
<!-- Step 2: Recipe Details -->
<div id="detailsStep" class="import-step" style="display: none;">
<div class="recipe-details-layout">
<div class="recipe-image-container">
<div id="recipeImagePreview" class="recipe-image"></div>
</div>
<div class="recipe-form-container">
<div class="recipe-name-container">
<label for="recipeName">Recipe Name</label>
<input type="text" id="recipeName" placeholder="Enter recipe name"
onchange="importManager.handleRecipeNameChange(event)">
</div>
<div class="tags-section">
<label>Tags (optional)</label>
<div class="tag-input-container">
<input type="text" id="tagInput" placeholder="Add a tag">
<button class="secondary-btn" onclick="importManager.addTag()">
<i class="fas fa-plus"></i> Add
</button>
</div>
<div id="tagsContainer" class="tags-container">
<div class="empty-tags">No tags added</div>
</div>
</div>
</div>
</div>
<div class="loras-section">
<label>LoRAs in this Recipe <span id="loraCountInfo" class="lora-count-info">(0/0 in library)</span></label>
<div id="lorasList" class="loras-list">
<!-- LoRAs will be populated here -->
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="importManager.backToUpload()">Back</button>
<button class="primary-btn" onclick="importManager.proceedFromDetails()">Next</button>
</div>
</div>
<!-- Step 3: Download Location (if needed) -->
<div id="locationStep" class="import-step" style="display: none;">
<p>The following LoRAs are not in your library and need to be downloaded:</p>
<div id="missingLorasList" class="missing-loras-list">
<!-- Missing LoRAs will be populated here -->
</div>
<div class="input-group">
<label for="importLoraRoot">Select LoRA Root Directory</label>
<select id="importLoraRoot">
<!-- LoRA roots will be populated here -->
</select>
</div>
<div class="input-group">
<label>Select Folder (optional)</label>
<div id="importFolderBrowser" class="folder-browser">
<!-- Folders will be populated here -->
</div>
</div>
<div class="input-group">
<label for="importNewFolder">Create New Folder (optional)</label>
<input type="text" id="importNewFolder" placeholder="Enter folder name">
</div>
<div class="path-preview">
<label>Download Location:</label>
<div id="importTargetPathDisplay" class="path-display"></div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="importManager.backToDetails()">Back</button>
<button class="primary-btn" onclick="importManager.saveRecipe()">Download & Save Recipe</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/loras_static/css/style.css">
<link rel="stylesheet" href="/loras_static/css/components/recipe-card.css">
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/loras_static/images/favicon-16x16.png">
@@ -113,6 +114,7 @@
{% include 'components/modals.html' %}
{% include 'components/loading.html' %}
{% include 'components/context_menu.html' %}
{% include 'components/import_modal.html' %}
<div class="container">
{% if is_initializing %}