`;
// 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 = `
${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 ?
`` :
``;
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
`
: '';
// 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 `