// Recipe Modal Component import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js'; import { isModelWeightFile } from '../utils/modelFileTypes.js'; import { buildCivitaiUrl } from '../utils/civitaiUtils.js'; import { translate } from '../utils/i18nHelpers.js'; import { state } from '../state/index.js'; import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow, extractRecipeId } from '../api/recipeApi.js'; import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; import { openMediaViewer } from './shared/MediaViewer.js'; import { showRecipeDeleteConfirmation } from './RecipeCard.js'; import { renderCompactTags, setupTagTooltip } from './shared/utils.js'; import { setupTagEditMode } from './shared/ModelTags.js'; const ALLOWED_GEN_PARAM_KEYS = new Set([ 'prompt', 'negative_prompt', 'steps', 'sampler', 'cfg_scale', 'seed', 'size', 'clip_skip', 'denoising_strength', ]); const GEN_PARAM_NORMALIZATION = { cfg: 'cfg_scale', cfgScale: 'cfg_scale', clipSkip: 'clip_skip', negativePrompt: 'negative_prompt', Sampler: 'sampler', sampler_name: 'sampler', scheduler: 'sampler', Steps: 'steps', Seed: 'seed', Size: 'size', Prompt: 'prompt', 'Negative prompt': 'negative_prompt', 'Cfg scale': 'cfg_scale', 'Clip skip': 'clip_skip', 'Denoising strength': 'denoising_strength', }; const PARAM_DISPLAY_NAMES = { steps: 'Steps', sampler: 'Sampler', cfg_scale: 'CFG', seed: 'Seed', size: 'Size', clip_skip: 'Clip Skip', denoising_strength: 'Denoising Strength', }; function escapeHtml(value) { if (value == null) { return ''; } return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } class RecipeModal { constructor() { this.promptEditorState = {}; this.recipeHydrationRequestId = 0; this.navigationKeyHandler = null; this.navigationInProgress = false; this.resetLocalEditState(); this.init(); } createLocalEditState() { return { title: { commitVersion: 0, isDirty: false }, tags: { commitVersion: 0, isDirty: false }, prompt: { commitVersion: 0, isDirty: false }, negative_prompt: { commitVersion: 0, isDirty: false }, source_path: { commitVersion: 0, isDirty: false }, }; } resetLocalEditState() { this.localEditState = this.createLocalEditState(); this.sourceUrlEditState = this.localEditState.source_path; } getLocalEditState(field) { if (!this.localEditState[field]) { this.localEditState[field] = { commitVersion: 0, isDirty: false }; } return this.localEditState[field]; } markFieldDirty(field) { this.getLocalEditState(field).isDirty = true; } clearFieldDirty(field) { this.getLocalEditState(field).isDirty = false; } commitField(field) { const fieldState = this.getLocalEditState(field); fieldState.isDirty = false; fieldState.commitVersion += 1; } captureLocalEditVersions() { return Object.fromEntries( Object.entries(this.localEditState).map(([field, state]) => [ field, state.commitVersion, ]) ); } shouldPreserveField(field, requestVersions) { const fieldState = this.getLocalEditState(field); const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion; return fieldState.isDirty || fieldState.commitVersion !== requestVersion; } hasFieldCommittedSinceRequest(field, requestVersions) { const fieldState = this.getLocalEditState(field); const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion; return fieldState.commitVersion !== requestVersion; } init() { this.setupCopyButtons(); this.setupStripLoraToggle(); this.setupPromptEditors(); this.setupNavigationControls(); this.setupDeleteControl(); // Set up document click handler to close edit fields document.addEventListener('click', (event) => { const recipeModal = document.getElementById('recipeModal'); if (recipeModal && recipeModal.style.display !== 'none') { const mediaEl = event.target.closest('.recipe-preview-media'); if (mediaEl && mediaEl.tagName) { event.stopPropagation(); const isVideo = mediaEl.tagName === 'VIDEO'; const url = mediaEl.src || mediaEl.currentSrc; if (url) { openMediaViewer(url, { type: isVideo ? 'video' : 'image', title: document.getElementById('recipeModalTitle')?.textContent || '' }); } return; } } // 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 reconnect input const reconnectContainers = document.querySelectorAll('.lora-reconnect-container'); reconnectContainers.forEach(container => { if (container.classList.contains('active') && !container.contains(event.target) && !event.target.closest('.lora-reconnect')) { this.hideReconnectInput(container); } }); }); } setupNavigationControls() { const prevBtn = document.getElementById('recipeNavPrevBtn'); const nextBtn = document.getElementById('recipeNavNextBtn'); if (prevBtn) { prevBtn.addEventListener('click', () => this.handleDirectionalNavigation('prev')); } if (nextBtn) { nextBtn.addEventListener('click', () => this.handleDirectionalNavigation('next')); } this.updateNavigationControls(); } setupDeleteControl() { const deleteBtn = document.getElementById('deleteRecipeBtn'); if (deleteBtn) { deleteBtn.addEventListener('click', () => this.handleDeleteRecipe()); } } handleDeleteRecipe() { if (!this.currentRecipe) return; showRecipeDeleteConfirmation(this.currentRecipe); } shouldIgnoreNavigationKey(event) { const target = event.target; if (!target) return false; const tagName = target.tagName ? target.tagName.toLowerCase() : ''; return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName); } updateNavigationControls() { const modalElement = document.getElementById('recipeModal'); if (!modalElement) return; const prevBtn = modalElement.querySelector('#recipeNavPrevBtn'); const nextBtn = modalElement.querySelector('#recipeNavNextBtn'); if (!prevBtn || !nextBtn) return; const scroller = state.virtualScroller; if (!scroller || typeof scroller.getNavigationState !== 'function') { prevBtn.disabled = true; nextBtn.disabled = true; return; } const { hasPrev, hasNext } = scroller.getNavigationState(this.listFilePath || this.filePath || ''); prevBtn.disabled = this.navigationInProgress || !hasPrev; nextBtn.disabled = this.navigationInProgress || !hasNext; } cleanupNavigationShortcuts() { if (this.navigationKeyHandler) { document.removeEventListener('keydown', this.navigationKeyHandler); this.navigationKeyHandler = null; } this.navigationInProgress = false; } setupNavigationShortcuts() { const modalElement = document.getElementById('recipeModal'); if (!modalElement) return; this.cleanupNavigationShortcuts(); this.navigationKeyHandler = (event) => { if (this.shouldIgnoreNavigationKey(event)) return; if (event.key === 'ArrowLeft') { event.preventDefault(); this.handleDirectionalNavigation('prev'); } else if (event.key === 'ArrowRight') { event.preventDefault(); this.handleDirectionalNavigation('next'); } else if (event.key === 'Delete') { event.preventDefault(); this.handleDeleteRecipe(); } }; document.addEventListener('keydown', this.navigationKeyHandler); } async handleDirectionalNavigation(direction) { if (this.navigationInProgress) return; const scroller = state.virtualScroller; const filePath = this.listFilePath || this.filePath || ''; if (!filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') { return; } this.navigationInProgress = true; this.updateNavigationControls(); try { const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction); if (!adjacent || !adjacent.item) { const toastKey = direction === 'prev' ? 'toast.recipes.noPreviousRecipe' : 'toast.recipes.noNextRecipe'; const toastFallback = direction === 'prev' ? 'No previous recipe available' : 'No next recipe available'; showToast(toastKey, {}, 'info', toastFallback); return; } this.showRecipeDetails(adjacent.item); } finally { this.navigationInProgress = false; this.updateNavigationControls(); } } showRecipeDetails(recipe) { const hydratedRecipe = recipe || {}; this.resetLocalEditState(); // Store the full recipe for editing this.currentRecipe = hydratedRecipe; this.resetPromptEditors(); // Set modal title with edit icon const modalTitle = document.getElementById('recipeModalTitle'); if (modalTitle) { modalTitle.innerHTML = `
${hydratedRecipe.title || 'Recipe Details'}
`; // 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 = hydratedRecipe.id; this.filePath = hydratedRecipe.file_path; this.listFilePath = hydratedRecipe.file_path; // Render tags using shared utility const tagsContainer = document.getElementById('recipeTagsContainer'); if (tagsContainer) { this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []); } // Set recipe image const mediaContainer = document.getElementById('recipePreviewContainer'); if (mediaContainer) { this.syncPreviewMedia(hydratedRecipe); mediaContainer.querySelector('.source-url-container')?.remove(); mediaContainer.querySelector('.source-url-editor')?.remove(); // Add source URL container if the recipe has a source_path const sourceUrlContainer = document.createElement('div'); sourceUrlContainer.className = 'source-url-container'; const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0; const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : ''; const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://')); sourceUrlContainer.innerHTML = `
${hasSourceUrl ? sourceUrl : 'No source URL' }
`; // Add source URL editor const sourceUrlEditor = document.createElement('div'); sourceUrlEditor.className = 'source-url-editor'; sourceUrlEditor.innerHTML = `
`; // Append both containers to the media container mediaContainer.appendChild(sourceUrlContainer); mediaContainer.appendChild(sourceUrlEditor); // Delay binding slightly so modal layout is stable, but skip if this render was torn down. const sourceUrlContainerRef = sourceUrlContainer; const sourceUrlEditorRef = sourceUrlEditor; setTimeout(() => { if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) { return; } this.setupSourceUrlHandlers(); }, 50); } this.syncGenerationParams(hydratedRecipe.gen_params); this.syncResourcesSection(hydratedRecipe); this.syncHeaderActions(); // Show the modal modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts()); this.updateNavigationControls(); this.setupNavigationShortcuts(); if (this.recipeId) { // Fire-and-forget: record this open for the "Recently Opened" // sort. Tracking must never disturb the modal, so failures are // swallowed. fetch(`/api/lm/recipe/${encodeURIComponent(this.recipeId)}/opened`, { method: 'POST', keepalive: true, }).catch(() => {}); const hydrationRequestId = ++this.recipeHydrationRequestId; const requestEditVersions = this.captureLocalEditVersions(); this.hydrateRecipeDetails( this.recipeId, hydrationRequestId, requestEditVersions ); } } async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) { try { const fullRecipe = await fetchRecipeDetails(recipeId); if (requestId !== this.recipeHydrationRequestId || !fullRecipe) { return; } const nextRecipe = { ...this.currentRecipe }; if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) { nextRecipe.title = fullRecipe.title; } if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) { nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags; } if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) { nextRecipe.source_path = fullRecipe.source_path || ''; } const previousFilePath = nextRecipe.file_path; if (fullRecipe.file_path !== undefined) { nextRecipe.file_path = fullRecipe.file_path; } if (fullRecipe.file_url !== undefined) { nextRecipe.file_url = fullRecipe.file_url; } if (fullRecipe.preview_url !== undefined) { nextRecipe.preview_url = fullRecipe.preview_url; } if ( fullRecipe.file_path !== undefined && fullRecipe.file_path !== previousFilePath && fullRecipe.file_url === undefined && fullRecipe.preview_url === undefined ) { delete nextRecipe.file_url; delete nextRecipe.preview_url; } if (fullRecipe.gen_params !== undefined) { const previousGenParams = nextRecipe.gen_params || {}; const incomingGenParams = { ...(fullRecipe.gen_params || {}) }; for (const [key, value] of Object.entries(previousGenParams)) { if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) { incomingGenParams[key] = value; } } nextRecipe.gen_params = incomingGenParams; } else { const previousGenParams = nextRecipe.gen_params || {}; const preservedGenParams = {}; for (const [key, value] of Object.entries(previousGenParams)) { if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) { preservedGenParams[key] = value; } } nextRecipe.gen_params = preservedGenParams; } if (fullRecipe.has_workflow !== undefined) { nextRecipe.has_workflow = fullRecipe.has_workflow; } if (fullRecipe.checkpoint !== undefined) { nextRecipe.checkpoint = fullRecipe.checkpoint; } else { delete nextRecipe.checkpoint; } if (fullRecipe.loras !== undefined) { nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras; } else { delete nextRecipe.loras; } this.currentRecipe = nextRecipe; this.filePath = this.currentRecipe.file_path || this.filePath; this.syncHydratedRecipeFields(requestEditVersions); } catch (error) { // Keep the cached recipe visible if hydration fails. console.warn('Failed to hydrate recipe details:', error); } } syncHydratedRecipeFields(requestEditVersions = {}) { this.syncPreviewMedia(this.currentRecipe); if (!this.shouldPreserveField('title', requestEditVersions)) { this.syncTitleDisplay(this.currentRecipe?.title || ''); } if (!this.shouldPreserveField('tags', requestEditVersions)) { this.syncTagsDisplay(this.currentRecipe?.tags || []); } if (!this.shouldPreserveField('prompt', requestEditVersions)) { this.syncPromptField( 'prompt', this.currentRecipe?.gen_params?.prompt || '', 'No prompt information available' ); } if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) { this.syncPromptField( 'negative_prompt', this.currentRecipe?.gen_params?.negative_prompt || '', 'No negative prompt information available' ); } this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true }); this.syncResourcesSection(this.currentRecipe); if (!this.shouldPreserveField('source_path', requestEditVersions)) { this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true }); } else { this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); } this.syncHeaderActions(); } getPreviewMediaUrl(recipe = {}) { return recipe.file_url || recipe.preview_url || (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : '/loras_static/images/no-preview.png'); } syncPreviewMedia(recipe = {}) { const mediaContainer = document.getElementById('recipePreviewContainer'); if (!mediaContainer) { return; } const previewUrl = this.getPreviewMediaUrl(recipe); const isVideo = previewUrl.toLowerCase().endsWith('.mp4'); const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage'; let previewElement = mediaContainer.querySelector(`#${expectedElementId}`); const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media'); if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) { if (existingPreviewElement?.tagName === 'VIDEO') { const existingVideo = existingPreviewElement; existingVideo.pause(); existingVideo.currentTime = 0; } existingPreviewElement?.remove(); previewElement = document.createElement(isVideo ? 'video' : 'img'); previewElement.id = expectedElementId; previewElement.className = 'recipe-preview-media'; mediaContainer.prepend(previewElement); } previewElement.src = previewUrl; previewElement.alt = recipe.title || 'Recipe Preview'; if (isVideo) { previewElement.controls = true; previewElement.autoplay = false; previewElement.loop = true; previewElement.muted = true; } } getMetadataUpdateOptions() { return this.listFilePath ? { listFilePath: this.listFilePath } : {}; } syncTitleDisplay(title) { const titleContainer = document.getElementById('recipeModalTitle'); if (!titleContainer) { return; } const contentText = titleContainer.querySelector('.content-text'); if (contentText) { contentText.textContent = title || 'Recipe Details'; } const titleInput = titleContainer.querySelector('.title-input'); if (titleInput) { titleInput.value = title || ''; } } syncHeaderActions() { const actionsContainer = document.getElementById('recipeHeaderActions'); if (!actionsContainer) { return; } actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove()); // Keep the delete button as the last (rightmost) header action; // insertBefore with null falls back to appendChild if it is missing. const deleteBtn = document.getElementById('deleteRecipeBtn'); if (this.currentRecipe?.has_workflow === true) { const workflowBtn = document.createElement('button'); workflowBtn.className = 'recipe-source-url-btn'; workflowBtn.id = 'sendWorkflowBtn'; workflowBtn.title = 'Send Workflow to ComfyUI'; workflowBtn.innerHTML = ' Send Workflow to ComfyUI'; workflowBtn.addEventListener('click', () => { this.sendWorkflowToComfyUI(); }); actionsContainer.insertBefore(workflowBtn, deleteBtn); } const sourcePath = this.currentRecipe?.source_path || ''; const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://'); if (isValidUrl) { const btn = document.createElement('button'); btn.className = 'recipe-source-url-btn'; btn.title = sourcePath; btn.innerHTML = ' Open Source URL'; btn.addEventListener('click', () => { window.open(sourcePath, '_blank'); }); actionsContainer.insertBefore(btn, deleteBtn); } } async sendWorkflowToComfyUI() { if (!this.recipeId) { return; } try { const result = await sendRecipeWorkflow(this.recipeId); if (result?.success) { showToast('toast.recipes.workflowSent', {}, 'success', 'Workflow sent to ComfyUI'); return; } const error = result?.error || ''; if (error === 'Standalone Mode Active') { showToast('toast.general.cannotInteractStandalone', {}, 'warning', 'Cannot interact with ComfyUI in standalone mode'); } else if (error === 'no_workflow') { showToast('toast.recipes.workflowNoWorkflow', {}, 'warning', 'No embedded workflow found in this recipe'); } else { showToast('toast.recipes.workflowSendFailed', { error }, 'error', `Failed to send workflow to ComfyUI: ${error}`); } } catch (error) { console.error('Failed to send workflow to ComfyUI:', error); showToast('toast.recipes.workflowSendFailed', { error: error.message }, 'error', `Failed to send workflow to ComfyUI: ${error.message}`); } } syncTagsDisplay(tags) { const container = document.getElementById('recipeTagsContainer'); if (!container) return; this.updateTagsDisplay(container, tags || []); } // Re-render tags display using shared utility, wire edit mode with ModelTags updateTagsDisplay(container, tags) { const filePath = this.filePath || ''; container.innerHTML = renderCompactTags(tags, filePath); // Setup tooltip for all tags setupTagTooltip(container); // Wire edit button using shared tag editing (no suggestions for recipes) setupTagEditMode(null, { container: container, showSuggestions: false, normalizeTag: false, saveHandler: async (filePath, tags) => { await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions()); }, onSaved: (tags) => { this.currentRecipe.tags = tags; this.commitField('tags'); const c = document.getElementById('recipeTagsContainer'); if (c) this.updateTagsDisplay(c, tags); }, }); } syncPromptField(field, value, placeholder) { const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt'; const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor'; const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput'; this.renderPromptContent(document.getElementById(contentId), value, placeholder); const input = document.getElementById(inputId); if (input) { input.value = value || ''; } } syncGenerationParams(genParams, options = {}) { const promptElement = document.getElementById('recipePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt'); const otherParamsElement = document.getElementById('recipeOtherParams'); const promptInput = document.getElementById('recipePromptInput'); const negativePromptInput = document.getElementById('recipeNegativePromptInput'); const promptFieldsOnly = options.promptFieldsOnly === true; const sanitizedGenParams = this.sanitizeGenParams(genParams); if (sanitizedGenParams) { if (!promptFieldsOnly) { this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available'); this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available'); if (promptInput) { promptInput.value = sanitizedGenParams.prompt || ''; } if (negativePromptInput) { negativePromptInput.value = sanitizedGenParams.negative_prompt || ''; } } if (otherParamsElement) { otherParamsElement.innerHTML = ''; const excludedParams = ['prompt', 'negative_prompt']; for (const [key, value] of Object.entries(sanitizedGenParams)) { if (!excludedParams.includes(key) && value !== undefined && value !== null) { const displayName = PARAM_DISPLAY_NAMES[key] || key; const paramTag = document.createElement('div'); paramTag.className = 'param-tag'; paramTag.innerHTML = ` ${displayName}: ${value} `; otherParamsElement.appendChild(paramTag); } } if (otherParamsElement.children.length === 0) { otherParamsElement.innerHTML = '
No additional parameters available
'; } } return; } if (!promptFieldsOnly) { this.renderPromptContent(promptElement, '', 'No prompt information available'); this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available'); if (promptInput) promptInput.value = ''; if (negativePromptInput) negativePromptInput.value = ''; } if (otherParamsElement) { otherParamsElement.innerHTML = '
No parameters available
'; } } sanitizeGenParams(genParams) { if (!genParams || typeof genParams !== 'object') { return null; } const sanitized = {}; for (const [key, value] of Object.entries(genParams)) { if (value === undefined || value === null || value === '') { continue; } if (!ALLOWED_GEN_PARAM_KEYS.has(key)) { continue; } sanitized[key] = value; } for (const [key, value] of Object.entries(genParams)) { if (value === undefined || value === null || value === '') { continue; } const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key; if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) { continue; } if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') { sanitized[normalizedKey] = value; } } return sanitized; } syncResourcesSection(recipe = {}) { const checkpointContainer = document.getElementById('recipeCheckpoint'); const resourceDivider = document.getElementById('recipeResourceDivider'); const lorasListElement = document.getElementById('recipeLorasList'); const lorasCountElement = document.getElementById('recipeLorasCount'); const loras = Array.isArray(recipe.loras) ? recipe.loras : []; if (checkpointContainer) { checkpointContainer.innerHTML = ''; if (recipe.checkpoint && typeof recipe.checkpoint === 'object') { checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint); this.setupCheckpointActions(checkpointContainer, recipe.checkpoint); this.setupCheckpointNavigation(checkpointContainer, recipe.checkpoint); } } let allLorasAvailable = true; let missingLorasCount = 0; let deletedLorasCount = 0; loras.forEach(lora => { if (lora.isDeleted) { deletedLorasCount++; } else if (!lora.inLibrary) { allLorasAvailable = false; missingLorasCount++; } }); if (lorasCountElement) { const totalCount = loras.length; let statusHTML = ''; if (totalCount > 0) { if (allLorasAvailable && deletedLorasCount === 0) { statusHTML = `
${translate('recipes.status.ready', {}, 'Ready to use')}
`; } else if (missingLorasCount > 0) { // Rendered as a real button so the affordance is visible without // hover and the control is keyboard/screen-reader accessible. // Leading download icon: the red tint + "missing" text already // encode the state, so the icon's job is to hint the action. statusHTML = ``; } else if (deletedLorasCount > 0 && missingLorasCount === 0) { statusHTML = `
${translate('recipes.status.deletedCount', { count: deletedLorasCount }, `${deletedLorasCount} deleted`)}
`; } } lorasCountElement.innerHTML = ` ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`; const missingStatus = lorasCountElement.querySelector('.recipe-status.missing'); if (missingStatus && missingLorasCount > 0) { missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal()); } setTimeout(() => { const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); if (viewRecipeLorasBtn) { viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); } }, 100); } if (lorasListElement && loras.length > 0) { lorasListElement.innerHTML = loras.map(lora => { const existsLocally = lora.inLibrary; const isDeleted = lora.isDeleted; const loraIndex = loras.indexOf(lora); // Status badges are pure indicators (consistent with the // versions-tab pattern): they never carry click behavior, // only a tooltip. Remediation lives in the action row below. let statusBadge; if (existsLocally) { statusBadge = `
${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
`; } else if (isDeleted) { statusBadge = `
${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
`; } else { statusBadge = `
${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
`; } const actionsRow = this.renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }); // The Civitai link belongs to the model name (it answers // "what is this"), so it sits inline in the title — the same // pattern the versions tab uses — never in the action row. // Skipped for deleted models: their source page is gone. const titleLink = isDeleted ? '' : this.renderCivitaiLink(this.getResourceCivitaiUrl(lora)); const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4'); const previewMedia = isPreviewVideo ? `` : `LoRA preview`; let loraItemClass = 'recipe-lora-item'; if (existsLocally) { loraItemClass += ' exists-locally'; } else if (isDeleted) { loraItemClass += ' is-deleted'; } else { loraItemClass += ' missing-locally'; } // Only in-library items are row-navigable (they open the local // LoRA detail); make that affordance keyboard-accessible. const rowA11yAttributes = existsLocally ? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openLoraDetails', { name: lora.modelName }, `View ${lora.modelName} in the LoRA library`))}"` : ''; return `
${previewMedia}

${lora.modelName}

${titleLink}
${statusBadge}
${lora.modelVersionName ? `
${lora.modelVersionName}
` : ''}
Weight: ${lora.strength || 1.0}
${lora.baseModel ? `
${lora.baseModel}
` : ''}
${actionsRow} ${isDeleted ? `

Enter LoRA Syntax or Name to Reconnect:

Example: <lora:Boris_Vallejo_BV_flux_D:1> or just Boris_Vallejo_BV_flux_D
` : ''}
`; }).join(''); setTimeout(() => { this.setupReconnectButtons(); this.setupLoraItemActions(); this.setupLoraItemsClickable(); }, 100); this.recipeLorasSyntax = ''; } else if (lorasListElement) { lorasListElement.innerHTML = '
No LoRAs associated with this recipe
'; this.recipeLorasSyntax = ''; } if (resourceDivider) { const hasCheckpoint = checkpointContainer && checkpointContainer.querySelector('.recipe-lora-item'); const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item'); resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none'; } } updateSourceUrlDisplay(sourcePath, options = {}) { const sourceUrlContainer = document.querySelector('.source-url-container'); const sourceUrlEditor = document.querySelector('.source-url-editor'); if (!sourceUrlContainer || !sourceUrlEditor) { return; } const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); if (!sourceUrlText || !sourceUrlInput) { return; } const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : ''; const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://'); sourceUrlText.textContent = normalizedSourcePath || 'No source URL'; sourceUrlText.title = normalizedSourcePath ? (isValidUrl ? 'Click to open source URL' : 'No valid URL') : 'No valid URL'; if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) { sourceUrlInput.value = normalizedSourcePath; } } // 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.oninput = () => this.markFieldDirty('title'); 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 updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions()) .then(data => { // Show success toast showToast('toast.recipes.nameUpdated', {}, 'success'); // Update the current recipe object this.currentRecipe.title = newTitle; this.commitField('title'); }) .catch(error => { // Error is handled in the API function // Reset the UI if needed titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || ''; this.clearFieldDirty('title'); }); } else { this.clearFieldDirty('title'); } // 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 || ''; this.clearFieldDirty('title'); // Hide editor editor.classList.remove('active'); titleContainer.querySelector('.editable-content').classList.remove('hide'); } } setupPromptEditors() { const promptConfigs = [ { editButtonId: 'editPromptBtn', contentId: 'recipePrompt', editorId: 'recipePromptEditor', inputId: 'recipePromptInput', field: 'prompt', placeholder: 'No prompt information available', successKey: 'toast.recipes.promptUpdated', successFallback: 'Prompt updated successfully', }, { editButtonId: 'editNegativePromptBtn', contentId: 'recipeNegativePrompt', editorId: 'recipeNegativePromptEditor', inputId: 'recipeNegativePromptInput', field: 'negative_prompt', placeholder: 'No negative prompt information available', successKey: 'toast.recipes.negativePromptUpdated', successFallback: 'Negative prompt updated successfully', } ]; promptConfigs.forEach((config) => { const editButton = document.getElementById(config.editButtonId); const input = document.getElementById(config.inputId); if (editButton) { editButton.addEventListener('click', () => this.showPromptEditor(config)); } if (input) { input.addEventListener('input', () => this.markFieldDirty(config.field)); input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.cancelPromptEdit(config); return; } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); event.stopPropagation(); this.promptEditorState[config.field] = { ...(this.promptEditorState[config.field] || {}), skipBlurSave: true, }; this.savePromptEdit(config); } }); input.addEventListener('blur', () => { const promptState = this.promptEditorState[config.field] || {}; if (promptState.skipBlurSave) { this.promptEditorState[config.field] = { ...promptState, skipBlurSave: false, }; return; } this.savePromptEdit(config); }); } }); } renderPromptContent(element, value, placeholder) { if (!element) { return; } const text = value || ''; if (text) { element.textContent = text; element.classList.remove('is-placeholder'); } else { element.textContent = placeholder; element.classList.add('is-placeholder'); } } resetPromptEditors() { this.hidePromptEditor({ contentId: 'recipePrompt', editorId: 'recipePromptEditor' }); this.hidePromptEditor({ contentId: 'recipeNegativePrompt', editorId: 'recipeNegativePromptEditor' }); } showPromptEditor(config) { const content = document.getElementById(config.contentId); const editor = document.getElementById(config.editorId); const input = document.getElementById(config.inputId); if (!content || !editor || !input) { return; } const currentValue = this.currentRecipe?.gen_params?.[config.field] || ''; input.value = currentValue; this.promptEditorState[config.field] = { initialValue: currentValue, skipBlurSave: false, isSaving: false, }; content.classList.add('hide'); editor.classList.add('active'); input.focus(); input.setSelectionRange(input.value.length, input.value.length); } async savePromptEdit(config) { const content = document.getElementById(config.contentId); const editor = document.getElementById(config.editorId); const input = document.getElementById(config.inputId); if (!content || !editor || !input || !this.currentRecipe) { return; } const promptState = this.promptEditorState[config.field] || {}; if (promptState.isSaving) { return; } const currentGenParams = this.currentRecipe.gen_params || {}; const nextValue = input.value.trim() === '' ? '' : input.value; const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || ''; if (nextValue === currentValue) { this.clearFieldDirty(config.field); this.hidePromptEditor(config); return; } const nextGenParams = { ...currentGenParams, [config.field]: nextValue, }; try { this.promptEditorState[config.field] = { ...promptState, isSaving: true, }; await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions()); this.currentRecipe.gen_params = nextGenParams; this.renderPromptContent(content, nextValue, config.placeholder); showToast(config.successKey, {}, 'success', config.successFallback); this.commitField(config.field); } catch (error) { this.renderPromptContent(content, currentValue, config.placeholder); input.value = currentValue; this.clearFieldDirty(config.field); } finally { this.clearFieldDirty(config.field); this.hidePromptEditor(config); } } cancelPromptEdit(config) { const input = document.getElementById(config.inputId); if (input) { input.value = this.currentRecipe?.gen_params?.[config.field] || ''; } this.clearFieldDirty(config.field); this.hidePromptEditor(config); } hidePromptEditor(config) { const content = document.getElementById(config.contentId); const editor = document.getElementById(config.editorId); if (content) { content.classList.remove('hide'); } if (editor) { editor.classList.remove('active'); } delete this.promptEditorState[config.field]; } // Setup source URL handlers setupSourceUrlHandlers() { const sourceUrlContainer = document.querySelector('.source-url-container'); const sourceUrlEditor = document.querySelector('.source-url-editor'); if (!sourceUrlContainer || !sourceUrlEditor) { return; } const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn'); const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn'); const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn'); const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) { return; } // Show editor on edit button click sourceUrlEditBtn.addEventListener('click', () => { sourceUrlContainer.classList.add('hide'); sourceUrlEditor.classList.add('active'); sourceUrlInput.focus(); }); sourceUrlInput.addEventListener('input', () => { this.sourceUrlEditState.isDirty = true; }); // Cancel editing sourceUrlCancelBtn.addEventListener('click', () => { sourceUrlEditor.classList.remove('active'); sourceUrlContainer.classList.remove('hide'); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true }); this.clearFieldDirty('source_path'); }); // Save new source URL sourceUrlSaveBtn.addEventListener('click', () => { const newSourceUrl = sourceUrlInput.value.trim(); if (newSourceUrl !== this.currentRecipe.source_path) { // Update the recipe on the server updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions()) .then(data => { // Show success toast showToast('toast.recipes.sourceUrlUpdated', {}, 'success'); // Update source URL in the UI this.commitField('source_path'); this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); this.syncHeaderActions(); // Update the current recipe object this.currentRecipe.source_path = newSourceUrl; }) .catch(error => { // Error is handled in the API function this.clearFieldDirty('source_path'); }); } else { this.clearFieldDirty('source_path'); } // Hide editor sourceUrlEditor.classList.remove('active'); sourceUrlContainer.classList.remove('hide'); }); // Open source URL in a new tab if it's valid sourceUrlText.addEventListener('click', () => { const url = sourceUrlText.textContent.trim(); if (url.startsWith('http://') || url.startsWith('https://')) { window.open(url, '_blank'); } }); } // Setup copy buttons for prompts and send recipe button setupCopyButtons() { const copyPromptBtn = document.getElementById('copyPromptBtn'); const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn'); const sendRecipeBtn = document.getElementById('sendRecipeBtn'); if (copyPromptBtn) { copyPromptBtn.addEventListener('click', () => { let promptText = this.currentRecipe?.gen_params?.prompt || ''; if (this.shouldStripLoraOnCopy()) { promptText = RecipeModal.stripLoraTags(promptText); } this.copyToClipboard(promptText, 'Prompt copied to clipboard'); }); } if (copyNegativePromptBtn) { copyNegativePromptBtn.addEventListener('click', () => { let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || ''; if (this.shouldStripLoraOnCopy()) { negativePromptText = RecipeModal.stripLoraTags(negativePromptText); } this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard'); }); } if (sendRecipeBtn) { sendRecipeBtn.addEventListener('click', () => { // Send recipe to ComfyUI workflow this.sendRecipeToWorkflow(); }); } // Send prompt to workflow buttons const sendPromptBtn = document.getElementById('sendPromptBtn'); const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn'); if (sendPromptBtn) { sendPromptBtn.addEventListener('click', () => { let promptText = this.currentRecipe?.gen_params?.prompt || ''; if (this.shouldStripLoraOnCopy()) { promptText = RecipeModal.stripLoraTags(promptText); } if (!promptText.trim()) { showToast('toast.recipes.noPromptToSend', {}, 'warning'); return; } sendPromptToWorkflow(promptText); }); } if (sendNegativePromptBtn) { sendNegativePromptBtn.addEventListener('click', () => { let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || ''; if (this.shouldStripLoraOnCopy()) { negativePromptText = RecipeModal.stripLoraTags(negativePromptText); } if (!negativePromptText.trim()) { showToast('toast.recipes.noPromptToSend', {}, 'warning'); return; } sendPromptToWorkflow(negativePromptText, { actionTypeText: 'Negative Prompt', }); }); } // Send params to workflow button const sendParamsBtn = document.getElementById('sendParamsBtn'); if (sendParamsBtn) { sendParamsBtn.addEventListener('click', () => { const genParams = this.currentRecipe?.gen_params || {}; if (!genParams || Object.keys(genParams).length === 0) { showToast('No generation parameters available', {}, 'warning'); return; } sendGenParamsToWorkflow(genParams); }); } } /** * Strip tags from prompt text and clean up residual punctuation/whitespace. * Handles both unescaped () and HTML-escaped (<lora:...>) variants. * Cleans up artifacts like leading ", ", double commas, and extra whitespace. */ static stripLoraTags(text) { return stripLoraTags(text); } shouldStripLoraOnCopy() { const toggle = document.getElementById('stripLoraOnCopyToggle'); return toggle ? toggle.checked : false; } setupStripLoraToggle() { const toggle = document.getElementById('stripLoraOnCopyToggle'); if (!toggle) return; const stored = getStorageItem('strip_lora_on_copy'); if (stored !== null) { toggle.checked = stored === true; } toggle.addEventListener('change', () => { const checked = toggle.checked; setStorageItem('strip_lora_on_copy', checked); state.global.settings.strip_lora_on_copy = checked; }); } // Helper method to copy text to clipboard copyToClipboard(text, successMessage) { copyToClipboard(text, successMessage); } // Send recipe to ComfyUI workflow async sendRecipeToWorkflow() { if (!this.recipeId) { showToast('toast.recipes.noRecipeId', {}, 'error'); return; } try { // Fetch recipe syntax from backend const response = await fetch(`/api/lm/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) { // Send the recipe syntax to ComfyUI workflow await sendLoraToWorkflow(data.syntax, false, 'recipe'); } else { throw new Error(data.error || 'No syntax returned from server'); } } catch (error) { console.error('Error sending recipe to workflow:', error); showToast('toast.recipes.sendToWorkflowFailed', { message: error.message }, 'error'); } } // 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('toast.recipes.noMissingLoras', {}, '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/lm/loras/civitai/model/version/${lora.modelVersionId}`; } else if (lora.hash) { endpoint = `/api/lm/loras/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('toast.recipes.missingLorasInfoFailed', {}, '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 => isModelWeightFile(file.type)) : null; return { // Basic lora info name: civitaiInfo.model?.name || lora.name, version: civitaiInfo.name || '', strength: lora.strength || 1.0, // Model identifiers modelId: lora.modelId || lora.model_id || civitaiInfo.modelId, hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash, id: 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('toast.recipes.preparingForDownloadFailed', {}, 'error'); } finally { state.loadingManager.hide(); } } // Wire the reconnect form controls. The entry point is the explicit // "Reconnect" ghost button rendered for deleted LoRAs (see // setupLoraItemActions), keeping badges as pure status indicators. setupReconnectButtons() { // 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('toast.recipes.enterLoraName', {}, 'error'); return; } try { // Parse input value to extract file_name let loraSyntaxMatch = inputValue.match(/]+)(?::[^>]+)?>/); let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim(); // Remove .safetensors extension if present fileName = fileName.replace(/\.safetensors$/, ''); state.loadingManager.showSimpleLoading('Reconnecting LoRA...'); // Call API to reconnect the LoRA const response = await fetch('/api/lm/recipe/lora/reconnect', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ recipe_id: this.recipeId, lora_index: loraIndex, 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('toast.recipes.reconnectedSuccessfully', {}, 'success'); // Refresh modal to show updated content setTimeout(() => { this.showRecipeDetails(this.currentRecipe); }, 500); state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, { loras: this.currentRecipe.loras }); } else { showToast('toast.recipes.reconnectFailed', { message: result.error }, 'error'); } } catch (error) { console.error('Error reconnecting LoRA:', error); showToast('toast.recipes.reconnectFailed', { message: error.message }, 'error'); } finally { state.loadingManager.hide(); } } renderCheckpoint(checkpoint) { const existsLocally = !!checkpoint.inLibrary; const localPath = checkpoint.localPath || ''; const previewUrl = checkpoint.preview_url || checkpoint.thumbnailUrl || '/loras_static/images/no-preview.png'; const isPreviewVideo = typeof previewUrl === 'string' && previewUrl.toLowerCase().endsWith('.mp4'); const checkpointName = checkpoint.name || checkpoint.modelName || checkpoint.file_name || 'Checkpoint'; const versionLabel = checkpoint.version || checkpoint.modelVersionName || ''; const baseModel = checkpoint.baseModel || checkpoint.base_model || ''; const modelTypeRaw = (checkpoint.sub_type || checkpoint.type || 'checkpoint').toLowerCase(); const modelTypeLabel = modelTypeRaw === 'diffusion_model' ? 'Diffusion Model' : 'Checkpoint'; const previewMedia = isPreviewVideo ? ` ` : `Checkpoint preview`; // Status badge: pure indicator with a tooltip, mirroring the LoRA // items and the versions-tab badge pattern. The header carries only // the badge; every action lives in the bottom action row. const badge = existsLocally ? `
${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
` : `
${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
`; const actions = []; if (existsLocally && localPath) { actions.push(` `); } else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) { actions.push(` `); } const actionsMarkup = actions.filter(Boolean).join(''); const actionsRow = actionsMarkup ? `
${actionsMarkup}
` : ''; // Civitai link lives inline with the title, same as LoRA items. const titleLink = this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint)); // Only in-library checkpoints are row-navigable; make it keyboard-accessible. const rowA11yAttributes = existsLocally ? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openCheckpointDetails', { name: checkpointName }, `View ${checkpointName} in the model library`))}"` : ''; return `
${previewMedia}

${checkpointName}

${titleLink}
${badge}
${versionLabel ? `
${versionLabel}
` : ''} ${baseModel ? `
${baseModel}
` : ''} ${modelTypeLabel ? `${modelTypeLabel}` : ''}
${actionsRow}
`; } setupCheckpointActions(container, checkpoint) { const sendBtn = container.querySelector('.checkpoint-send'); if (sendBtn) { sendBtn.addEventListener('click', (e) => { e.stopPropagation(); this.sendCheckpointToWorkflow(checkpoint); }); } const downloadBtn = container.querySelector('.checkpoint-download'); if (downloadBtn) { downloadBtn.addEventListener('click', async (e) => { e.stopPropagation(); await this.downloadCheckpoint(checkpoint, downloadBtn); }); } } setupCheckpointNavigation(container, checkpoint) { // Only in-library checkpoints navigate (to the local detail view). // Missing checkpoints expose explicit Download / Civitai-link // controls instead, so a row click never has two different outcomes. if (!checkpoint.inLibrary) { return; } const checkpointItem = container.querySelector('.checkpoint-item'); if (!checkpointItem) return; checkpointItem.addEventListener('click', (e) => { if (e.target.closest('.resource-action') || e.target.closest('.recipe-civitai-link')) { return; } this.navigateToCheckpointPage(checkpoint); }); checkpointItem.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToCheckpointPage(checkpoint); } }); } canDownloadCheckpoint(checkpoint) { if (!checkpoint) return false; const modelId = checkpoint.modelId || checkpoint.modelID || checkpoint.model_id; const versionId = checkpoint.id || checkpoint.modelVersionId; return !!(modelId && versionId); } async sendCheckpointToWorkflow(checkpoint) { if (!checkpoint || !checkpoint.localPath) { showToast('toast.recipes.missingCheckpointPath', {}, 'error'); return; } const modelType = (checkpoint.sub_type || checkpoint.type || 'checkpoint').toLowerCase(); const isDiffusionModel = modelType === 'diffusion_model' || modelType === 'unet'; const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name'; const actionTypeText = translate( isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint', {}, isDiffusionModel ? 'Diffusion Model' : 'Checkpoint' ); const successMessage = translate( 'uiHelpers.workflow.modelUpdated', {}, 'Model updated in workflow' ); const failureMessage = translate( 'uiHelpers.workflow.modelFailed', {}, 'Failed to update model node' ); const missingNodesMessage = translate( 'uiHelpers.workflow.noMatchingNodes', {}, 'No compatible nodes available in the current workflow' ); const missingTargetMessage = translate( 'uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected' ); await sendModelPathToWorkflow(checkpoint.localPath, { widgetName, collectionType: MODEL_TYPES.CHECKPOINT, actionTypeText, successMessage, failureMessage, missingNodesMessage, missingTargetMessage, }); } async downloadCheckpoint(checkpoint, button) { if (!this.canDownloadCheckpoint(checkpoint)) { showToast('toast.recipes.missingCheckpointInfo', {}, 'error'); return; } const modelId = checkpoint.modelId || checkpoint.modelID || checkpoint.model_id; const versionId = checkpoint.id || checkpoint.modelVersionId; const versionName = checkpoint.version || checkpoint.modelVersionName || checkpoint.name || 'Checkpoint'; if (button) { button.disabled = true; } try { const success = await downloadManager.downloadVersionWithDefaults( MODEL_TYPES.CHECKPOINT, modelId, versionId, { versionName, source: 'recipe-modal', } ); if (success) { await this.refreshResourcesAfterDownload(); } } catch (error) { console.error('Error downloading checkpoint:', error); showToast('toast.recipes.downloadCheckpointFailed', { message: error.message }, 'error'); } finally { if (button) { button.disabled = false; } } } getResourceCivitaiUrl(resource) { if (!resource) { return null; } const modelId = resource.modelId || resource.modelID || resource.model_id || null; const versionId = resource.id || resource.modelVersionId || null; const modelName = resource.modelName || resource.name || resource.file_name || null; return buildCivitaiUrl({ modelId, versionId, modelName, host: state?.global?.settings?.civitai_host, }); } canDownloadLora(lora) { if (!lora) return false; const modelId = lora.modelId || lora.modelID || lora.model_id; const versionId = lora.id || lora.modelVersionId; // Direct download needs both identifiers; a hash alone is enough // because downloadRecipeLora resolves it to a version on demand — // the same fallback the bulk "download missing" flow uses. return !!((modelId && versionId) || lora.hash); } renderCivitaiLink(url) { if (!url) { return ''; } const tooltip = translate('recipes.resources.viewOnCivitai', {}, 'View on Civitai'); return ` `; } renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) { // In-library LoRAs need no remediation: the badge and the local path // already tell the full story. if (existsLocally) { return ''; } const controls = []; if (isDeleted) { const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); controls.push(` `); } else { if (this.canDownloadLora(lora)) { const downloadLabel = translate('recipes.resources.download', {}, 'Download'); const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); controls.push(` `); } } const markup = controls.filter(Boolean).join(''); if (!markup) { return ''; } return `
${markup}
`; } setupLoraItemActions() { const lorasListElement = document.getElementById('recipeLorasList'); if (!lorasListElement) { return; } // Deferred wiring can run again after a hydration re-render while the // latest DOM is already in place; a data flag prevents stacking // duplicate handlers (which would fire the download twice). lorasListElement.querySelectorAll('.lora-download').forEach(button => { if (button.dataset.wired === 'true') { return; } button.dataset.wired = 'true'; button.addEventListener('click', (e) => { e.stopPropagation(); const loraIndex = parseInt(button.dataset.loraIndex, 10); const lora = this.currentRecipe?.loras?.[loraIndex]; if (lora) { this.downloadRecipeLora(lora, button); } }); }); lorasListElement.querySelectorAll('.lora-reconnect').forEach(button => { if (button.dataset.wired === 'true') { return; } button.dataset.wired = 'true'; button.addEventListener('click', (e) => { e.stopPropagation(); this.showReconnectInput(button.dataset.loraIndex); }); }); } /** * Resolve the Civitai model/version identifiers needed for download. * Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve * it through the same endpoint the bulk "download missing" flow uses. */ async resolveLoraDownloadIdentifiers(lora) { let modelId = lora.modelId || lora.modelID || lora.model_id; let versionId = lora.id || lora.modelVersionId; let versionName = lora.modelVersionName || lora.modelName || lora.name || 'LoRA'; if (modelId && versionId) { return { modelId, versionId, versionName }; } if (!lora.hash) { return null; } const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`); const versionInfo = await response.json(); if (versionInfo?.error) { return null; } modelId = versionInfo.modelId || versionInfo.model?.id; versionId = versionInfo.id; versionName = versionInfo.name || versionName; return modelId && versionId ? { modelId, versionId, versionName } : null; } /** * A completed download flips inLibrary flags server-side; re-fetch the * recipe and reconcile both this modal's resources section and the * recipe card on the listing page (mirrors the bulk download flow in * BulkMissingLoraDownloadManager). */ async refreshResourcesAfterDownload() { try { const recipeId = this.recipeId || extractRecipeId(this.listFilePath || this.currentRecipe?.file_path); if (!recipeId) { return; } const updated = await fetchRecipeDetails(recipeId); if (!updated) { return; } this.currentRecipe.loras = updated.loras ?? this.currentRecipe.loras; this.currentRecipe.checkpoint = updated.checkpoint ?? this.currentRecipe.checkpoint; this.syncResourcesSection(this.currentRecipe); if (state.virtualScroller) { state.virtualScroller.updateSingleItem( this.listFilePath || this.currentRecipe.file_path, updated ); } } catch (error) { console.warn('Failed to refresh recipe resources after download:', error); } } async downloadRecipeLora(lora, button) { if (!this.canDownloadLora(lora)) { showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error'); return; } if (button) { button.disabled = true; } // Hash-only LoRAs need a network round trip to resolve identifiers // before the progress UI can appear; show immediate feedback so the // click never feels dead. const hasDirectIds = !!( (lora.modelId || lora.modelID || lora.model_id) && (lora.id || lora.modelVersionId) ); if (!hasDirectIds) { state.loadingManager.showSimpleLoading( translate('recipes.resources.preparingDownload', {}, 'Preparing download...') ); } try { const identifiers = await this.resolveLoraDownloadIdentifiers(lora); if (!hasDirectIds) { state.loadingManager.hide(); } if (!identifiers) { showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error'); return; } const success = await downloadManager.downloadVersionWithDefaults( MODEL_TYPES.LORA, identifiers.modelId, identifiers.versionId, { versionName: identifiers.versionName, source: 'recipe-modal', } ); if (success) { await this.refreshResourcesAfterDownload(); } } catch (error) { if (!hasDirectIds) { state.loadingManager.hide(); } console.error('Error downloading LoRA:', error); showToast('toast.recipes.downloadLoraFailed', { message: error.message }, 'error'); } finally { if (button) { button.disabled = false; } } } navigateToCheckpointPage(checkpoint) { const checkpointHash = this._getCheckpointHash(checkpoint); if (!checkpointHash) { showToast('toast.recipes.missingCheckpointInfo', {}, 'error'); return; } modalManager.closeModal('recipeModal'); removeSessionItem('recipe_to_checkpoint_filterHash'); removeSessionItem('recipe_to_checkpoint_filterHashes'); removeSessionItem('filterCheckpointRecipeName'); setSessionItem('recipe_to_checkpoint_filterHash', checkpointHash.toLowerCase()); if (this.currentRecipe?.title) { setSessionItem('filterCheckpointRecipeName', this.currentRecipe.title); } window.location.href = '/checkpoints'; } _getCheckpointHash(checkpoint) { if (!checkpoint) return ''; const hash = checkpoint.hash || checkpoint.sha256 || checkpoint.sha256_hash || checkpoint.sha256Hash || checkpoint.SHA256; return hash ? hash.toString() : ''; } // 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'; } // Only in-library LoRA items are row-navigable: the row opens the local // LoRA detail. Missing/deleted rows expose explicit action buttons // instead (download / reconnect / Civitai link), so a single gesture // never produces two different outcomes. setupLoraItemsClickable() { const loraItems = document.querySelectorAll('.recipe-lora-item.exists-locally:not(.checkpoint-item)'); loraItems.forEach(item => { // Guard against duplicate wiring from deferred re-runs (see // setupLoraItemActions). if (item.dataset.navigationWired === 'true') { return; } item.dataset.navigationWired = 'true'; // Get the lora index from the data attribute const loraIndex = parseInt(item.dataset.loraIndex); item.addEventListener('click', (e) => { // The inline Civitai link inside the title keeps its own // navigation; don't let it trigger the row navigation. if (e.target.closest('.recipe-civitai-link')) { return; } this.navigateToLorasPage(loraIndex); }); item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToLorasPage(loraIndex); } }); }); } } export { RecipeModal };