`;
// 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;
this._scheduleDeferred(() => {
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
return;
}
this.setupSourceUrlHandlers();
}, 50);
}
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
this.syncHeaderActions();
this.syncMetaFooter();
// 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
);
}
}
/**
* Render the meta footer: clickable file location (opens the recipe JSON
* in the OS file manager) plus the truncated recipe ID with copy button.
* De-emphasized by design, mirroring the model modal's hash footnote.
*/
syncMetaFooter() {
const footer = document.getElementById('recipeMetaFooter');
if (!footer) {
return;
}
const recipeId = this.currentRecipe?.id || '';
const filePath = this.currentRecipe?.file_path || '';
const openTarget = this.currentRecipe?.recipe_json_path || filePath;
const folderPath = filePath.replace(/[^/\\]+$/, '');
if (!recipeId && !folderPath) {
footer.hidden = true;
footer.innerHTML = '';
return;
}
const truncatedId = recipeId.length > 14
? `${recipeId.slice(0, 8)}…${recipeId.slice(-4)}`
: recipeId;
const openLocationLabel = translate('recipes.modal.actions.openFileLocation', {}, 'Open File Location');
const copyIdLabel = translate('recipes.modal.actions.copyId', {}, 'Copy recipe ID');
const locationMarkup = folderPath ? `
${escapeHtml(folderPath)}` : '';
const idMarkup = recipeId ? `
${translate('recipes.modal.metadata.id', {}, 'ID')}${escapeHtml(truncatedId)}` : '';
footer.innerHTML = locationMarkup + idMarkup;
footer.hidden = false;
const locationEl = footer.querySelector('.recipe-meta-location');
if (locationEl) {
const openLocation = () => {
if (locationEl.dataset.filepath) {
openRecipeFileLocation(locationEl.dataset.filepath);
}
};
locationEl.addEventListener('click', openLocation);
locationEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openLocation();
}
});
}
const copyBtn = footer.querySelector('.recipe-meta-copy-btn');
if (copyBtn && recipeId) {
copyBtn.addEventListener('click', () => {
copyToClipboard(recipeId);
});
}
}
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
try {
const fullRecipe = await fetchRecipeDetails(recipeId);
if (this._disposed || 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.recipe_json_path !== undefined) {
nextRecipe.recipe_json_path = fullRecipe.recipe_json_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();
this.syncMetaFooter();
}
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 = {}) {
if (this._disposed) {
return;
}
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) {
// The innerHTML below discards the checkpoint reconnect container;
// tear down its Combobox panel (appended to document.body) first.
const checkpointPanel = checkpointContainer.querySelector(
'.lora-reconnect-container[data-lora-index="checkpoint"]'
);
if (checkpointPanel) {
this._destroyReconnectCombobox(checkpointPanel);
}
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 = `
`;
}
}
lorasCountElement.innerHTML = ` ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
const missingStatus = lorasCountElement.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) {
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
}
this._scheduleDeferred(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
}
}, 100);
}
if (lorasListElement && loras.length > 0) {
// The list innerHTML below discards every reconnect container;
// tear down their Combobox panels (appended to document.body) first.
this._destroyAllReconnectComboboxes();
lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted;
const loraIndex = loras.indexOf(lora);
// Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading, so no
// download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(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.notInLibrary', {}, 'Not in Library'))}
`;
}
const actionsRow = this.renderLoraItemActions(loraIndex, { existsLocally, needsReconnect });
// 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`))}"`
: '';
// A reconnect snapshot marks a manually reconnected entry.
// The restore icon on the info row doubles as that marker;
// its tooltip names the previous association.
let undoReconnectIcon = '';
if (existsLocally && lora.reconnectSnapshot) {
const previousName = lora.reconnectSnapshot.file_name || lora.reconnectSnapshot.modelName || '';
const undoLabel = translate('recipes.resources.undoReconnect', {}, 'Undo');
const undoTooltip = previousName
? translate('recipes.resources.undoReconnectTooltipNamed', { name: previousName }, `Restore to ${previousName} (the association before reconnecting)`)
: translate('recipes.resources.undoReconnectTooltip', {}, 'Restore the association this entry had before reconnecting');
undoReconnectIcon = `
`;
}
return `
${previewMedia}
${lora.modelName}
${titleLink}
${statusBadge}
${lora.modelVersionName ? `
${lora.modelVersionName}
` : ''}
Weight: ${lora.strength || 1.0}
${lora.baseModel ? `
${lora.baseModel}
` : ''}
${undoReconnectIcon}
${actionsRow}
${!existsLocally ? `
${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}
${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: or just the name'))}
` : ''}
`;
}).join('');
this._scheduleDeferred(() => {
this.setupReconnectButtons();
this.setupLoraItemActions();
this.setupLoraItemsClickable();
}, 100);
this.recipeLorasSyntax = '';
} else if (lorasListElement) {
this._destroyAllReconnectComboboxes();
lorasListElement.innerHTML = this.renderNoLorasState(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';
}
}
/**
* Render the empty LoRA list, including a collapsed "Why no LoRAs?"
* explanation panel when the cause is known or can be inferred.
* @param {Object} recipe
* @returns {string}
*/
renderNoLorasState(recipe) {
const emptyText = translate(
'recipes.resources.noLorasAssociated',
{},
'No LoRAs associated with this recipe'
);
const reason = this.resolveNoLorasReason(recipe);
let html = `
${escapeHtml(emptyText)}
`;
// 'no_loras_used' is the normal case — the generation simply used no
// LoRAs, nothing to explain.
if (!reason || reason.code === 'no_loras_used') {
return html;
}
const toggle = translate('recipes.resources.noLorasWhyToggle', {}, 'Why no LoRAs?');
const reasonText = translate(
`recipes.resources.noLorasReasons.${reason.code}`,
{},
NO_LORAS_REASON_FALLBACKS[reason.code] || NO_LORAS_REASON_FALLBACKS.unknown
);
const bullets = [];
if (reason.channel) {
const channelText = translate(
`recipes.resources.noLorasChannels.${reason.channel}`,
{},
NO_LORAS_CHANNEL_FALLBACKS[reason.channel] || reason.channel
);
bullets.push(
`
`);
bullets.push(...this.renderNoLorasDetailBullets(reason));
if (reason.inferred) {
bullets.push(
`
${escapeHtml(translate('recipes.resources.noLorasInferredNote', {}, 'Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.'))}
`
);
}
html += `
${escapeHtml(toggle)}
${bullets.join('')}
`;
return html;
}
/**
* Resolve the no-LoRA reason: recorded import_info takes precedence;
* legacy recipes without it fall back to heuristics on the stored data.
* @param {Object} recipe
* @returns {{code: string, channel: ?string, details: ?Object, inferred: boolean}|null}
*/
resolveNoLorasReason(recipe) {
const importInfo =
recipe && typeof recipe.import_info === 'object' && recipe.import_info !== null
? recipe.import_info
: null;
if (importInfo && typeof importInfo.reason === 'string' && importInfo.reason) {
return {
code: importInfo.reason,
channel: typeof importInfo.channel === 'string' ? importInfo.channel : null,
details:
typeof importInfo.details === 'object' && importInfo.details !== null
? importInfo.details
: null,
inferred: false,
};
}
return this.inferNoLorasReason(recipe);
}
/**
* Heuristic reason for legacy recipes that predate import_info.
* @param {Object} recipe
* @returns {{code: string, channel: ?string, details: ?Object, inferred: boolean}}
*/
inferNoLorasReason(recipe) {
const sourcePath = recipe && recipe.source_path ? String(recipe.source_path).trim() : '';
const genParams =
recipe && recipe.gen_params && typeof recipe.gen_params === 'object'
? recipe.gen_params
: {};
const paramKeys = Object.keys(genParams).filter(
(key) => genParams[key] !== '' && genParams[key] !== null && genParams[key] !== undefined
);
if (recipe && recipe.has_workflow) {
return { code: 'workflow_metadata_limited', channel: null, details: null, inferred: true };
}
if (/^https?:\/\//i.test(sourcePath)) {
// URL imports come from CivitAI; a missing LoRA list there almost
// always means the public API did not report LoRA resources.
return { code: 'api_meta_no_lora_resources', channel: 'url', details: null, inferred: true };
}
if (sourcePath) {
return paramKeys.length === 0
? { code: 'no_embedded_metadata', channel: 'local', details: null, inferred: true }
: { code: 'no_loras_used', channel: 'local', details: null, inferred: true };
}
if (paramKeys.length > 0) {
return { code: 'no_loras_used', channel: null, details: null, inferred: true };
}
return { code: 'unknown', channel: null, details: null, inferred: true };
}
/**
* Render the recorded diagnostic detail bullets (API meta shape, EXIF
* presence). Only shown for recorded (non-inferred) import_info.
* @param {{details: ?Object}} reason
* @returns {string[]}
*/
renderNoLorasDetailBullets(reason) {
const details = reason.details;
if (!details) {
return [];
}
const bullets = [];
if (Array.isArray(details.api_meta_keys) && details.api_meta_keys.length > 0) {
const label = translate('recipes.resources.noLorasDetails.apiMetaFields', {}, 'API metadata fields');
bullets.push(
`
${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
`;
}
// Action row: broken (deleted / unresolvable hash) entries offer the
// reconnect affordance instead of the download button — same rule as
// the LoRA items. A local checkpoint only exposes "Send to ComfyUI".
const actions = [];
if (existsLocally && localPath) {
actions.push(`
`);
} else if (broken) {
const reconnectLabel = translate('recipes.resources.reconnectCheckpoint', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectCheckpointTooltip', {}, 'Reconnect with a local checkpoint');
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.
// Skipped for deleted models: their source page is gone.
const titleLink = isDeleted
? ''
: 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`))}"`
: '';
// A reconnect snapshot marks a manually reconnected entry. The restore
// icon on the info row doubles as that marker (mirrors LoRA entries).
let undoReconnectIcon = '';
if (existsLocally && checkpoint.reconnectSnapshot) {
const previousName = checkpoint.reconnectSnapshot.name
|| checkpoint.reconnectSnapshot.file_name
|| checkpoint.reconnectSnapshot.modelName
|| '';
const undoLabel = translate('recipes.resources.undoReconnect', {}, 'Undo');
const undoTooltip = previousName
? translate('recipes.resources.undoReconnectTooltipNamed', { name: previousName }, `Restore to ${previousName} (the association before reconnecting)`)
: translate('recipes.resources.undoReconnectTooltip', {}, 'Restore the association this entry had before reconnecting');
undoReconnectIcon = `
`;
}
// Inline reconnect form for broken entries, sharing the LoRA
// container structure/classes and the combobox interaction.
const reconnectContainer = broken ? `
${escapeHtml(translate('recipes.resources.checkpointReconnectInstructions', {}, 'Enter checkpoint name to reconnect:'))}
`;
}
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);
});
}
// 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 (same pattern as the LoRA item actions).
const reconnectBtn = container.querySelector('.checkpoint-reconnect');
if (reconnectBtn && reconnectBtn.dataset.wired !== 'true') {
reconnectBtn.dataset.wired = 'true';
reconnectBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showReconnectInput('checkpoint');
});
}
const undoBtn = container.querySelector('.checkpoint-undo-reconnect');
if (undoBtn && undoBtn.dataset.wired !== 'true') {
undoBtn.dataset.wired = 'true';
undoBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.restoreCheckpoint();
});
}
}
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)) {
// No resolvable CivitAI identifiers for this entry. A hash-only
// checkpoint is not downloadable through the version downloader —
// point the user at the reconnect flow instead.
if (this._getCheckpointHash(checkpoint)) {
showToast('toast.recipes.checkpointDownloadUnavailable', {}, 'warning');
} else {
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();
return;
}
// Business-level download failure (the request completed but the
// backend rejected it). Enroll the entry in the rematch/reconnect
// remediation flow only when the failure is clearly unresolvable
// (model removed or version gone on CivitAI) — the same signal
// rule as the LoRA path. Transient failures (network, 5xx) leave
// the entry untouched.
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
await this.markCheckpointHashInvalid();
}
} catch (error) {
console.error('Error downloading checkpoint:', error);
showToast('toast.recipes.downloadCheckpointFailed', { message: error.message }, 'error');
} finally {
if (button) {
button.disabled = false;
}
}
}
/**
* Decide whether a download failure means the model is unrecoverable.
*
* Mirrors the LoRA behaviour: the hash invalid flag (and the resulting
* rematch/reconnect candidacy) is only set when CivitAI explicitly says
* the model cannot be resolved — never for transient transport errors.
*/
_isUnresolvableDownloadError(message) {
return isUnresolvableDownloadError(message);
}
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 versionId = lora.id || lora.modelVersionId;
// A bare CivitAI version id is enough: it uniquely pins the exact
// file, and downloadRecipeLora resolves the owning model id from the
// version on demand (the same fallback the bulk "download missing"
// flow uses). A hash alone is likewise sufficient. A model id without
// an exact version id is NOT enough — downloading the model's latest
// version could silently mismatch the recipe's pinned version.
return !!(versionId || lora.hash);
}
renderCivitaiLink(url) {
if (!url) {
return '';
}
const tooltip = translate('recipes.resources.viewOnCivitai', {}, 'View on Civitai');
return `
`;
}
renderLoraItemActions(loraIndex, { existsLocally, needsReconnect }) {
// In-library LoRAs need no remediation: the badge and the local path
// already tell the full story. (The restore affordance for manually
// reconnected entries lives on the info row, not here.)
if (existsLocally) {
return '';
}
const controls = [];
if (!needsReconnect) {
// needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional in this branch.
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(`
`);
}
// Reconnect is always offered for missing entries — when the LoRA
// already exists locally under a different hash, downloading first
// just to flip the button would be a waste.
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
`);
return `
${controls.join('')}
`;
}
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, loraIndex);
}
});
});
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);
});
});
lorasListElement.querySelectorAll('.lora-undo-reconnect').forEach(button => {
if (button.dataset.wired === 'true') {
return;
}
button.dataset.wired = 'true';
button.addEventListener('click', (e) => {
e.stopPropagation();
this.restoreLora(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.
* Version-only entries (page-imported recipes whose CivitAI version has
* no sha256) are resolved through the version endpoint, which returns
* the owning model id.
*/
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 };
}
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
// model/version through the same endpoint the bulk "download
// missing" flow uses.
if (lora.hash) {
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;
}
// Version-only entries (page-imported recipes whose CivitAI versions
// expose no sha256): the version id still pins the exact file, so
// resolve the owning model id from the version endpoint on demand.
if (versionId) {
const response = await fetch(`/api/lm/loras/civitai/model/version/${versionId}`);
const versionInfo = await response.json();
if (!versionInfo || versionInfo?.error === 'Model not found') {
return null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id || versionId;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
}
return 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 (this._disposed) {
return;
}
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, loraIndex) {
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) {
if (!hasDirectIds && lora.hash) {
await this.markLoraHashInvalid(loraIndex);
showToast('toast.recipes.hashNotFoundOnCivitai', {}, 'error');
} else {
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();
return;
}
// Business-level download failure (the request completed but the
// backend rejected it). Mark the hash invalid — and thereby offer
// the reconnect affordance — only when the failure is clearly
// unresolvable (model removed or version gone on CivitAI), the
// same signal rule as the checkpoint path. Transient failures
// (network, 5xx) leave the entry untouched.
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
await this.markLoraHashInvalid(loraIndex);
}
} 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;
}
}
}
async markLoraHashInvalid(loraIndex) {
const recipeId =
this.recipeId ||
extractRecipeId(this.listFilePath || this.currentRecipe?.file_path);
if (!recipeId) {
return;
}
try {
await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: recipeId,
lora_index: loraIndex,
}),
});
if (this._disposed) {
return;
}
if (this.currentRecipe?.loras?.[loraIndex]) {
this.currentRecipe.loras[loraIndex].hashInvalid = true;
this.syncResourcesSection(this.currentRecipe);
}
} catch (error) {
console.warn('Failed to mark LoRA hash invalid:', error);
}
}
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 };