mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched entries can be undone via the existing restore flow - Bulk missing-LoRA downloads mark unresolvable failures hash-invalid, flipping those entries from download to reconnect candidacy - Recipe modal always offers a reconnect action next to download for missing LoRA entries - Rematch runs collect an opt-in relaxed-matching choice (also reconnect missing models by file name) via a pre-run options dialog on the global, bulk and single-recipe entries - L4 (filename-level) matches are listed in a results dialog with per-entry undo
This commit is contained in:
@@ -3,6 +3,7 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { rematchModalManager } from './RematchModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
@@ -978,6 +979,15 @@ export class BulkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect options (relaxed matching) before starting anything; the
|
||||
// run only begins when the user confirms the dialog.
|
||||
rematchModalManager.showOptionsModal({
|
||||
recipeCount: state.selectedModels.size,
|
||||
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
|
||||
});
|
||||
}
|
||||
|
||||
async _startRematchSelectedRecipes(relaxed = false) {
|
||||
try {
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
@@ -989,7 +999,7 @@ export class BulkManager {
|
||||
|
||||
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
|
||||
|
||||
const result = await apiClient.rematchBulkModels(filePaths);
|
||||
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
|
||||
|
||||
if (result.success) {
|
||||
const total = result.total || filePaths.length;
|
||||
@@ -1050,6 +1060,12 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
if (state.bulkMode) this.toggleBulkMode();
|
||||
|
||||
// Filename-level (L4) matches are imprecise — always surface
|
||||
// them for review/undo.
|
||||
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
|
||||
rematchModalManager.showResultsModal(result.l4_matches);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Bulk rematch failed');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { extractRecipeId } from '../api/recipeApi.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
this.pendingLoras = [];
|
||||
this.pendingRecipes = [];
|
||||
this.pendingMissingByRecipe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
// Execute download
|
||||
await this.executeDownload(this.pendingLoras);
|
||||
this.pendingLoras = [];
|
||||
this.pendingMissingByRecipe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Collect missing LoRAs with deduplication
|
||||
const stats = this.collectMissingLoras(selectedRecipes);
|
||||
// Kept so executeDownload can mark unresolvable failures back onto
|
||||
// every recipe occurrence (hashInvalid → reconnect candidacy).
|
||||
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
|
||||
|
||||
if (stats.uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let markedInvalidCount = 0;
|
||||
let currentLoraProgress = 0;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
failedDownloads++;
|
||||
// An unresolvable failure (model gone on CivitAI) flips
|
||||
// every recipe occurrence to reconnect candidacy — same
|
||||
// rule as the single-LoRA download in RecipeModal.
|
||||
if (isUnresolvableDownloadError(response.error)) {
|
||||
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||
}
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
|
||||
if (!cancelled) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
if (isUnresolvableDownloadError(error?.message)) {
|
||||
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
// Unresolvable failures were marked hash-invalid during the loop;
|
||||
// tell the user those entries now offer reconnect instead of download.
|
||||
if (markedInvalidCount > 0) {
|
||||
showToast('toast.recipes.unresolvableMarkedForReconnect', {
|
||||
count: markedInvalidCount
|
||||
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
|
||||
}
|
||||
|
||||
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||
if (state.virtualScroller) {
|
||||
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||
for (const recipe of this.pendingRecipes) {
|
||||
const recipeId = extractRecipeId(recipe.file_path);
|
||||
if (!recipeId) continue;
|
||||
@@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
|
||||
*
|
||||
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
|
||||
* makes each occurrence an unresolved rematch candidate and swaps its
|
||||
* action from download to reconnect. Only called for unresolvable
|
||||
* failures — transient errors leave entries untouched.
|
||||
*
|
||||
* @param {Object} failedLora - The deduplicated LoRA that failed
|
||||
* @returns {Promise<number>} - How many recipe entries were marked
|
||||
*/
|
||||
async markLoraHashInvalidInRecipes(failedLora) {
|
||||
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
|
||||
if (!failedKey || !this.pendingMissingByRecipe) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let marked = 0;
|
||||
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
|
||||
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
|
||||
if (!recipeId || !Array.isArray(recipe.loras)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of missingLoras) {
|
||||
const entryKey = entry.hash || entry.id || entry.modelVersionId;
|
||||
if (entryKey !== failedKey) {
|
||||
continue;
|
||||
}
|
||||
const loraIndex = recipe.loras.indexOf(entry);
|
||||
if (loraIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = 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 (response.ok) {
|
||||
entry.hashInvalid = true;
|
||||
marked++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to mark LoRA hash invalid:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return marked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA root directory from API
|
||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||
|
||||
@@ -347,6 +347,32 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register rematchOptionsModal
|
||||
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
|
||||
if (rematchOptionsModal) {
|
||||
this.registerModal('rematchOptionsModal', {
|
||||
element: rematchOptionsModal,
|
||||
onClose: () => {
|
||||
this.getModal('rematchOptionsModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Register rematchResultsModal
|
||||
const rematchResultsModal = document.getElementById('rematchResultsModal');
|
||||
if (rematchResultsModal) {
|
||||
this.registerModal('rematchResultsModal', {
|
||||
element: rematchResultsModal,
|
||||
onClose: () => {
|
||||
this.getModal('rematchResultsModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Owns the two recipe-rematch modals:
|
||||
*
|
||||
* - rematchOptionsModal — shown BEFORE a global/bulk/single rematch run;
|
||||
* collects the "relaxed matching" opt-in and only then invokes the run
|
||||
* callback.
|
||||
* - rematchResultsModal — shown AFTER a run that produced L4 (filename
|
||||
* level) matches; lists them for review with a per-row Undo that calls
|
||||
* the existing restore endpoints.
|
||||
*/
|
||||
export class RematchModalManager {
|
||||
constructor() {
|
||||
this._optionsConfirmCallback = null;
|
||||
this._resultsMatches = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the options modal. `onConfirm({ relaxed })` fires only when the
|
||||
* user clicks Rematch — Cancel/X runs nothing.
|
||||
*
|
||||
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
|
||||
*/
|
||||
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
|
||||
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
|
||||
const message = document.getElementById('rematchOptionsMessage');
|
||||
if (message) {
|
||||
if (resolvedScope === 'bulk') {
|
||||
message.textContent = translate(
|
||||
'modals.rematchOptions.messageBulk',
|
||||
{ count: recipeCount },
|
||||
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
|
||||
);
|
||||
} else if (resolvedScope === 'single') {
|
||||
message.textContent = translate(
|
||||
'modals.rematchOptions.messageSingle',
|
||||
{},
|
||||
'This recipe will be scanned against your local model library.'
|
||||
);
|
||||
} else {
|
||||
message.textContent = translate(
|
||||
'modals.rematchOptions.messageGlobal',
|
||||
{},
|
||||
'All recipes will be scanned against your local model library.'
|
||||
);
|
||||
}
|
||||
}
|
||||
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
|
||||
modalManager.showModal('rematchOptionsModal');
|
||||
}
|
||||
|
||||
confirmOptions() {
|
||||
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||
const relaxed = checkbox ? !!checkbox.checked : false;
|
||||
const callback = this._optionsConfirmCallback;
|
||||
this._optionsConfirmCallback = null;
|
||||
modalManager.closeModal('rematchOptionsModal');
|
||||
if (callback) {
|
||||
// Returned so callers (and tests) can await the started run.
|
||||
return callback({ relaxed });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
cancelOptions() {
|
||||
this._optionsConfirmCallback = null;
|
||||
modalManager.closeModal('rematchOptionsModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the results modal listing L4 (filename-level) matches.
|
||||
*
|
||||
* @param {Array<{recipe_id: string, type: string, entry: string, file_name: string, lora_index?: number}>} l4Matches
|
||||
*/
|
||||
showResultsModal(l4Matches) {
|
||||
if (!Array.isArray(l4Matches) || l4Matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
const list = document.getElementById('rematchResultsList');
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
this._resultsMatches = l4Matches;
|
||||
list.innerHTML = '';
|
||||
|
||||
l4Matches.forEach((match, index) => {
|
||||
const row = document.createElement('li');
|
||||
row.className = 'rematch-results-row';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'rematch-results-info';
|
||||
|
||||
const entryName = document.createElement('span');
|
||||
entryName.className = 'rematch-results-entry';
|
||||
entryName.textContent = match.entry || '';
|
||||
|
||||
const matchedFile = document.createElement('span');
|
||||
matchedFile.className = 'rematch-results-file';
|
||||
matchedFile.textContent = `→ ${match.file_name || ''}`;
|
||||
|
||||
const recipeRef = document.createElement('span');
|
||||
recipeRef.className = 'rematch-results-recipe';
|
||||
recipeRef.textContent = match.recipe_id || '';
|
||||
|
||||
info.appendChild(entryName);
|
||||
info.appendChild(matchedFile);
|
||||
info.appendChild(recipeRef);
|
||||
|
||||
const undoButton = document.createElement('button');
|
||||
undoButton.className = 'secondary-btn rematch-results-undo';
|
||||
undoButton.textContent = translate('modals.rematchResults.undo', {}, 'Undo');
|
||||
undoButton.addEventListener('click', () => this.undoMatch(index, row, undoButton));
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(undoButton);
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
modalManager.showModal('rematchResultsModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a single L4 match via the existing restore endpoints. On success
|
||||
* the row is struck through and its button disabled.
|
||||
*/
|
||||
async undoMatch(index, row, button) {
|
||||
const match = this._resultsMatches[index];
|
||||
if (!match || button.disabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isCheckpoint = match.type === 'checkpoint';
|
||||
const body = isCheckpoint
|
||||
? { recipe_id: match.recipe_id }
|
||||
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
||||
const response = await fetch(
|
||||
isCheckpoint
|
||||
? '/api/lm/recipe/checkpoint/restore'
|
||||
: '/api/lm/recipe/lora/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Restore failed');
|
||||
}
|
||||
row.classList.add('undone');
|
||||
button.disabled = true;
|
||||
button.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
||||
} catch (error) {
|
||||
console.error('Failed to undo rematch match:', error);
|
||||
showToast(
|
||||
'modals.rematchResults.undoFailed',
|
||||
{ message: error.message },
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const rematchModalManager = new RematchModalManager();
|
||||
Reference in New Issue
Block a user