feat(recipes): add manual checkpoint reconnect for broken recipe entries

Checkpoint entries that cannot be restored by download (deleted,
unresolvable hash, or name-only remnants with no CivitAI identifiers)
now get the same remediation chain LoRAs already had:

- scanner: parameterized reconnect-suggestion ranking, update/restore/
  set-hash-invalid for the checkpoint entry, and clear hashInvalid on
  rematch write-back (was only done for LoRAs)
- persistence/handlers/routes: reconnect/restore/reconnect-suggestions/
  mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/*
- modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline
  form with suggestions, undo for reconnected entries); download
  failures mark the hash invalid only on explicit unresolvable signals
  (not found/deleted/404/410), matching the LoRA rule
- css: checkpoint undo button shares the LoRA undo styles
- i18n: the 14 new keys translated in all 9 locales
This commit is contained in:
Will Miao
2026-08-30 18:02:15 +08:00
parent bce7d1d30c
commit c8b9db5bf4
21 changed files with 2031 additions and 38 deletions
+7 -3
View File
@@ -957,8 +957,10 @@
}
/* Restore icon for manually reconnected entries: its presence on the info
row doubles as the "was reconnected" marker. */
.lora-undo-reconnect {
row doubles as the "was reconnected" marker. Shared by LoRA and
checkpoint entries, which use the same info-row flex layout. */
.lora-undo-reconnect,
.checkpoint-undo-reconnect {
margin-left: auto;
background: none;
border: none;
@@ -973,7 +975,9 @@
}
.lora-undo-reconnect:hover,
.lora-undo-reconnect:focus-visible {
.lora-undo-reconnect:focus-visible,
.checkpoint-undo-reconnect:hover,
.checkpoint-undo-reconnect:focus-visible {
opacity: 1;
color: var(--lora-accent);
background: var(--lora-surface);
+310 -17
View File
@@ -848,6 +848,14 @@ class RecipeModal {
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
if (checkpointContainer) {
// The innerHTML below discards the checkpoint reconnect container;
// tear down its Combobox panel (appended to document.body) first.
const checkpointPanel = checkpointContainer.querySelector(
'.lora-reconnect-container[data-lora-index="checkpoint"]'
);
if (checkpointPanel) {
this._destroyReconnectCombobox(checkpointPanel);
}
checkpointContainer.innerHTML = '';
if (recipe.checkpoint && typeof recipe.checkpoint === 'object') {
checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint);
@@ -1936,7 +1944,9 @@ class RecipeModal {
},
// emptyText only labels the dropdown empty state; the input keeps
// its own translated placeholder from the markup.
emptyText: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
emptyText: String(loraIndex) === 'checkpoint'
? translate('recipes.resources.checkpointReconnectSuggestionsEmpty', {}, 'No matching checkpoints in your local library')
: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
onCommit: (value) => {
this.reconnectLora(loraIndex, value);
},
@@ -1963,7 +1973,10 @@ class RecipeModal {
async _fetchReconnectSuggestions(loraIndex, query) {
const suffix = query ? `?query=${encodeURIComponent(query)}` : '';
const response = await fetch(`/api/lm/recipe/${this.recipeId}/lora/${loraIndex}/reconnect-suggestions${suffix}`);
const targetPath = String(loraIndex) === 'checkpoint'
? 'checkpoint/reconnect-suggestions'
: `lora/${loraIndex}/reconnect-suggestions`;
const response = await fetch(`/api/lm/recipe/${this.recipeId}/${targetPath}${suffix}`);
if (!response.ok) {
return [];
}
@@ -2004,7 +2017,9 @@ class RecipeModal {
if (!suggestions.length) {
const empty = document.createElement('div');
empty.className = 'reconnect-suggestions-empty';
empty.textContent = translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
empty.textContent = String(loraIndex) === 'checkpoint'
? translate('recipes.resources.checkpointReconnectSuggestionsEmpty', {}, 'No matching checkpoints in your local library')
: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
listElement.appendChild(empty);
return;
}
@@ -2061,6 +2076,12 @@ class RecipeModal {
}
async reconnectLora(loraIndex, inputValue) {
// The checkpoint entry reuses the same container/combobox machinery;
// route it to the checkpoint-specific flow (no <lora:...> syntax, no
// lora_index in the payload).
if (String(loraIndex) === 'checkpoint') {
return this.reconnectCheckpoint(inputValue);
}
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
if (!inputValue || !inputValue.trim()) {
@@ -2176,8 +2197,153 @@ class RecipeModal {
}
}
async reconnectCheckpoint(inputValue) {
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
if (!inputValue || !inputValue.trim()) {
this.showReconnectError(container, translate('toast.recipes.enterCheckpointName', {}, 'Please enter a checkpoint name'));
return;
}
try {
// Remove .safetensors extension if present
const fileName = inputValue.trim().replace(/\.safetensors$/, '');
state.loadingManager.showSimpleLoading('Reconnecting checkpoint...');
// Call API to reconnect the checkpoint entry
const response = await fetch('/api/lm/recipe/checkpoint/reconnect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: this.recipeId,
target_name: fileName
})
});
const result = await response.json();
if (result.success) {
// Hide the reconnect input
this.hideReconnectInput(container);
// Update the current recipe with the updated checkpoint data
this.currentRecipe.checkpoint = result.updated_checkpoint;
// Show success message
showToast('toast.recipes.checkpointReconnectedSuccessfully', {}, 'success');
// Same-architecture-family reconnects (e.g. Pony ↔ Illustrious)
// succeed but carry structured mismatch data — warn the user.
if (result.base_model_mismatch) {
showToast(
'toast.recipes.reconnectCheckpointBaseModelMismatch',
{
recipe: result.base_model_mismatch.recipe_base_model,
checkpoint: result.base_model_mismatch.checkpoint_base_model,
},
'warning'
);
}
// Refresh modal to show updated content
setTimeout(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
checkpoint: this.currentRecipe.checkpoint
});
} else {
this.showReconnectError(container, translate('toast.recipes.checkpointReconnectFailed', { message: result.error }, `Error reconnecting checkpoint: ${result.error}`));
}
} catch (error) {
console.error('Error reconnecting checkpoint:', error);
this.showReconnectError(container, translate('toast.recipes.checkpointReconnectFailed', { message: error.message }, `Error reconnecting checkpoint: ${error.message}`));
} finally {
state.loadingManager.hide();
}
}
async restoreCheckpoint() {
try {
state.loadingManager.showSimpleLoading('Restoring checkpoint...');
const response = await fetch('/api/lm/recipe/checkpoint/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: this.recipeId
})
});
const result = await response.json();
if (result.success) {
// Swap the entry back to its pre-reconnect state
this.currentRecipe.checkpoint = result.updated_checkpoint;
showToast('toast.recipes.checkpointRestored', {}, 'success');
setTimeout(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
checkpoint: this.currentRecipe.checkpoint
});
} else {
showToast('toast.recipes.checkpointRestoreFailed', { message: result.error }, 'error');
}
} catch (error) {
console.error('Error restoring checkpoint:', error);
showToast('toast.recipes.checkpointRestoreFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
}
}
async markCheckpointHashInvalid() {
const recipeId =
this.recipeId ||
extractRecipeId(this.listFilePath || this.currentRecipe?.file_path);
if (!recipeId) {
return;
}
try {
await fetch('/api/lm/recipe/checkpoint/mark-hash-invalid', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: recipeId,
}),
});
if (this.currentRecipe?.checkpoint) {
this.currentRecipe.checkpoint.hashInvalid = true;
this.syncResourcesSection(this.currentRecipe);
}
} catch (error) {
console.warn('Failed to mark checkpoint hash invalid:', error);
}
}
renderCheckpoint(checkpoint) {
const existsLocally = !!checkpoint.inLibrary;
const isDeleted = !!checkpoint.isDeleted;
const hashInvalid = !!checkpoint.hashInvalid;
// "Broken" = cannot be restored by downloading: explicitly marked
// deleted, an unresolvable hash, or an entry with no CivitAI
// identifiers at all (a name-only remnant that never had a version
// id to query — it can only be fixed by reconnecting a local model).
const broken = isDeleted
|| hashInvalid
|| (!existsLocally && !this.canDownloadCheckpoint(checkpoint));
const localPath = checkpoint.localPath || '';
const previewUrl = checkpoint.preview_url || checkpoint.thumbnailUrl || '/loras_static/images/no-preview.png';
const isPreviewVideo = typeof previewUrl === 'string' && previewUrl.toLowerCase().endsWith('.mp4');
@@ -2194,18 +2360,38 @@ class RecipeModal {
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
// Status badge: pure indicator with a tooltip, mirroring the LoRA
// items and the versions-tab badge pattern. The header carries only
// the badge; every action lives in the bottom action row.
const badge = existsLocally ? `
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
</div>
` : `
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
</div>
`;
// items and the versions-tab badge pattern. Deleted / unresolvable
// hash states render the same fixable-broken badges as LoRA entries.
let badge;
if (existsLocally) {
badge = `
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
</div>
`;
} else if (isDeleted) {
badge = `
<div class="deleted-badge" title="${escapeHtml(translate('recipes.resources.checkpointDeletedTooltip', {}, 'This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model'))}">
<i class="fas fa-trash-alt" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
</div>
`;
} else if (hashInvalid) {
badge = `
<div class="invalid-hash-badge" title="${escapeHtml(translate('recipes.resources.checkpointHashInvalidTooltip', {}, 'This checkpoint hash cannot be resolved on CivitAI - the model may have been updated'))}">
<i class="fas fa-question-circle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.hashInvalid', {}, 'Unresolvable Hash'))}
</div>
`;
} else {
badge = `
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
</div>
`;
}
// Action row: broken (deleted / unresolvable hash) entries offer the
// reconnect affordance instead of the download button — same rule as
// the LoRA items. A local checkpoint only exposes "Send to ComfyUI".
const actions = [];
if (existsLocally && localPath) {
actions.push(`
@@ -2214,6 +2400,16 @@ class RecipeModal {
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
</button>
`);
} else if (broken) {
const reconnectLabel = translate('recipes.resources.reconnectCheckpoint', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectCheckpointTooltip', {}, 'Reconnect with a local checkpoint');
actions.push(`
<button type="button" class="resource-action ghost compact checkpoint-reconnect"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) {
actions.push(`
<button class="resource-action primary compact checkpoint-download">
@@ -2228,15 +2424,56 @@ class RecipeModal {
: '';
// Civitai link lives inline with the title, same as LoRA items.
const titleLink = this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint));
// Skipped for deleted models: their source page is gone.
const titleLink = isDeleted
? ''
: this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint));
// Only in-library checkpoints are row-navigable; make it keyboard-accessible.
const rowA11yAttributes = existsLocally
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openCheckpointDetails', { name: checkpointName }, `View ${checkpointName} in the model library`))}"`
: '';
// A reconnect snapshot marks a manually reconnected entry. The restore
// icon on the info row doubles as that marker (mirrors LoRA entries).
let undoReconnectIcon = '';
if (existsLocally && checkpoint.reconnectSnapshot) {
const previousName = checkpoint.reconnectSnapshot.name
|| checkpoint.reconnectSnapshot.file_name
|| checkpoint.reconnectSnapshot.modelName
|| '';
const undoLabel = translate('recipes.resources.undoReconnect', {}, 'Undo');
const undoTooltip = previousName
? translate('recipes.resources.undoReconnectTooltipNamed', { name: previousName }, `Restore to ${previousName} (the association before reconnecting)`)
: translate('recipes.resources.undoReconnectTooltip', {}, 'Restore the association this entry had before reconnecting');
undoReconnectIcon = `
<button type="button" class="checkpoint-undo-reconnect"
title="${escapeHtml(undoTooltip)}" aria-label="${escapeHtml(undoTooltip)}">
<i class="fas fa-rotate-left" aria-hidden="true"></i>
</button>
`;
}
// Inline reconnect form for broken entries, sharing the LoRA
// container structure/classes and the combobox interaction.
const reconnectContainer = broken ? `
<div class="lora-reconnect-container" data-lora-index="checkpoint">
<div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.checkpointReconnectInstructions', {}, 'Enter checkpoint name to reconnect:'))}</p>
</div>
<div class="reconnect-form">
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.checkpointReconnectPlaceholder', {}, 'Enter checkpoint name'))}">
<div class="reconnect-actions">
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
</div>
</div>
<div class="reconnect-suggestions"></div>
<p class="reconnect-error" role="alert"></p>
</div>` : '';
return `
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : 'missing-locally'}"${rowA11yAttributes}>
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : (isDeleted ? 'is-deleted' : 'missing-locally')}"${rowA11yAttributes}>
<div class="recipe-lora-thumbnail">
${previewMedia}
</div>
@@ -2252,9 +2489,11 @@ class RecipeModal {
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
${modelTypeLabel ? `<span class="checkpoint-type-text">${modelTypeLabel}</span>` : ''}
${undoReconnectIcon}
</div>
${actionsRow}
</div>
${reconnectContainer}
</div>
`;
}
@@ -2275,6 +2514,27 @@ class RecipeModal {
await this.downloadCheckpoint(checkpoint, downloadBtn);
});
}
// Deferred wiring can run again after a hydration re-render while the
// latest DOM is already in place; a data flag prevents stacking
// duplicate handlers (same pattern as the LoRA item actions).
const reconnectBtn = container.querySelector('.checkpoint-reconnect');
if (reconnectBtn && reconnectBtn.dataset.wired !== 'true') {
reconnectBtn.dataset.wired = 'true';
reconnectBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showReconnectInput('checkpoint');
});
}
const undoBtn = container.querySelector('.checkpoint-undo-reconnect');
if (undoBtn && undoBtn.dataset.wired !== 'true') {
undoBtn.dataset.wired = 'true';
undoBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.restoreCheckpoint();
});
}
}
setupCheckpointNavigation(container, checkpoint) {
@@ -2357,7 +2617,14 @@ class RecipeModal {
async downloadCheckpoint(checkpoint, button) {
if (!this.canDownloadCheckpoint(checkpoint)) {
showToast('toast.recipes.missingCheckpointInfo', {}, 'error');
// No resolvable CivitAI identifiers for this entry. A hash-only
// checkpoint is not downloadable through the version downloader —
// point the user at the reconnect flow instead.
if (this._getCheckpointHash(checkpoint)) {
showToast('toast.recipes.checkpointDownloadUnavailable', {}, 'warning');
} else {
showToast('toast.recipes.missingCheckpointInfo', {}, 'error');
}
return;
}
@@ -2381,6 +2648,17 @@ class RecipeModal {
);
if (success) {
await this.refreshResourcesAfterDownload();
return;
}
// Business-level download failure (the request completed but the
// backend rejected it). Enroll the entry in the rematch/reconnect
// remediation flow only when the failure is clearly unresolvable
// (model removed or version gone on CivitAI) — the same signal
// rule as the LoRA path, which marks the hash invalid only when
// resolving it on CivitAI returns "not found". Transient
// failures (network, 5xx) leave the entry untouched.
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
await this.markCheckpointHashInvalid();
}
} catch (error) {
console.error('Error downloading checkpoint:', error);
@@ -2392,6 +2670,21 @@ class RecipeModal {
}
}
/**
* Decide whether a download failure means the model is unrecoverable.
*
* Mirrors the LoRA behaviour: 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.
*/
_isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
}
getResourceCivitaiUrl(resource) {
if (!resource) {
return null;
+4 -1
View File
@@ -1182,10 +1182,13 @@ export class DownloadManager {
if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
const errorMessage = response?.error || 'Unknown error';
// Always record the latest failure so callers can distinguish
// an unresolvable model (not found / deleted) from a transient
// transport failure; the summary flow below may or may not run.
this._lastDownloadError = errorMessage;
// When the caller aggregates failures itself (multi-file
// loop), just record the error and return (#1058).
if (suppressFailureSummary) {
this._lastDownloadError = errorMessage;
return false;
}
// A file-level "already in library" rejection is an expected