diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 95a32b14..38b0c663 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -922,6 +922,13 @@ class RecipeModal { const isDeleted = lora.isDeleted; 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 // versions-tab pattern): they never carry click behavior, // only a tooltip. Remediation lives in the action row below. @@ -948,7 +955,7 @@ class RecipeModal { `; } - 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 // "what is this"), so it sits inline in the title — the same @@ -1019,7 +1026,7 @@ class RecipeModal { ${actionsRow} - ${isDeleted || lora.hashInvalid ? ` + ${needsReconnect ? `

${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}

@@ -2654,9 +2661,8 @@ class RecipeModal { // 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. + // rule as the LoRA path. Transient failures (network, 5xx) leave + // the entry untouched. if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) { 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 // already tell the full story. (The restore affordance for manually // reconnected entries lives on the info row, not here.) @@ -2738,7 +2744,7 @@ class RecipeModal { } const controls = []; - if (isDeleted || lora.hashInvalid) { + if (needsReconnect) { const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); controls.push(` @@ -2749,24 +2755,20 @@ class RecipeModal { `); } else { - if (this.canDownloadLora(lora)) { - const downloadLabel = translate('recipes.resources.download', {}, 'Download'); - const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); - controls.push(` - - `); - } + // needsReconnect already implies canDownloadLora() here, so the + // download action is unconditional. + const downloadLabel = translate('recipes.resources.download', {}, 'Download'); + const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); + controls.push(` + + `); } - const markup = controls.filter(Boolean).join(''); - if (!markup) { - return ''; - } - return `
${markup}
`; + return `
${controls.join('')}
`; } setupLoraItemActions() { @@ -2928,6 +2930,16 @@ class RecipeModal { ); if (success) { 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) { if (!hasDirectIds) { diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js index b2c7fd54..e85d166d 100644 --- a/tests/frontend/components/recipeModal.resourceItems.test.js +++ b/tests/frontend/components/recipeModal.resourceItems.test.js @@ -92,11 +92,13 @@ vi.mock('../../../static/js/api/apiConfig.js', () => ({ }, })); +const downloadManagerMock = { + downloadVersionWithDefaults: downloadVersionWithDefaultsMock, + _lastDownloadError: '', +}; + vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ - downloadManager: { - downloadVersionWithDefaults: downloadVersionWithDefaultsMock, - _lastDownloadError: '', - }, + downloadManager: downloadManagerMock, })); function recipeModalFixture() { @@ -193,6 +195,7 @@ describe('RecipeModal resource item interactions', () => { // the shared mocks to their defaults explicitly. downloadVersionWithDefaultsMock.mockReset(); downloadVersionWithDefaultsMock.mockResolvedValue(undefined); + downloadManagerMock._lastDownloadError = ''; fetchRecipeDetailsMock.mockReset(); // Hydration re-fetches the recipe right after render; resolving an empty // object would delete currentRecipe.loras and wipe the list, so resolve @@ -429,14 +432,20 @@ describe('RecipeModal resource item interactions', () => { 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(); recipeModal.showRecipeDetails(recipeWithResources); + await flushWiring(); const mysteryItem = document.querySelector('[data-lora-index="4"]'); expect(mysteryItem.querySelector('.lora-download')).toBeNull(); - // No actions at all -> no empty action row taking vertical space - expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull(); + const reconnectButton = mysteryItem.querySelector('.lora-reconnect'); + 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 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'); }); + 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 () => { const recipeModal = await createRecipeModal(); global.fetch = vi.fn(async (url) => ({