fix(recipes): align LoRA reconnect affordances with checkpoint rules

- Offer reconnect for name-only LoRA entries with no CivitAI
  identifiers, matching the checkpoint "broken" classification
  instead of rendering no action at all
- Mark a LoRA hash-invalid when a direct (modelId/versionId) download
  fails with a clearly unresolvable error, mirroring the checkpoint
  path; transient failures leave the entry untouched
This commit is contained in:
Will Miao
2026-08-30 18:33:24 +08:00
parent c8b9db5bf4
commit d761ac77f7
2 changed files with 126 additions and 30 deletions
+26 -14
View File
@@ -922,6 +922,13 @@ class RecipeModal {
const isDeleted = lora.isDeleted; const isDeleted = lora.isDeleted;
const loraIndex = loras.indexOf(lora); const loraIndex = loras.indexOf(lora);
// 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.
const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
// Status badges are pure indicators (consistent with the // Status badges are pure indicators (consistent with the
// versions-tab pattern): they never carry click behavior, // versions-tab pattern): they never carry click behavior,
// only a tooltip. Remediation lives in the action row below. // only a tooltip. Remediation lives in the action row below.
@@ -948,7 +955,7 @@ class RecipeModal {
</div>`; </div>`;
} }
const actionsRow = this.renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }); const actionsRow = this.renderLoraItemActions(loraIndex, { existsLocally, needsReconnect });
// The Civitai link belongs to the model name (it answers // The Civitai link belongs to the model name (it answers
// "what is this"), so it sits inline in the title — the same // "what is this"), so it sits inline in the title — the same
@@ -1019,7 +1026,7 @@ class RecipeModal {
</div> </div>
${actionsRow} ${actionsRow}
</div> </div>
${isDeleted || lora.hashInvalid ? ` ${needsReconnect ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}"> <div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions"> <div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p> <p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
@@ -2654,9 +2661,8 @@ class RecipeModal {
// backend rejected it). Enroll the entry in the rematch/reconnect // backend rejected it). Enroll the entry in the rematch/reconnect
// remediation flow only when the failure is clearly unresolvable // remediation flow only when the failure is clearly unresolvable
// (model removed or version gone on CivitAI) — the same signal // (model removed or version gone on CivitAI) — the same signal
// rule as the LoRA path, which marks the hash invalid only when // rule as the LoRA path. Transient failures (network, 5xx) leave
// resolving it on CivitAI returns "not found". Transient // the entry untouched.
// failures (network, 5xx) leave the entry untouched.
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) { if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
await this.markCheckpointHashInvalid(); await this.markCheckpointHashInvalid();
} }
@@ -2729,7 +2735,7 @@ class RecipeModal {
`; `;
} }
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) { renderLoraItemActions(loraIndex, { existsLocally, needsReconnect }) {
// In-library LoRAs need no remediation: the badge and the local path // In-library LoRAs need no remediation: the badge and the local path
// already tell the full story. (The restore affordance for manually // already tell the full story. (The restore affordance for manually
// reconnected entries lives on the info row, not here.) // reconnected entries lives on the info row, not here.)
@@ -2738,7 +2744,7 @@ class RecipeModal {
} }
const controls = []; const controls = [];
if (isDeleted || lora.hashInvalid) { if (needsReconnect) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(` controls.push(`
@@ -2749,7 +2755,8 @@ class RecipeModal {
</button> </button>
`); `);
} else { } else {
if (this.canDownloadLora(lora)) { // needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional.
const downloadLabel = translate('recipes.resources.download', {}, 'Download'); const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(` controls.push(`
@@ -2760,13 +2767,8 @@ class RecipeModal {
</button> </button>
`); `);
} }
}
const markup = controls.filter(Boolean).join(''); return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
if (!markup) {
return '';
}
return `<div class="recipe-lora-actions">${markup}</div>`;
} }
setupLoraItemActions() { setupLoraItemActions() {
@@ -2928,6 +2930,16 @@ class RecipeModal {
); );
if (success) { if (success) {
await this.refreshResourcesAfterDownload(); await this.refreshResourcesAfterDownload();
return;
}
// Business-level download failure (the request completed but the
// backend rejected it). Mark the hash invalid — and thereby offer
// the reconnect affordance — only when the failure is clearly
// unresolvable (model removed or version gone on CivitAI), the
// same signal rule as the checkpoint path. Transient failures
// (network, 5xx) leave the entry untouched.
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
await this.markLoraHashInvalid(loraIndex);
} }
} catch (error) { } catch (error) {
if (!hasDirectIds) { if (!hasDirectIds) {
@@ -92,11 +92,13 @@ vi.mock('../../../static/js/api/apiConfig.js', () => ({
}, },
})); }));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ const downloadManagerMock = {
downloadManager: {
downloadVersionWithDefaults: downloadVersionWithDefaultsMock, downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
_lastDownloadError: '', _lastDownloadError: '',
}, };
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
downloadManager: downloadManagerMock,
})); }));
function recipeModalFixture() { function recipeModalFixture() {
@@ -193,6 +195,7 @@ describe('RecipeModal resource item interactions', () => {
// the shared mocks to their defaults explicitly. // the shared mocks to their defaults explicitly.
downloadVersionWithDefaultsMock.mockReset(); downloadVersionWithDefaultsMock.mockReset();
downloadVersionWithDefaultsMock.mockResolvedValue(undefined); downloadVersionWithDefaultsMock.mockResolvedValue(undefined);
downloadManagerMock._lastDownloadError = '';
fetchRecipeDetailsMock.mockReset(); fetchRecipeDetailsMock.mockReset();
// Hydration re-fetches the recipe right after render; resolving an empty // Hydration re-fetches the recipe right after render; resolving an empty
// object would delete currentRecipe.loras and wipe the list, so resolve // object would delete currentRecipe.loras and wipe the list, so resolve
@@ -429,14 +432,20 @@ describe('RecipeModal resource item interactions', () => {
expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled(); expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled();
}); });
it('renders no action row when neither identifiers nor hash are available', async () => { it('offers reconnect for name-only LoRAs with no CivitAI identifiers', async () => {
const recipeModal = await createRecipeModal(); const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources); recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const mysteryItem = document.querySelector('[data-lora-index="4"]'); const mysteryItem = document.querySelector('[data-lora-index="4"]');
expect(mysteryItem.querySelector('.lora-download')).toBeNull(); expect(mysteryItem.querySelector('.lora-download')).toBeNull();
// No actions at all -> no empty action row taking vertical space const reconnectButton = mysteryItem.querySelector('.lora-reconnect');
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull(); expect(reconnectButton).not.toBeNull();
reconnectButton.click();
const container = mysteryItem.querySelector('.lora-reconnect-container');
expect(container).not.toBeNull();
expect(container.classList.contains('active')).toBe(true);
// The name-fallback search link still sits inline in the title // The name-fallback search link still sits inline in the title
const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link'); const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
@@ -444,6 +453,81 @@ describe('RecipeModal resource item interactions', () => {
expect(link.href).toContain('query=Mystery%20LoRA'); expect(link.href).toContain('query=Mystery%20LoRA');
}); });
it('marks the entry hash-invalid when a direct download fails with an unresolvable error', async () => {
const recipeModal = await createRecipeModal();
const requests = [];
// Deep copy so the mark step mutating loras[1].hashInvalid does not
// leak into the shared fixture used by later tests.
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
downloadManagerMock._lastDownloadError = 'Model not found';
downloadVersionWithDefaultsMock.mockResolvedValue(false);
global.fetch = vi.fn(async (url, options) => {
requests.push({ url: String(url), options });
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(isolatedRecipe);
await flushWiring();
// missingLora carries direct identifiers, so no hash-resolution round
// trip happens before the download attempt.
const missingItem = document.querySelector('[data-lora-index="1"]');
missingItem.querySelector('.lora-download').click();
await vi.waitFor(() => {
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
});
await vi.waitFor(() => {
expect(
requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid'))
).toBe(true);
});
const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid'));
expect(JSON.parse(markRequest.options.body)).toEqual({
recipe_id: 'recipe-resources',
lora_index: 1,
});
// The re-rendered entry swaps the download action for the reconnect one
await vi.waitFor(() => {
const item = document.querySelector('[data-lora-index="1"]');
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
expect(item.querySelector('.lora-download')).toBeNull();
expect(item.querySelector('.invalid-hash-badge')).not.toBeNull();
});
});
it('leaves the entry untouched when a direct download fails transiently', async () => {
const recipeModal = await createRecipeModal();
const requests = [];
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
downloadManagerMock._lastDownloadError = 'Connection timed out';
downloadVersionWithDefaultsMock.mockResolvedValue(false);
global.fetch = vi.fn(async (url, options) => {
requests.push({ url: String(url), options });
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(isolatedRecipe);
await flushWiring();
const missingItem = document.querySelector('[data-lora-index="1"]');
missingItem.querySelector('.lora-download').click();
await vi.waitFor(() => {
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
});
// Give any (unexpected) mark request a chance to fire
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
// The entry keeps the download action and never flips to reconnect
const item = document.querySelector('[data-lora-index="1"]');
expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull();
});
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => { it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
const recipeModal = await createRecipeModal(); const recipeModal = await createRecipeModal();
global.fetch = vi.fn(async (url) => ({ global.fetch = vi.fn(async (url) => ({