mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): reconnect suggestions, undo, and base-model family tolerance
Enhance the deleted-LoRA reconnect flow in the recipe modal: - Suggest local reconnect candidates when the panel opens, ranked by identity (same hash / same CivitAI version) then filename/name similarity, with a hard filter on confident base-model mismatches; the input gets a Combobox backed by the same endpoint as you type. - Snapshot the pre-reconnect entry and offer a permanent restore: reconnected entries show an undo icon at the right end of the info row, with the original filename in the tooltip. - Relax the manual reconnect base-model guard to a three-tier check: exact/unknown labels pass silently, same-architecture families (e.g. Pony <-> Illustrious) pass with a warning toast, and only cross-architecture mismatches stay hard-rejected.
This commit is contained in:
@@ -12,6 +12,7 @@ import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||
import { Combobox } from './Combobox.js';
|
||||
|
||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||
'prompt',
|
||||
@@ -171,7 +172,10 @@ class RecipeModal {
|
||||
reconnectContainers.forEach(container => {
|
||||
if (container.classList.contains('active') &&
|
||||
!container.contains(event.target) &&
|
||||
!event.target.closest('.lora-reconnect')) {
|
||||
!event.target.closest('.lora-reconnect') &&
|
||||
// The Combobox dropdown lives on document.body — clicks on
|
||||
// its options are part of the reconnect interaction.
|
||||
!event.target.closest('.lm-combobox-panel')) {
|
||||
this.hideReconnectInput(container);
|
||||
}
|
||||
});
|
||||
@@ -236,6 +240,7 @@ class RecipeModal {
|
||||
this.navigationKeyHandler = null;
|
||||
}
|
||||
this.navigationInProgress = false;
|
||||
this._destroyAllReconnectComboboxes();
|
||||
}
|
||||
|
||||
setupNavigationShortcuts() {
|
||||
@@ -878,6 +883,9 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -941,6 +949,24 @@ class RecipeModal {
|
||||
? ` 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 = `
|
||||
<button type="button" class="lora-undo-reconnect" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(undoTooltip)}" aria-label="${escapeHtml(undoTooltip)}">
|
||||
<i class="fas fa-rotate-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}>
|
||||
<div class="recipe-lora-thumbnail">
|
||||
@@ -958,24 +984,26 @@ class RecipeModal {
|
||||
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
${undoReconnectIcon}
|
||||
</div>
|
||||
${actionsRow}
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="reconnect-error" role="alert"></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-suggestions"></div>
|
||||
<p class="reconnect-error" role="alert"></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -988,6 +1016,7 @@ class RecipeModal {
|
||||
|
||||
this.recipeLorasSyntax = '';
|
||||
} else if (lorasListElement) {
|
||||
this._destroyAllReconnectComboboxes();
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
this.recipeLorasSyntax = '';
|
||||
}
|
||||
@@ -1665,12 +1694,17 @@ class RecipeModal {
|
||||
this.clearReconnectError(input.closest('.lora-reconnect-container'));
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
// When a Combobox is attached it owns Enter (pick a highlighted
|
||||
// option, or commit free text via onCommit) and, while its
|
||||
// dropdown is open, Escape (close the dropdown first).
|
||||
const combobox = this._reconnectComboboxes && this._reconnectComboboxes.get(container);
|
||||
if (e.key === 'Enter') {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
if (combobox) return;
|
||||
const loraIndex = container.getAttribute('data-lora-index');
|
||||
this.reconnectLora(loraIndex, input.value);
|
||||
} else if (e.key === 'Escape') {
|
||||
const container = input.closest('.lora-reconnect-container');
|
||||
if (combobox && combobox.isOpen()) return;
|
||||
this.hideReconnectInput(container);
|
||||
}
|
||||
});
|
||||
@@ -1680,7 +1714,7 @@ class RecipeModal {
|
||||
showReconnectInput(loraIndex) {
|
||||
// Hide any currently active reconnect containers
|
||||
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
|
||||
active.classList.remove('active');
|
||||
this.hideReconnectInput(active);
|
||||
});
|
||||
|
||||
// Show the reconnect container for this lora
|
||||
@@ -1690,6 +1724,8 @@ class RecipeModal {
|
||||
this.clearReconnectError(container);
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
input.focus();
|
||||
this._attachReconnectCombobox(container, loraIndex);
|
||||
this._loadReconnectSuggestions(container, loraIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,6 +1736,139 @@ class RecipeModal {
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
if (container) {
|
||||
this._destroyReconnectCombobox(container);
|
||||
// Invalidate any in-flight suggestions fetch for this panel
|
||||
this._reconnectSuggestionsToken = (this._reconnectSuggestionsToken || 0) + 1;
|
||||
const suggestions = container.querySelector('.reconnect-suggestions');
|
||||
if (suggestions) suggestions.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
_attachReconnectCombobox(container, loraIndex) {
|
||||
if (!this._reconnectComboboxes) {
|
||||
this._reconnectComboboxes = new Map();
|
||||
}
|
||||
if (this._reconnectComboboxes.has(container)) {
|
||||
return;
|
||||
}
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const combobox = new Combobox(input, {
|
||||
fetchOptions: async (value) => {
|
||||
const suggestions = await this._fetchReconnectSuggestions(loraIndex, value);
|
||||
return suggestions.map(suggestion => suggestion.target_name);
|
||||
},
|
||||
// emptyText only labels the dropdown empty state; the input keeps
|
||||
// its own translated placeholder from the markup.
|
||||
emptyText: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
|
||||
onCommit: (value) => {
|
||||
this.reconnectLora(loraIndex, value);
|
||||
},
|
||||
});
|
||||
this._reconnectComboboxes.set(container, combobox);
|
||||
}
|
||||
|
||||
_destroyReconnectCombobox(container) {
|
||||
const combobox = this._reconnectComboboxes && this._reconnectComboboxes.get(container);
|
||||
if (combobox) {
|
||||
combobox.destroy();
|
||||
this._reconnectComboboxes.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
_destroyAllReconnectComboboxes() {
|
||||
if (!this._reconnectComboboxes) {
|
||||
return;
|
||||
}
|
||||
this._reconnectComboboxes.forEach(combobox => combobox.destroy());
|
||||
this._reconnectComboboxes.clear();
|
||||
this._reconnectSuggestionsToken = (this._reconnectSuggestionsToken || 0) + 1;
|
||||
}
|
||||
|
||||
async _fetchReconnectSuggestions(loraIndex, query) {
|
||||
const suffix = query ? `?query=${encodeURIComponent(query)}` : '';
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/lora/${loraIndex}/reconnect-suggestions${suffix}`);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const result = await response.json();
|
||||
return result && result.success && Array.isArray(result.suggestions) ? result.suggestions : [];
|
||||
}
|
||||
|
||||
async _loadReconnectSuggestions(container, loraIndex) {
|
||||
const listElement = container.querySelector('.reconnect-suggestions');
|
||||
if (!listElement) {
|
||||
return;
|
||||
}
|
||||
const token = (this._reconnectSuggestionsToken || 0) + 1;
|
||||
this._reconnectSuggestionsToken = token;
|
||||
listElement.innerHTML = `<div class="reconnect-suggestions-loading">${escapeHtml(translate('recipes.resources.reconnectSuggestionsLoading', {}, 'Searching local library...'))}</div>`;
|
||||
try {
|
||||
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
|
||||
// Stale guard: panel closed or another item opened while fetching
|
||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
this._renderReconnectSuggestions(container, suggestions, loraIndex);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reconnect suggestions:', error);
|
||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
this._renderReconnectSuggestions(container, [], loraIndex);
|
||||
}
|
||||
}
|
||||
|
||||
_renderReconnectSuggestions(container, suggestions, loraIndex) {
|
||||
const listElement = container.querySelector('.reconnect-suggestions');
|
||||
if (!listElement) {
|
||||
return;
|
||||
}
|
||||
listElement.innerHTML = '';
|
||||
if (!suggestions.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'reconnect-suggestions-empty';
|
||||
empty.textContent = translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
|
||||
listElement.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const reasonLabels = {
|
||||
same_hash: translate('recipes.resources.reconnectMatchSameHash', {}, 'Same hash'),
|
||||
same_version: translate('recipes.resources.reconnectMatchSameVersion', {}, 'Same model version'),
|
||||
similar_filename: translate('recipes.resources.reconnectMatchSimilarFilename', {}, 'Similar filename'),
|
||||
similar_name: translate('recipes.resources.reconnectMatchSimilarName', {}, 'Similar name'),
|
||||
};
|
||||
suggestions.forEach(suggestion => {
|
||||
// The filename (stem) is what the match scored on and what gets
|
||||
// submitted — show it as the primary label, with the base model
|
||||
// as secondary context. The model name is omitted: it played no
|
||||
// part in the match and only adds noise.
|
||||
const stem = suggestion.target_name || suggestion.file_name || '';
|
||||
const secondaryParts = [];
|
||||
if (suggestion.base_model) {
|
||||
secondaryParts.push(suggestion.base_model);
|
||||
}
|
||||
const secondary = secondaryParts.join(' · ');
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'reconnect-suggestion';
|
||||
row.title = stem;
|
||||
row.innerHTML = `
|
||||
<img class="reconnect-suggestion-preview" src="${escapeHtml(suggestion.preview_url || '/loras_static/images/no-preview.png')}" alt="" loading="lazy" onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<span class="reconnect-suggestion-info">
|
||||
<span class="reconnect-suggestion-name">${escapeHtml(stem)}</span>
|
||||
${secondary ? `<span class="reconnect-suggestion-secondary">${escapeHtml(secondary)}</span>` : ''}
|
||||
</span>
|
||||
<span class="reconnect-suggestion-reason">${escapeHtml(reasonLabels[suggestion.match_reason] || suggestion.match_reason || '')}</span>
|
||||
`;
|
||||
row.addEventListener('click', () => {
|
||||
this.reconnectLora(loraIndex, suggestion.target_name);
|
||||
});
|
||||
listElement.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
showReconnectError(container, message) {
|
||||
@@ -1761,6 +1930,19 @@ class RecipeModal {
|
||||
// Show success message
|
||||
showToast('toast.recipes.reconnectedSuccessfully', {}, 'success');
|
||||
|
||||
// Same-architecture-family reconnects (e.g. Pony ↔ Illustrious)
|
||||
// succeed but carry structured mismatch data — warn the user.
|
||||
if (result.base_model_mismatch) {
|
||||
showToast(
|
||||
'toast.recipes.reconnectBaseModelMismatch',
|
||||
{
|
||||
recipe: result.base_model_mismatch.recipe_base_model,
|
||||
lora: result.base_model_mismatch.lora_base_model,
|
||||
},
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh modal to show updated content
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
@@ -1780,6 +1962,47 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
async restoreLora(loraIndex) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Restoring LoRA...');
|
||||
|
||||
const response = await fetch('/api/lm/recipe/lora/restore', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: this.recipeId,
|
||||
lora_index: loraIndex
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Swap the entry back to its pre-reconnect state
|
||||
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||||
|
||||
showToast('toast.recipes.loraRestored', {}, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||
loras: this.currentRecipe.loras
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.loraRestoreFailed', { message: result.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restoring LoRA:', error);
|
||||
showToast('toast.recipes.loraRestoreFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
renderCheckpoint(checkpoint) {
|
||||
const existsLocally = !!checkpoint.inLibrary;
|
||||
const localPath = checkpoint.localPath || '';
|
||||
@@ -2042,7 +2265,8 @@ class RecipeModal {
|
||||
|
||||
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) {
|
||||
// In-library LoRAs need no remediation: the badge and the local path
|
||||
// already tell the full story.
|
||||
// already tell the full story. (The restore affordance for manually
|
||||
// reconnected entries lives on the info row, not here.)
|
||||
if (existsLocally) {
|
||||
return '';
|
||||
}
|
||||
@@ -2113,6 +2337,17 @@ class RecipeModal {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user