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:
Will Miao
2026-09-09 06:59:54 +08:00
parent e747946f7a
commit 1b5cbbbaa0
33 changed files with 2103 additions and 76 deletions
+180
View File
@@ -592,3 +592,183 @@ button:disabled,
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Options Modal */
#rematchOptionsModal .modal-body {
padding: var(--space-3);
}
#rematchOptionsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
/* Selectable option card — click anywhere toggles the checkbox (label wrap).
Checkmark follows the batch-import modal's custom checkbox pattern. */
#rematchOptionsModal .rematch-option-card {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-subtle);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
user-select: none;
transition: var(--transition-base);
}
#rematchOptionsModal .rematch-option-card:hover {
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
border-color: var(--lora-accent);
background: oklch(from var(--lora-accent) l c h / 0.08);
}
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
#rematchOptionsModal .rematch-option-checkmark {
width: 18px;
height: 18px;
margin-top: 1px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
background: var(--bg-color);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
#rematchOptionsModal .rematch-option-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-color);
min-width: 0;
}
#rematchOptionsModal .rematch-option-title {
font-weight: 600;
font-size: 0.95em;
}
#rematchOptionsModal .rematch-option-caveat {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: 0.85em;
line-height: 1.4;
color: var(--text-muted);
}
#rematchOptionsModal .rematch-option-caveat i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch L4 Results Modal */
#rematchResultsModal .modal-body {
padding: var(--space-3);
}
#rematchResultsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
#rematchResultsModal .rematch-results-preview {
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2) var(--space-3);
}
#rematchResultsModal .rematch-results-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 320px;
overflow-y: auto;
}
#rematchResultsModal .rematch-results-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
#rematchResultsModal .rematch-results-row:last-child {
border-bottom: none;
}
#rematchResultsModal .rematch-results-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
#rematchResultsModal .rematch-results-entry {
font-weight: 500;
color: var(--text-color);
overflow-wrap: anywhere;
}
#rematchResultsModal .rematch-results-file {
color: var(--text-muted);
overflow-wrap: anywhere;
}
#rematchResultsModal .rematch-results-recipe {
font-size: 0.85em;
opacity: 0.7;
color: var(--text-muted);
overflow-wrap: anywhere;
}
#rematchResultsModal .rematch-results-row.undone .rematch-results-info {
text-decoration: line-through;
opacity: 0.6;
}
#rematchResultsModal .rematch-results-undo {
flex-shrink: 0;
}
+9 -4
View File
@@ -677,7 +677,7 @@ export class RecipeSidebarApiClient {
};
}
async rematchBulkModels(filePaths) {
async rematchBulkModels(filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
@@ -690,14 +690,19 @@ export class RecipeSidebarApiClient {
throw new Error('No recipe IDs could be derived from file paths');
}
const body = { recipe_ids: recipeIds };
// Only sent when opted in — the strict body stays exactly
// {recipe_ids} for backward compatibility.
if (options.relaxed === true) {
body.relaxed = true;
}
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
body: JSON.stringify(body),
});
const result = await response.json();
@@ -4,6 +4,7 @@ import { translate } from '../../utils/i18nHelpers.js';
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';
export class GlobalContextMenu extends BaseContextMenu {
constructor() {
@@ -368,6 +369,18 @@ export class GlobalContextMenu extends BaseContextMenu {
return;
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
});
}
async _startRematch(menuItem, relaxed = false) {
if (this._rematchInProgress) {
return;
}
this._rematchInProgress = true;
menuItem?.classList.add('disabled');
@@ -384,6 +397,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const response = await fetch('/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
@@ -458,6 +472,11 @@ export class GlobalContextMenu extends BaseContextMenu {
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') {
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
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 { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu {
@@ -303,11 +304,22 @@ export class RecipeContextMenu extends BaseContextMenu {
// Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath;
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
scope: 'single',
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
});
}
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
try {
showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
@@ -330,6 +342,11 @@ 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.
+20 -21
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -1078,8 +1078,9 @@ class RecipeModal {
// Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading
// reconnecting a local LoRA is the only remediation.
// identifiers at all cannot be fixed by downloading, so no
// download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
@@ -1180,7 +1181,7 @@ class RecipeModal {
</div>
${actionsRow}
</div>
${needsReconnect ? `
${!existsLocally ? `
<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>
@@ -2853,11 +2854,7 @@ class RecipeModal {
* the model cannot be resolved — never for transient transport errors.
*/
_isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
return isUnresolvableDownloadError(message);
}
getResourceCivitaiUrl(resource) {
@@ -2915,19 +2912,9 @@ class RecipeModal {
}
const controls = [];
if (needsReconnect) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else {
if (!needsReconnect) {
// needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional.
// download action is unconditional in this branch.
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(`
@@ -2938,6 +2925,18 @@ class RecipeModal {
</button>
`);
}
// Reconnect is always offered for missing entries — when the LoRA
// already exists locally under a different hash, downloading first
// just to flip the button would be a waste.
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
}
+2
View File
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
import { rematchModalManager } from './managers/RematchModalManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
@@ -68,6 +69,7 @@ export class AppCore {
window.doctorManager = doctorManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
window.rematchModalManager = rematchModalManager;
// Initialize UI components
window.headerManager = new HeaderManager();
+17 -1
View File
@@ -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
+26
View File
@@ -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;
}
+171
View File
@@ -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();
+17
View File
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Decide whether a download failure means the model is unrecoverable.
*
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
* only set when CivitAI explicitly says the model cannot be resolved — never
* for transient transport errors (network, 5xx).
* @param {*} message - The error message carried by the failed download
* @returns {boolean}
*/
export function isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);