mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
1072 lines
48 KiB
JavaScript
1072 lines
48 KiB
JavaScript
// Recipe Modal Component
|
||
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
||
import { state } from '../state/index.js';
|
||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||
|
||
class RecipeModal {
|
||
constructor() {
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.setupCopyButtons();
|
||
// Set up tooltip positioning handlers after DOM is ready
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
this.setupTooltipPositioning();
|
||
});
|
||
|
||
// Set up document click handler to close edit fields
|
||
document.addEventListener('click', (event) => {
|
||
// Handle title edit
|
||
const titleEditor = document.getElementById('recipeTitleEditor');
|
||
if (titleEditor && titleEditor.classList.contains('active') &&
|
||
!titleEditor.contains(event.target) &&
|
||
!event.target.closest('.edit-icon')) {
|
||
this.saveTitleEdit();
|
||
}
|
||
|
||
// Handle tags edit
|
||
const tagsEditor = document.getElementById('recipeTagsEditor');
|
||
if (tagsEditor && tagsEditor.classList.contains('active') &&
|
||
!tagsEditor.contains(event.target) &&
|
||
!event.target.closest('.edit-icon')) {
|
||
this.saveTagsEdit();
|
||
}
|
||
|
||
// Handle reconnect input
|
||
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
||
reconnectContainers.forEach(container => {
|
||
if (container.classList.contains('active') &&
|
||
!container.contains(event.target) &&
|
||
!event.target.closest('.deleted-badge.reconnectable')) {
|
||
this.hideReconnectInput(container);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips
|
||
setupTooltipPositioning() {
|
||
document.addEventListener('mouseover', (event) => {
|
||
// Check if we're hovering over a local-badge
|
||
if (event.target.closest('.local-badge')) {
|
||
const badge = event.target.closest('.local-badge');
|
||
const tooltip = badge.querySelector('.local-path');
|
||
|
||
if (tooltip) {
|
||
// Get badge position
|
||
const badgeRect = badge.getBoundingClientRect();
|
||
|
||
// Position the tooltip
|
||
tooltip.style.top = (badgeRect.bottom + 4) + 'px';
|
||
tooltip.style.left = (badgeRect.right - tooltip.offsetWidth) + 'px';
|
||
}
|
||
}
|
||
|
||
// Add tooltip positioning for missing badge
|
||
if (event.target.closest('.recipe-status.missing')) {
|
||
const badge = event.target.closest('.recipe-status.missing');
|
||
const tooltip = badge.querySelector('.missing-tooltip');
|
||
|
||
if (tooltip) {
|
||
// Get badge position
|
||
const badgeRect = badge.getBoundingClientRect();
|
||
|
||
// Position the tooltip
|
||
tooltip.style.top = (badgeRect.bottom + 4) + 'px';
|
||
tooltip.style.left = (badgeRect.left) + 'px';
|
||
}
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
showRecipeDetails(recipe) {
|
||
// Store the full recipe for editing
|
||
this.currentRecipe = JSON.parse(JSON.stringify(recipe)); // 深拷贝以避免对原始对象的修改
|
||
|
||
// Set modal title with edit icon
|
||
const modalTitle = document.getElementById('recipeModalTitle');
|
||
if (modalTitle) {
|
||
modalTitle.innerHTML = `
|
||
<div class="editable-content">
|
||
<span class="content-text">${recipe.title || 'Recipe Details'}</span>
|
||
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
||
</div>
|
||
<div id="recipeTitleEditor" class="content-editor">
|
||
<input type="text" class="title-input" value="${recipe.title || ''}">
|
||
</div>
|
||
`;
|
||
|
||
// Add event listener for title editing
|
||
const editIcon = modalTitle.querySelector('.edit-icon');
|
||
editIcon.addEventListener('click', () => this.showTitleEditor());
|
||
|
||
// Add key event listener for Enter key
|
||
const titleInput = modalTitle.querySelector('.title-input');
|
||
titleInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
this.saveTitleEdit();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
this.cancelTitleEdit();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Store the recipe ID for copy syntax API call
|
||
this.recipeId = recipe.id;
|
||
|
||
// Set recipe tags if they exist
|
||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||
|
||
if (tagsCompactElement) {
|
||
// Add tags container with edit functionality
|
||
tagsCompactElement.innerHTML = `
|
||
<div class="editable-content tags-content">
|
||
<div class="tags-display"></div>
|
||
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
|
||
</div>
|
||
<div id="recipeTagsEditor" class="content-editor tags-editor">
|
||
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
|
||
</div>
|
||
`;
|
||
|
||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
||
|
||
if (recipe.tags && recipe.tags.length > 0) {
|
||
// Limit displayed tags to 5, show a "+X more" button if needed
|
||
const maxVisibleTags = 5;
|
||
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
||
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
||
|
||
// Add visible tags
|
||
visibleTags.forEach(tag => {
|
||
const tagElement = document.createElement('div');
|
||
tagElement.className = 'recipe-tag-compact';
|
||
tagElement.textContent = tag;
|
||
tagsDisplay.appendChild(tagElement);
|
||
});
|
||
|
||
// Add "more" button if needed
|
||
if (remainingTags.length > 0) {
|
||
const moreButton = document.createElement('div');
|
||
moreButton.className = 'recipe-tag-more';
|
||
moreButton.textContent = `+${remainingTags.length} more`;
|
||
tagsDisplay.appendChild(moreButton);
|
||
|
||
// Add tooltip functionality
|
||
moreButton.addEventListener('mouseenter', () => {
|
||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||
});
|
||
|
||
moreButton.addEventListener('mouseleave', () => {
|
||
setTimeout(() => {
|
||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||
});
|
||
|
||
// Add all tags to tooltip
|
||
if (tagsTooltipContent) {
|
||
tagsTooltipContent.innerHTML = '';
|
||
recipe.tags.forEach(tag => {
|
||
const tooltipTag = document.createElement('div');
|
||
tooltipTag.className = 'tooltip-tag';
|
||
tooltipTag.textContent = tag;
|
||
tagsTooltipContent.appendChild(tooltipTag);
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
||
}
|
||
|
||
// Add event listeners for tags editing
|
||
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
|
||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||
|
||
// Set current tags in the input
|
||
if (recipe.tags && recipe.tags.length > 0) {
|
||
tagsInput.value = recipe.tags.join(', ');
|
||
}
|
||
|
||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||
|
||
// Add key event listener for Enter key
|
||
tagsInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
this.saveTagsEdit();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
this.cancelTagsEdit();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Set recipe image
|
||
const modalImage = document.getElementById('recipeModalImage');
|
||
if (modalImage) {
|
||
// Ensure file_url exists, fallback to file_path if needed
|
||
const imageUrl = recipe.file_url ||
|
||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||
'/loras_static/images/no-preview.png');
|
||
|
||
// Check if the file is a video (mp4)
|
||
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
|
||
|
||
// Replace the image element with appropriate media element
|
||
const mediaContainer = modalImage.parentElement;
|
||
mediaContainer.innerHTML = '';
|
||
|
||
if (isVideo) {
|
||
const videoElement = document.createElement('video');
|
||
videoElement.id = 'recipeModalVideo';
|
||
videoElement.src = imageUrl;
|
||
videoElement.controls = true;
|
||
videoElement.autoplay = false;
|
||
videoElement.loop = true;
|
||
videoElement.muted = true;
|
||
videoElement.className = 'recipe-preview-media';
|
||
videoElement.alt = recipe.title || 'Recipe Preview';
|
||
mediaContainer.appendChild(videoElement);
|
||
} else {
|
||
const imgElement = document.createElement('img');
|
||
imgElement.id = 'recipeModalImage';
|
||
imgElement.src = imageUrl;
|
||
imgElement.className = 'recipe-preview-media';
|
||
imgElement.alt = recipe.title || 'Recipe Preview';
|
||
mediaContainer.appendChild(imgElement);
|
||
}
|
||
}
|
||
|
||
// Set generation parameters
|
||
const promptElement = document.getElementById('recipePrompt');
|
||
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||
|
||
if (recipe.gen_params) {
|
||
// Set prompt
|
||
if (promptElement && recipe.gen_params.prompt) {
|
||
promptElement.textContent = recipe.gen_params.prompt;
|
||
} else if (promptElement) {
|
||
promptElement.textContent = 'No prompt information available';
|
||
}
|
||
|
||
// Set negative prompt
|
||
if (negativePromptElement && recipe.gen_params.negative_prompt) {
|
||
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
|
||
} else if (negativePromptElement) {
|
||
negativePromptElement.textContent = 'No negative prompt information available';
|
||
}
|
||
|
||
// Set other parameters
|
||
if (otherParamsElement) {
|
||
// Clear previous params
|
||
otherParamsElement.innerHTML = '';
|
||
|
||
// Add all other parameters except prompt and negative_prompt
|
||
const excludedParams = ['prompt', 'negative_prompt'];
|
||
|
||
for (const [key, value] of Object.entries(recipe.gen_params)) {
|
||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||
const paramTag = document.createElement('div');
|
||
paramTag.className = 'param-tag';
|
||
paramTag.innerHTML = `
|
||
<span class="param-name">${key}:</span>
|
||
<span class="param-value">${value}</span>
|
||
`;
|
||
otherParamsElement.appendChild(paramTag);
|
||
}
|
||
}
|
||
|
||
// If no other params, show a message
|
||
if (otherParamsElement.children.length === 0) {
|
||
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||
}
|
||
}
|
||
} else {
|
||
// No generation parameters available
|
||
if (promptElement) promptElement.textContent = 'No prompt information available';
|
||
if (negativePromptElement) promptElement.textContent = 'No negative prompt information available';
|
||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||
}
|
||
|
||
// Set LoRAs list and count
|
||
const lorasListElement = document.getElementById('recipeLorasList');
|
||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||
|
||
// Check all LoRAs status
|
||
let allLorasAvailable = true;
|
||
let missingLorasCount = 0;
|
||
let deletedLorasCount = 0;
|
||
|
||
if (recipe.loras && recipe.loras.length > 0) {
|
||
recipe.loras.forEach(lora => {
|
||
if (lora.isDeleted) {
|
||
deletedLorasCount++;
|
||
} else if (!lora.inLibrary) {
|
||
allLorasAvailable = false;
|
||
missingLorasCount++;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Set LoRAs count and status
|
||
if (lorasCountElement && recipe.loras) {
|
||
const totalCount = recipe.loras.length;
|
||
|
||
// Create status indicator based on LoRA states
|
||
let statusHTML = '';
|
||
if (totalCount > 0) {
|
||
if (allLorasAvailable && deletedLorasCount === 0) {
|
||
// All LoRAs are available
|
||
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||
} else if (missingLorasCount > 0) {
|
||
// Some LoRAs are missing (prioritize showing missing over deleted)
|
||
statusHTML = `<div class="recipe-status missing">
|
||
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
||
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
||
</div>`;
|
||
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
||
// Some LoRAs are deleted but none are missing
|
||
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
||
}
|
||
}
|
||
|
||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||
|
||
// Add event listeners for buttons and status indicators
|
||
setTimeout(() => {
|
||
// Set up click handler for View LoRAs button
|
||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||
if (viewRecipeLorasBtn) {
|
||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||
}
|
||
|
||
// Add click handler for missing LoRAs status
|
||
const missingStatus = document.querySelector('.recipe-status.missing');
|
||
if (missingStatus && missingLorasCount > 0) {
|
||
missingStatus.classList.add('clickable');
|
||
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
||
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
||
const existsLocally = lora.inLibrary;
|
||
const isDeleted = lora.isDeleted;
|
||
const localPath = lora.localPath || '';
|
||
|
||
// Create status badge based on LoRA state
|
||
let localStatus;
|
||
if (existsLocally) {
|
||
localStatus = `
|
||
<div class="local-badge">
|
||
<i class="fas fa-check"></i> In Library
|
||
<div class="local-path">${localPath}</div>
|
||
</div>`;
|
||
} else if (isDeleted) {
|
||
localStatus = `
|
||
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||
</div>`;
|
||
} else {
|
||
localStatus = `
|
||
<div class="missing-badge">
|
||
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
||
</div>`;
|
||
}
|
||
|
||
// Check if preview is a video
|
||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||
const previewMedia = isPreviewVideo ?
|
||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||
<source src="${lora.preview_url}" type="video/mp4">
|
||
</video>` :
|
||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
||
|
||
// Determine CSS class based on LoRA state
|
||
let loraItemClass = 'recipe-lora-item';
|
||
if (existsLocally) {
|
||
loraItemClass += ' exists-locally';
|
||
} else if (isDeleted) {
|
||
loraItemClass += ' is-deleted';
|
||
} else {
|
||
loraItemClass += ' missing-locally';
|
||
}
|
||
|
||
return `
|
||
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||
<div class="recipe-lora-thumbnail">
|
||
${previewMedia}
|
||
</div>
|
||
<div class="recipe-lora-content">
|
||
<div class="recipe-lora-header">
|
||
<h4>${lora.modelName}</h4>
|
||
<div class="badge-container">${localStatus}</div>
|
||
</div>
|
||
<div class="recipe-lora-info">
|
||
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||
</div>
|
||
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||
<div class="reconnect-instructions">
|
||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||
</div>
|
||
<div class="reconnect-form">
|
||
<input type="text" class="reconnect-input" placeholder="Enter LoRA name or syntax">
|
||
<div class="reconnect-actions">
|
||
<button class="reconnect-cancel-btn">Cancel</button>
|
||
<button class="reconnect-confirm-btn">Reconnect</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Add event listeners for reconnect functionality
|
||
setTimeout(() => {
|
||
this.setupReconnectButtons();
|
||
this.setupLoraItemsClickable();
|
||
}, 100);
|
||
|
||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
||
this.recipeLorasSyntax = '';
|
||
|
||
} else if (lorasListElement) {
|
||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||
this.recipeLorasSyntax = '';
|
||
}
|
||
|
||
// Show the modal
|
||
modalManager.showModal('recipeModal');
|
||
}
|
||
|
||
// Title editing methods
|
||
showTitleEditor() {
|
||
const titleContainer = document.getElementById('recipeModalTitle');
|
||
if (titleContainer) {
|
||
titleContainer.querySelector('.editable-content').classList.add('hide');
|
||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||
editor.classList.add('active');
|
||
const input = editor.querySelector('input');
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
}
|
||
|
||
saveTitleEdit() {
|
||
const titleContainer = document.getElementById('recipeModalTitle');
|
||
if (titleContainer) {
|
||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||
const input = editor.querySelector('input');
|
||
const newTitle = input.value.trim();
|
||
|
||
// Check if title changed
|
||
if (newTitle && newTitle !== this.currentRecipe.title) {
|
||
// Update title in the UI
|
||
titleContainer.querySelector('.content-text').textContent = newTitle;
|
||
|
||
// Update the recipe on the server
|
||
this.updateRecipeMetadata({ title: newTitle });
|
||
}
|
||
|
||
// Hide editor
|
||
editor.classList.remove('active');
|
||
titleContainer.querySelector('.editable-content').classList.remove('hide');
|
||
}
|
||
}
|
||
|
||
cancelTitleEdit() {
|
||
const titleContainer = document.getElementById('recipeModalTitle');
|
||
if (titleContainer) {
|
||
// Reset input value
|
||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||
const input = editor.querySelector('input');
|
||
input.value = this.currentRecipe.title || '';
|
||
|
||
// Hide editor
|
||
editor.classList.remove('active');
|
||
titleContainer.querySelector('.editable-content').classList.remove('hide');
|
||
}
|
||
}
|
||
|
||
// Tags editing methods
|
||
showTagsEditor() {
|
||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||
if (tagsContainer) {
|
||
tagsContainer.querySelector('.editable-content').classList.add('hide');
|
||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||
editor.classList.add('active');
|
||
const input = editor.querySelector('input');
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
saveTagsEdit() {
|
||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||
if (tagsContainer) {
|
||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||
const input = editor.querySelector('input');
|
||
const tagsText = input.value.trim();
|
||
|
||
// Parse tags
|
||
let newTags = [];
|
||
if (tagsText) {
|
||
newTags = tagsText.split(',')
|
||
.map(tag => tag.trim())
|
||
.filter(tag => tag.length > 0);
|
||
}
|
||
|
||
// Check if tags changed
|
||
const oldTags = this.currentRecipe.tags || [];
|
||
const tagsChanged =
|
||
newTags.length !== oldTags.length ||
|
||
newTags.some((tag, index) => tag !== oldTags[index]);
|
||
|
||
if (tagsChanged) {
|
||
// Update the recipe on the server
|
||
this.updateRecipeMetadata({ tags: newTags });
|
||
|
||
// Update tags in the UI
|
||
const tagsDisplay = tagsContainer.querySelector('.tags-display');
|
||
tagsDisplay.innerHTML = '';
|
||
|
||
if (newTags.length > 0) {
|
||
// Limit displayed tags to 5, show a "+X more" button if needed
|
||
const maxVisibleTags = 5;
|
||
const visibleTags = newTags.slice(0, maxVisibleTags);
|
||
const remainingTags = newTags.length > maxVisibleTags ? newTags.slice(maxVisibleTags) : [];
|
||
|
||
// Add visible tags
|
||
visibleTags.forEach(tag => {
|
||
const tagElement = document.createElement('div');
|
||
tagElement.className = 'recipe-tag-compact';
|
||
tagElement.textContent = tag;
|
||
tagsDisplay.appendChild(tagElement);
|
||
});
|
||
|
||
// Add "more" button if needed
|
||
if (remainingTags.length > 0) {
|
||
const moreButton = document.createElement('div');
|
||
moreButton.className = 'recipe-tag-more';
|
||
moreButton.textContent = `+${remainingTags.length} more`;
|
||
tagsDisplay.appendChild(moreButton);
|
||
|
||
// Update tooltip content
|
||
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||
if (tooltipContent) {
|
||
tooltipContent.innerHTML = '';
|
||
newTags.forEach(tag => {
|
||
const tooltipTag = document.createElement('div');
|
||
tooltipTag.className = 'tooltip-tag';
|
||
tooltipTag.textContent = tag;
|
||
tooltipContent.appendChild(tooltipTag);
|
||
});
|
||
}
|
||
|
||
// Re-add tooltip functionality
|
||
moreButton.addEventListener('mouseenter', () => {
|
||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||
});
|
||
|
||
moreButton.addEventListener('mouseleave', () => {
|
||
setTimeout(() => {
|
||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||
}
|
||
}, 300);
|
||
});
|
||
}
|
||
} else {
|
||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
||
}
|
||
|
||
// Update the current recipe object
|
||
this.currentRecipe.tags = newTags;
|
||
}
|
||
|
||
// Hide editor
|
||
editor.classList.remove('active');
|
||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
||
}
|
||
}
|
||
|
||
cancelTagsEdit() {
|
||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||
if (tagsContainer) {
|
||
// Reset input value
|
||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||
const input = editor.querySelector('input');
|
||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||
|
||
// Hide editor
|
||
editor.classList.remove('active');
|
||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
||
}
|
||
}
|
||
|
||
// Update recipe metadata on the server
|
||
async updateRecipeMetadata(updates) {
|
||
try {
|
||
const response = await fetch(`/api/recipe/${this.recipeId}/update`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(updates)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
// 显示保存成功的提示
|
||
if (updates.title) {
|
||
showToast('Recipe name updated successfully', 'success');
|
||
} else if (updates.tags) {
|
||
showToast('Recipe tags updated successfully', 'success');
|
||
} else {
|
||
showToast('Recipe updated successfully', 'success');
|
||
}
|
||
|
||
// 更新当前recipe对象的属性
|
||
Object.assign(this.currentRecipe, updates);
|
||
|
||
// 确保这个更新也传播到卡片视图
|
||
// 尝试找到可能显示这个recipe的卡片并更新它
|
||
try {
|
||
const recipeCards = document.querySelectorAll('.recipe-card');
|
||
recipeCards.forEach(card => {
|
||
if (card.dataset.recipeId === this.recipeId) {
|
||
// 更新卡片标题
|
||
if (updates.title) {
|
||
const titleElement = card.querySelector('.recipe-title');
|
||
if (titleElement) {
|
||
titleElement.textContent = updates.title;
|
||
}
|
||
}
|
||
|
||
// 更新卡片标签
|
||
if (updates.tags) {
|
||
const tagsElement = card.querySelector('.recipe-tags');
|
||
if (tagsElement) {
|
||
if (updates.tags.length > 0) {
|
||
tagsElement.innerHTML = updates.tags.map(
|
||
tag => `<div class="recipe-tag">${tag}</div>`
|
||
).join('');
|
||
} else {
|
||
tagsElement.innerHTML = '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.log("Non-critical error updating recipe cards:", err);
|
||
}
|
||
|
||
// 重要:强制刷新recipes列表,确保从服务器获取最新数据
|
||
try {
|
||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||
// 异步刷新recipes列表,不阻塞用户界面
|
||
setTimeout(() => {
|
||
window.recipeManager.loadRecipes(true);
|
||
}, 500);
|
||
}
|
||
} catch (err) {
|
||
console.log("Error refreshing recipes list:", err);
|
||
}
|
||
} else {
|
||
showToast(`Failed to update recipe: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating recipe:', error);
|
||
showToast(`Error updating recipe: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Setup copy buttons for prompts and recipe syntax
|
||
setupCopyButtons() {
|
||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||
|
||
if (copyPromptBtn) {
|
||
copyPromptBtn.addEventListener('click', () => {
|
||
const promptText = document.getElementById('recipePrompt').textContent;
|
||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||
});
|
||
}
|
||
|
||
if (copyNegativePromptBtn) {
|
||
copyNegativePromptBtn.addEventListener('click', () => {
|
||
const negativePromptText = document.getElementById('recipeNegativePrompt').textContent;
|
||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||
});
|
||
}
|
||
|
||
if (copyRecipeSyntaxBtn) {
|
||
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
||
// Use backend API to get recipe syntax
|
||
this.fetchAndCopyRecipeSyntax();
|
||
});
|
||
}
|
||
}
|
||
|
||
// Fetch recipe syntax from backend and copy to clipboard
|
||
async fetchAndCopyRecipeSyntax() {
|
||
if (!this.recipeId) {
|
||
showToast('No recipe ID available', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Fetch recipe syntax from backend
|
||
const response = await fetch(`/api/recipe/${this.recipeId}/syntax`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.syntax) {
|
||
// Use the centralized copyToClipboard utility function
|
||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||
} else {
|
||
throw new Error(data.error || 'No syntax returned from server');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching recipe syntax:', error);
|
||
showToast(`Error copying recipe syntax: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Helper method to copy text to clipboard
|
||
copyToClipboard(text, successMessage) {
|
||
copyToClipboard(text, successMessage);
|
||
}
|
||
|
||
// Add new method to handle downloading missing LoRAs
|
||
async showDownloadMissingLorasModal() {
|
||
console.log("currentRecipe", this.currentRecipe);
|
||
// Get missing LoRAs from the current recipe
|
||
const missingLoras = this.currentRecipe.loras.filter(lora => !lora.inLibrary);
|
||
console.log("missingLoras", missingLoras);
|
||
|
||
if (missingLoras.length === 0) {
|
||
showToast('No missing LoRAs to download', 'info');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
|
||
|
||
// Get version info for each missing LoRA by calling the appropriate API endpoint
|
||
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
|
||
let endpoint;
|
||
|
||
// Determine which endpoint to use based on available data
|
||
if (lora.modelVersionId) {
|
||
endpoint = `/api/civitai/model/version/${lora.modelVersionId}`;
|
||
} else if (lora.hash) {
|
||
endpoint = `/api/civitai/model/hash/${lora.hash}`;
|
||
} else {
|
||
console.error("Missing both hash and modelVersionId for lora:", lora);
|
||
return null;
|
||
}
|
||
|
||
const response = await fetch(endpoint);
|
||
const versionInfo = await response.json();
|
||
|
||
// Return original lora data combined with version info
|
||
return {
|
||
...lora,
|
||
civitaiInfo: versionInfo
|
||
};
|
||
});
|
||
|
||
// Wait for all API calls to complete
|
||
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
|
||
console.log("Loras with version info:", lorasWithVersionInfo);
|
||
|
||
// Filter out null values (failed requests)
|
||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||
|
||
if (validLoras.length === 0) {
|
||
showToast('Failed to get information for missing LoRAs', 'error');
|
||
return;
|
||
}
|
||
|
||
// Close the recipe modal first
|
||
modalManager.closeModal('recipeModal');
|
||
|
||
// Prepare data for import manager using the retrieved information
|
||
const recipeData = {
|
||
loras: validLoras.map(lora => {
|
||
const civitaiInfo = lora.civitaiInfo;
|
||
const modelFile = civitaiInfo.files ?
|
||
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
||
|
||
return {
|
||
// Basic lora info
|
||
name: civitaiInfo.model?.name || lora.name,
|
||
version: civitaiInfo.name || '',
|
||
strength: lora.strength || 1.0,
|
||
|
||
// Model identifiers
|
||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
||
|
||
// Metadata
|
||
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
||
baseModel: civitaiInfo.baseModel || '',
|
||
downloadUrl: civitaiInfo.downloadUrl || '',
|
||
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
|
||
file_name: modelFile ? modelFile.name.split('.')[0] : '',
|
||
|
||
// Status flags
|
||
existsLocally: false,
|
||
isDeleted: civitaiInfo.error === "Model not found",
|
||
isEarlyAccess: !!civitaiInfo.earlyAccessEndsAt,
|
||
earlyAccessEndsAt: civitaiInfo.earlyAccessEndsAt || ''
|
||
};
|
||
})
|
||
};
|
||
|
||
console.log("recipeData for import:", recipeData);
|
||
|
||
// Call ImportManager's download missing LoRAs method
|
||
window.importManager.downloadMissingLoras(recipeData, this.currentRecipe.id);
|
||
} catch (error) {
|
||
console.error("Error downloading missing LoRAs:", error);
|
||
showToast('Error preparing LoRAs for download', 'error');
|
||
} finally {
|
||
state.loadingManager.hide();
|
||
}
|
||
}
|
||
|
||
// New methods for reconnecting LoRAs
|
||
setupReconnectButtons() {
|
||
// Add event listeners to all deleted badges
|
||
const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable');
|
||
deletedBadges.forEach(badge => {
|
||
badge.addEventListener('mouseenter', () => {
|
||
badge.querySelector('.badge-text').innerHTML = 'Reconnect';
|
||
});
|
||
|
||
badge.addEventListener('mouseleave', () => {
|
||
badge.querySelector('.badge-text').innerHTML = '<i class="fas fa-trash-alt"></i> Deleted';
|
||
});
|
||
|
||
badge.addEventListener('click', (e) => {
|
||
const loraIndex = badge.getAttribute('data-lora-index');
|
||
this.showReconnectInput(loraIndex);
|
||
});
|
||
});
|
||
|
||
// Add event listeners to reconnect cancel buttons
|
||
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
|
||
cancelButtons.forEach(button => {
|
||
button.addEventListener('click', (e) => {
|
||
const container = button.closest('.lora-reconnect-container');
|
||
this.hideReconnectInput(container);
|
||
});
|
||
});
|
||
|
||
// Add event listeners to reconnect confirm buttons
|
||
const confirmButtons = document.querySelectorAll('.reconnect-confirm-btn');
|
||
confirmButtons.forEach(button => {
|
||
button.addEventListener('click', (e) => {
|
||
const container = button.closest('.lora-reconnect-container');
|
||
const input = container.querySelector('.reconnect-input');
|
||
const loraIndex = container.getAttribute('data-lora-index');
|
||
this.reconnectLora(loraIndex, input.value);
|
||
});
|
||
});
|
||
|
||
// Add keydown handlers to reconnect inputs
|
||
const reconnectInputs = document.querySelectorAll('.reconnect-input');
|
||
reconnectInputs.forEach(input => {
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const container = input.closest('.lora-reconnect-container');
|
||
const loraIndex = container.getAttribute('data-lora-index');
|
||
this.reconnectLora(loraIndex, input.value);
|
||
} else if (e.key === 'Escape') {
|
||
const container = input.closest('.lora-reconnect-container');
|
||
this.hideReconnectInput(container);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
showReconnectInput(loraIndex) {
|
||
// Hide any currently active reconnect containers
|
||
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
|
||
active.classList.remove('active');
|
||
});
|
||
|
||
// Show the reconnect container for this lora
|
||
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||
if (container) {
|
||
container.classList.add('active');
|
||
const input = container.querySelector('.reconnect-input');
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
hideReconnectInput(container) {
|
||
if (container && container.classList.contains('active')) {
|
||
container.classList.remove('active');
|
||
const input = container.querySelector('.reconnect-input');
|
||
if (input) input.value = '';
|
||
}
|
||
}
|
||
|
||
async reconnectLora(loraIndex, inputValue) {
|
||
if (!inputValue || !inputValue.trim()) {
|
||
showToast('Please enter a LoRA name or syntax', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Parse input value to extract file_name
|
||
let loraSyntaxMatch = inputValue.match(/<lora:([^:>]+)(?::[^>]+)?>/);
|
||
let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim();
|
||
|
||
// Remove .safetensors extension if present
|
||
fileName = fileName.replace(/\.safetensors$/, '');
|
||
|
||
// Get the deleted lora data
|
||
const deletedLora = this.currentRecipe.loras[loraIndex];
|
||
if (!deletedLora) {
|
||
showToast('Error: Could not find the LoRA in the recipe', 'error');
|
||
return;
|
||
}
|
||
|
||
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
|
||
|
||
// Call API to reconnect the LoRA
|
||
const response = await fetch('/api/recipe/lora/reconnect', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
recipe_id: this.recipeId,
|
||
lora_data: deletedLora,
|
||
target_name: fileName
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// Hide the reconnect input
|
||
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||
this.hideReconnectInput(container);
|
||
|
||
// Update the current recipe with the updated lora data
|
||
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||
|
||
// Show success message
|
||
showToast('LoRA reconnected successfully', 'success');
|
||
|
||
// Refresh modal to show updated content
|
||
setTimeout(() => {
|
||
this.showRecipeDetails(this.currentRecipe);
|
||
}, 500);
|
||
|
||
// Refresh recipes list
|
||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||
setTimeout(() => {
|
||
window.recipeManager.loadRecipes(true);
|
||
}, 1000);
|
||
}
|
||
} else {
|
||
showToast(`Error: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error reconnecting LoRA:', error);
|
||
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
|
||
} finally {
|
||
state.loadingManager.hide();
|
||
}
|
||
}
|
||
|
||
// New method to navigate to the LoRAs page
|
||
navigateToLorasPage(specificLoraIndex = null) {
|
||
// Close the current modal
|
||
modalManager.closeModal('recipeModal');
|
||
|
||
// Clear any previous filters first
|
||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
||
removeSessionItem('filterRecipeName');
|
||
removeSessionItem('viewLoraDetail');
|
||
|
||
if (specificLoraIndex !== null) {
|
||
// If a specific LoRA index is provided, navigate to view just that one LoRA
|
||
const lora = this.currentRecipe.loras[specificLoraIndex];
|
||
if (lora && lora.hash) {
|
||
// Set session storage to open the LoRA modal directly
|
||
setSessionItem('recipe_to_lora_filterLoraHash', lora.hash.toLowerCase());
|
||
setSessionItem('viewLoraDetail', 'true');
|
||
setSessionItem('filterRecipeName', this.currentRecipe.title);
|
||
}
|
||
} else {
|
||
// If no specific LoRA index is provided, show all LoRAs from this recipe
|
||
// Collect all hashes from the recipe's LoRAs
|
||
const loraHashes = this.currentRecipe.loras
|
||
.filter(lora => lora.hash)
|
||
.map(lora => lora.hash.toLowerCase());
|
||
|
||
if (loraHashes.length > 0) {
|
||
// Store the LoRA hashes and recipe name in sessionStorage
|
||
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
||
setSessionItem('filterRecipeName', this.currentRecipe.title);
|
||
}
|
||
}
|
||
|
||
// Navigate to the LoRAs page
|
||
window.location.href = '/loras';
|
||
}
|
||
|
||
// New method to make LoRA items clickable
|
||
setupLoraItemsClickable() {
|
||
const loraItems = document.querySelectorAll('.recipe-lora-item');
|
||
loraItems.forEach(item => {
|
||
// Get the lora index from the data attribute
|
||
const loraIndex = parseInt(item.dataset.loraIndex);
|
||
|
||
item.addEventListener('click', (e) => {
|
||
// If the click is on the reconnect container or badge, don't navigate
|
||
if (e.target.closest('.lora-reconnect-container') ||
|
||
e.target.closest('.deleted-badge') ||
|
||
e.target.closest('.reconnect-tooltip')) {
|
||
return;
|
||
}
|
||
|
||
// Navigate to the LoRAs page with the specific LoRA index
|
||
this.navigateToLorasPage(loraIndex);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
export { RecipeModal }; |