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
@@ -5,6 +5,7 @@ import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
export class GlobalContextMenu extends BaseContextMenu {
constructor() {
@@ -425,58 +426,37 @@ export class GlobalContextMenu extends BaseContextMenu {
const recipes = p.matched_recipes ?? p.rematched ?? 0;
const failures = p.errors || 0;
const unresolved = p.unresolved_entries ?? 0;
if (entries > 0) {
const successKey = failures > 0
? 'globalContextMenu.rematchRecipes.successErrors'
: 'globalContextMenu.rematchRecipes.success';
const successText = failures > 0
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
: `Matched ${entries} entries across ${recipes} recipes.`;
progressUI?.complete(translate(
successKey,
{ count: recipes, recipes, entries, failures },
successText
));
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
} else if (failures > 0) {
// Nothing matched and at least one recipe
// errored — "no rematch needed" would be
// actively misleading here.
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.allFailed',
{ total: p.total, recipes, entries, failures },
`Rematch failed for ${failures} of ${p.total} recipes.`
));
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
} else if (unresolved > 0) {
// Entries existed but have no local model —
// expected for models deleted from Civitai;
// informational, not an error.
const unresolvedRecipes = p.unresolved_recipes ?? 0;
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.noMatch',
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
} else {
// Everything was skipped (nothing to do).
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
// Complete no-op (nothing matched, nothing
// unresolved, no errors) keeps the lightweight
// toast; anything else opens the post-run summary
// modal.
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
if (isNoop) {
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.success',
{ count: recipes, recipes, entries, failures },
`Matched ${entries} entries across ${recipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
} else {
progressUI?.complete();
showRematchSummary({
scope: 'global',
total: p.total || 0,
matchedRecipes: recipes,
matchedEntries: entries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: unresolved,
skipped: p.skipped || 0,
errors: failures,
l4Matches,
});
}
// Refresh recipes page if active
if (window.recipesPage) {
window.recipesPage.refresh();
}
// Filename-level (L4) matches are imprecise —
// always surface them for review/undo.
if (Array.isArray(p.l4_matches) && p.l4_matches.length > 0) {
rematchModalManager.showResultsModal(p.l4_matches);
}
} else if (p.status === 'error') {
throw new Error(p.error || 'Rematch failed');
} else if (p.status === 'cancelled') {
@@ -488,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
));
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
// A cancelled run still reports partial results
// via the summary modal (marked as cancelled).
showRematchSummary({
scope: 'global',
cancelled: true,
total: p.total || 0,
matchedRecipes: cancelledRecipes,
matchedEntries: cancelledEntries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: p.unresolved_entries ?? 0,
skipped: p.skipped || 0,
errors: p.errors || 0,
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
});
if (window.recipesPage) {
window.recipesPage.refresh();
}
}
} else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up
@@ -7,6 +7,7 @@ import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu {
@@ -326,15 +327,14 @@ export class RecipeContextMenu extends BaseContextMenu {
if (result.success) {
const matchedEntries = result.matched_entries || result.rematched || 0;
const failures = result.errors || 0;
const unresolvedEntries = result.unresolved_entries || 0;
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (matchedEntries > 0) {
const toastKey = failures > 0
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
failures > 0 ? 'warning' : 'success'
);
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
@@ -342,21 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
// 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 if (result.unresolved_entries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
'info'
);
} else {
}
if (isNoop) {
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
} else {
showRematchSummary({
scope: 'single',
total: 1,
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
matchedEntries,
unresolvedRecipes: result.unresolved_recipes || 0,
unresolvedEntries,
skipped: result.skipped || 0,
errors: failures,
l4Matches,
});
}
} else {
throw new Error(result.error || 'Rematch failed');