checkpoint

This commit is contained in:
Will Miao
2025-03-14 21:10:24 +08:00
parent 426e84cfa3
commit 5e4d2c7760
5 changed files with 664 additions and 693 deletions

View File

@@ -40,6 +40,7 @@ class ApiRoutes:
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
app.router.add_get('/api/lora-roots', routes.get_lora_roots)
app.router.add_get('/api/folders', routes.get_folders)
app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions)
app.router.add_post('/api/download-lora', routes.download_lora)
app.router.add_post('/api/settings', routes.update_settings)
@@ -520,6 +521,13 @@ class ApiRoutes:
return web.json_response({
'roots': config.loras_roots
})
async def get_folders(self, request: web.Request) -> web.Response:
"""Get all folders in the cache"""
cache = await self.scanner.get_cached_data()
return web.json_response({
'folders': cache.folders
})
async def get_civitai_versions(self, request: web.Request) -> web.Response:
"""Get available versions for a Civitai model with local availability info"""

View File

@@ -180,7 +180,6 @@ class RecipeRoutes:
# 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:
@@ -191,7 +190,6 @@ class RecipeRoutes:
# 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', [])
@@ -220,7 +218,6 @@ class RecipeRoutes:
# 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

View File

@@ -3,6 +3,341 @@
margin: var(--space-2) 0;
}
/* File Input Styles */
.file-input-wrapper {
position: relative;
margin-bottom: var(--space-1);
}
.file-input-wrapper input[type="file"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.file-input-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.file-input-button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
.file-input-wrapper:hover .file-input-button {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
/* Recipe Details Layout */
.recipe-details-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.recipe-image-container {
width: 100%;
height: 200px;
border-radius: var(--border-radius-sm);
overflow: hidden;
background: var(--lora-surface);
border: 1px solid var(--border-color);
}
.recipe-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.recipe-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.recipe-form-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Tags Input Styles */
.tag-input-container {
display: flex;
gap: 8px;
margin-bottom: var(--space-1);
}
.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: var(--space-1);
min-height: 32px;
}
.recipe-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--lora-surface);
border: 1px solid var(--border-color);
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;
color: var(--lora-error);
}
.empty-tags {
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
font-style: italic;
}
/* 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;
}
.lora-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.lora-item.missing-locally {
border-left: 4px solid var(--lora-error);
}
.lora-thumbnail {
width: 80px;
height: 80px;
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: 8px;
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: 1.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);
}
.lora-version {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.weight-badge {
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
}
/* Missing LoRAs List */
.missing-loras-list {
max-height: 200px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--space-1);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
}
.missing-lora-item {
display: flex;
gap: var(--space-2);
padding: var(--space-1);
border-bottom: 1px solid var(--border-color);
}
.missing-lora-item:last-child {
border-bottom: none;
}
.missing-badge {
display: inline-flex;
align-items: center;
background: var(--lora-error);
color: white;
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;
}
.lora-count-info {
font-size: 0.85em;
opacity: 0.8;
font-weight: normal;
margin-left: 8px;
}
/* Location Selection Styles */
.location-selection {
margin: var(--space-2) 0;
padding: var(--space-2);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
}
/* Reuse folder browser and path preview styles from download-modal.css */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Input Group Styles */
.input-group {
margin-bottom: var(--space-2);
}
@@ -29,447 +364,30 @@
margin-top: 4px;
}
/* Image Upload Styles */
.image-upload-container {
/* Modal Actions */
.modal-actions {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--space-2);
margin-bottom: var(--space-3);
margin-top: 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;
/* Dark theme adjustments */
[data-theme="dark"] .lora-item {
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;
[data-theme="dark"] .recipe-tag {
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;
/* Responsive adjustments */
@media (max-width: 768px) {
.recipe-details-layout {
grid-template-columns: 1fr;
}
.recipe-image-container {
height: 150px;
}
}

View File

@@ -20,10 +20,12 @@ export class ImportManager {
this.loadingManager = new LoadingManager();
this.folderClickHandler = null;
this.updateTargetPath = this.updateTargetPath.bind(this);
// 添加对注入样式的引用
this.injectedStyles = null;
}
showImportModal() {
console.log('Showing import modal...');
if (!this.initialized) {
// Check if modal exists
const modal = document.getElementById('importModal');
@@ -37,13 +39,26 @@ export class ImportManager {
modalManager.showModal('importModal', null, () => {
// Cleanup handler when modal closes
this.cleanupFolderBrowser();
// 移除任何强制样式
this.removeInjectedStyles();
});
this.resetSteps();
}
// 添加移除注入样式的方法
removeInjectedStyles() {
if (this.injectedStyles && this.injectedStyles.parentNode) {
this.injectedStyles.parentNode.removeChild(this.injectedStyles);
this.injectedStyles = null;
}
}
resetSteps() {
document.querySelectorAll('.import-step').forEach(step => step.style.display = 'none');
document.getElementById('uploadStep').style.display = 'block';
// 移除可能存在的强制样式
this.removeInjectedStyles();
this.showStep('uploadStep');
// Reset file input
const fileInput = document.getElementById('recipeImageUpload');
@@ -147,8 +162,7 @@ export class ImportManager {
}
showRecipeDetailsStep() {
document.getElementById('uploadStep').style.display = 'none';
document.getElementById('detailsStep').style.display = 'block';
this.showStep('detailsStep');
// Set default recipe name from image filename
const recipeName = document.getElementById('recipeName');
@@ -277,6 +291,8 @@ export class ImportManager {
return;
}
console.log('Proceeding from details, missing LoRAs:', this.missingLoras.length);
// If we have missing LoRAs, go to location step
if (this.missingLoras.length > 0) {
this.proceedToLocation();
@@ -287,239 +303,242 @@ export class ImportManager {
}
async proceedToLocation() {
document.getElementById('detailsStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block';
// 先移除可能已有的样式
this.removeInjectedStyles();
// 添加强制CSS覆盖
this.injectedStyles = document.createElement('style');
this.injectedStyles.innerHTML = `
#locationStep {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
position: static !important;
z-index: 10000 !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
transform: none !important;
}
`;
document.head.appendChild(this.injectedStyles);
console.log('Added override CSS to force visibility');
this.showStep('locationStep');
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');
// Fetch LoRA roots
const rootsResponse = await fetch('/api/lora-roots');
if (!rootsResponse.ok) {
throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`);
}
const data = await response.json();
const rootsData = await rootsResponse.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');
if (loraRoot) {
loraRoot.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>`
).join('');
}
// Populate roots dropdown
loraRoot.innerHTML = data.roots.map(root =>
`<option value="${root}">${root}</option>`
).join('');
// Fetch folders
const foldersResponse = await fetch('/api/folders');
if (!foldersResponse.ok) {
throw new Error(`Failed to fetch folders: ${foldersResponse.status}`);
}
const foldersData = await foldersResponse.json();
const folderBrowser = document.getElementById('importFolderBrowser');
if (folderBrowser) {
folderBrowser.innerHTML = foldersData.folders.map(folder =>
folder ? `<div class="folder-item" data-folder="${folder}">${folder}</div>` : ''
).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();
// Initialize folder browser after loading data
this.initializeFolderBrowser();
} catch (error) {
console.error('Error in proceedToLocation:', error);
console.error('Error in API calls:', 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';
this.showStep('uploadStep');
}
backToDetails() {
document.getElementById('locationStep').style.display = 'none';
document.getElementById('detailsStep').style.display = 'block';
this.showStep('detailsStep');
}
async saveRecipe() {
if (!this.recipeName) {
showToast('Please enter a recipe name', 'error');
return;
}
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
// First save the recipe
this.loadingManager.showSimpleLoading('Saving recipe...');
// Create form data for recipe save
// Create form data for save request
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));
formData.append('metadata', JSON.stringify(this.recipeData));
// Save recipe
const saveResponse = await fetch('/api/recipes/save', {
// Send save request
const response = await fetch('/api/recipes/save', {
method: 'POST',
body: formData
});
if (!saveResponse.ok) {
throw new Error(await saveResponse.text());
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to save recipe');
}
showToast('Recipe saved successfully', 'success');
// Show success message for recipe save
showToast(`Recipe "${this.recipeName}" saved successfully`, 'success');
// Check if we need to download LoRAs
if (this.missingLoras.length > 0) {
// For download, we need to validate the target path
const loraRoot = document.getElementById('importLoraRoot')?.value;
if (!loraRoot) {
throw new Error('Please select a LoRA root directory');
}
// Build target path
let targetPath = loraRoot;
if (this.selectedFolder) {
targetPath += '/' + this.selectedFolder;
}
const newFolder = document.getElementById('importNewFolder')?.value?.trim();
if (newFolder) {
targetPath += '/' + newFolder;
}
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
// Download missing LoRAs sequentially
this.loadingManager.show('Downloading LoRAs...', 0);
let completedDownloads = 0;
for (let i = 0; i < this.missingLoras.length; i++) {
const lora = this.missingLoras[i];
// Update overall progress
this.loadingManager.setStatus(`Downloading LoRA ${i+1}/${this.missingLoras.length}: ${lora.name}`);
// Set up progress tracking for current download
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
// Calculate overall progress: completed files + current file progress
const overallProgress = Math.floor(
(completedDownloads + data.progress/100) / this.missingLoras.length * 100
);
this.loadingManager.setProgress(overallProgress);
}
};
try {
// Download the LoRA
const response = await fetch('/api/download-lora', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
download_url: lora.downloadUrl,
lora_root: loraRoot,
relative_path: targetPath.replace(loraRoot + '/', '')
})
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Failed to download LoRA ${lora.name}: ${errorText}`);
// Continue with next download
} else {
completedDownloads++;
}
} catch (downloadError) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
// Continue with next download
}
}
// Close WebSocket
ws.close();
// Show final completion message
if (completedDownloads === this.missingLoras.length) {
showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success');
} else {
showToast(`Downloaded ${completedDownloads} of ${this.missingLoras.length} LoRAs`, 'warning');
}
}
// Close modal and reload recipes
modalManager.closeModal('importModal');
// Reload recipes
window.location.reload();
// Refresh the recipe list if needed
if (typeof refreshRecipes === 'function') {
refreshRecipes();
} else {
// Fallback to reloading the page
resetAndReload();
}
} catch (error) {
console.error('Error saving recipe:', error);
showToast(error.message, 'error');
} finally {
this.loadingManager.hide();
}
}
// Add new method to handle folder selection
async initializeFolderBrowser() {
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('');
// 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.innerHTML = '<div class="empty-folder">No folders found</div>';
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
f.classList.remove('selected'));
folderItem.classList.add('selected');
this.selectedFolder = folderItem.dataset.folder;
}
// Create new handler
this.folderClickHandler = (event) => {
const folderItem = event.target.closest('.folder-item');
if (!folderItem) return;
// Update path display after folder selection
this.updateTargetPath();
};
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 the new handler
folderBrowser.addEventListener('click', this.folderClickHandler);
// 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);
if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath);
if (newFolder) newFolder.addEventListener('input', this.updateTargetPath);
console.log('Initializing folder browser...');
// Update initial path
this.updateTargetPath();
}
@@ -541,15 +560,14 @@ export class ImportManager {
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() || '';
const newFolder = document.getElementById('importNewFolder')?.value?.trim() || '';
let fullPath = loraRoot || 'Select a LoRA root directory';
let fullPath = loraRoot || 'Select a LoRA root directory';
if (loraRoot) {
if (this.selectedFolder) {
@@ -559,7 +577,41 @@ export class ImportManager {
fullPath += '/' + newFolder;
}
}
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
}
showStep(stepId) {
// 隐藏所有步骤
document.querySelectorAll('.import-step').forEach(step => {
step.style.display = 'none';
});
// 显示目标步骤
const targetStep = document.getElementById(stepId);
if (targetStep) {
// 强制显示目标步骤 - 使用 !important 覆盖任何其他CSS规则
targetStep.setAttribute('style', 'display: block !important');
// 调试信息
console.log(`Showing step: ${stepId}`);
const rect = targetStep.getBoundingClientRect();
console.log('Step dimensions:', {
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left,
visible: rect.width > 0 && rect.height > 0
});
// 强制重新计算布局
targetStep.offsetHeight;
// 滚动模态内容到顶部
const modalContent = document.querySelector('#importModal .modal-content');
if (modalContent) {
modalContent.scrollTop = 0;
}
}
}
}

View File

@@ -1,106 +1,102 @@
<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>
<button class="close" onclick="modalManager.closeModal('importModal')">&times;</button>
<h2>Import Recipe</h2>
<!-- Step 1: Upload Image -->
<div class="import-step" id="uploadStep">
<p>Upload an image with LoRA metadata to import as a recipe.</p>
<div class="input-group">
<label for="recipeImageUpload">Select Image:</label>
<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 class="error-message" id="uploadError"></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 class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('importModal')">Cancel</button>
</div>
</div>
<!-- Step 2: Recipe Details -->
<div class="import-step" id="detailsStep" 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="input-group">
<label for="recipeName">Recipe Name</label>
<input type="text" id="recipeName" placeholder="Enter recipe name"
onchange="importManager.handleRecipeNameChange(event)">
</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 class="input-group">
<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 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 id="tagsContainer" class="tags-container">
<div class="empty-tags">No tags added</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 class="input-group">
<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 class="import-step" id="locationStep" style="display: none;">
<div class="location-selection">
<!-- Move path preview to top -->
<div class="path-preview">
<label>Download Location Preview:</label>
<div class="path-display" id="importTargetPathDisplay">
<span class="path-text">Select a LoRA root directory</span>
</div>
</div>
<div class="input-group">
<label>Select LoRA Root:</label>
<select id="importLoraRoot"></select>
</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">
<label>Target Folder:</label>
<div class="folder-browser" id="importFolderBrowser">
<!-- Folders will be populated here -->
</div>
</div>
<div class="input-group">
<label for="importNewFolder">Create New Folder (optional)</label>
<label for="importNewFolder">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 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>