feat(recipes): show a summary modal after rematch runs

Replace the post-run toast cascade and the standalone L4 results modal
with a summary modal modeled on the batch download summary: 3-state
header, stat cards (matched / needs review / unresolved / errors),
an L4 review table with per-entry undo, and a copyable report. Wired
into the global, bulk and single-recipe rematch entries; complete
no-op runs keep the lightweight toast. Obsolete results-modal code,
styles and i18n keys are removed.
This commit is contained in:
Will Miao
2026-09-09 10:38:10 +08:00
parent 51cad6f852
commit 4963bf2b2e
23 changed files with 997 additions and 583 deletions
+19 -33
View File
@@ -4,6 +4,7 @@ import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { rematchModalManager } from './RematchModalManager.js';
import { showRematchSummary } from '../components/RematchSummaryModal.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';
@@ -1025,47 +1026,32 @@ export class BulkManager {
}
}
if (matchedEntries > 0) {
const hasFailures = failures > 0;
const toastKey = hasFailures
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
hasFailures ? 'warning' : 'success'
);
} else if (failures > 0) {
// Nothing matched and at least one recipe errored —
// "no rematch needed" would be actively misleading here.
showToast(
'toast.recipes.rematchAllFailed',
{ total, failures },
'error'
);
} else if (unresolvedEntries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
'info'
);
} else {
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (isNoop) {
showToast(
'toast.recipes.rematchSkipped',
{ total },
'info'
);
} else {
showRematchSummary({
scope: 'bulk',
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors: failures,
l4Matches,
});
}
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');
}
-13
View File
@@ -360,19 +360,6 @@ export class ModalManager {
});
}
// 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;
}
+4 -103
View File
@@ -1,21 +1,15 @@
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.
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
* and only then invokes the run callback. Post-run reporting lives in
* static/js/components/RematchSummaryModal.js.
*/
export class RematchModalManager {
constructor() {
this._optionsConfirmCallback = null;
this._resultsMatches = [];
}
/**
@@ -73,99 +67,6 @@ export class RematchModalManager {
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();