Files
ComfyUI-Lora-Manager/static/js/managers/RematchModalManager.js
T
Will Miao 1b5cbbbaa0 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
2026-09-09 06:59:54 +08:00

172 lines
6.5 KiB
JavaScript

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();