From e2d85a0a217d799d8f6f98b82b2dae2462bdce81 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 6 Sep 2026 19:05:07 +0800 Subject: [PATCH] fix(recipes): allow download for version-only recipe LoRAs (no modelId/hash) Page-imported recipes can carry an exact CivitAI modelVersionId but no modelId and no hash (CivitAI exposes no sha256 for e.g. Krea versions). canDownloadLora() required (modelId && versionId) or a hash, so such entries were misclassified as unrepairable and offered Reconnect instead of Download. - canDownloadLora: treat a bare version id as downloadable (it uniquely pins the file; the model id is resolved on demand at download time). A model id without an exact version id stays non-downloadable to avoid silently grabbing the latest version. - resolveLoraDownloadIdentifiers: when a hash is absent but a version id exists, resolve the owning model id via /civitai/model/version/{id} (same endpoint the bulk download missing flow uses). Hash-only and direct (modelId+versionId) paths are unchanged. --- static/js/components/RecipeModal.js | 57 ++++++++++++----- .../recipeModal.resourceItems.test.js | 62 +++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 01afe5c8..140e0e4b 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -2877,12 +2877,14 @@ class RecipeModal { canDownloadLora(lora) { if (!lora) return false; - const modelId = lora.modelId || lora.modelID || lora.model_id; const versionId = lora.id || lora.modelVersionId; - // Direct download needs both identifiers; a hash alone is enough - // because downloadRecipeLora resolves it to a version on demand — - // the same fallback the bulk "download missing" flow uses. - return !!((modelId && versionId) || lora.hash); + // A bare CivitAI version id is enough: it uniquely pins the exact + // file, and downloadRecipeLora resolves the owning model id from the + // version on demand (the same fallback the bulk "download missing" + // flow uses). A hash alone is likewise sufficient. A model id without + // an exact version id is NOT enough — downloading the model's latest + // version could silently mismatch the recipe's pinned version. + return !!(versionId || lora.hash); } renderCivitaiLink(url) { @@ -2991,6 +2993,9 @@ class RecipeModal { * Resolve the Civitai model/version identifiers needed for download. * Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve * it through the same endpoint the bulk "download missing" flow uses. + * Version-only entries (page-imported recipes whose CivitAI version has + * no sha256) are resolved through the version endpoint, which returns + * the owning model id. */ async resolveLoraDownloadIdentifiers(lora) { let modelId = lora.modelId || lora.modelID || lora.model_id; @@ -3001,21 +3006,41 @@ class RecipeModal { return { modelId, versionId, versionName }; } - if (!lora.hash) { - return null; + // Hash-only entries (PNG/recipe-JSON imports): resolve the owning + // model/version through the same endpoint the bulk "download + // missing" flow uses. + if (lora.hash) { + const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`); + const versionInfo = await response.json(); + if (versionInfo?.error) { + return null; + } + + modelId = versionInfo.modelId || versionInfo.model?.id; + versionId = versionInfo.id; + versionName = versionInfo.name || versionName; + + return modelId && versionId ? { modelId, versionId, versionName } : null; } - const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`); - const versionInfo = await response.json(); - if (versionInfo?.error) { - return null; + // Version-only entries (page-imported recipes whose CivitAI versions + // expose no sha256): the version id still pins the exact file, so + // resolve the owning model id from the version endpoint on demand. + if (versionId) { + const response = await fetch(`/api/lm/loras/civitai/model/version/${versionId}`); + const versionInfo = await response.json(); + if (!versionInfo || versionInfo?.error === 'Model not found') { + return null; + } + + modelId = versionInfo.modelId || versionInfo.model?.id; + versionId = versionInfo.id || versionId; + versionName = versionInfo.name || versionName; + + return modelId && versionId ? { modelId, versionId, versionName } : null; } - modelId = versionInfo.modelId || versionInfo.model?.id; - versionId = versionInfo.id; - versionName = versionInfo.name || versionName; - - return modelId && versionId ? { modelId, versionId, versionName } : null; + return null; } /** diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js index fa0741fe..18cbb45a 100644 --- a/tests/frontend/components/recipeModal.resourceItems.test.js +++ b/tests/frontend/components/recipeModal.resourceItems.test.js @@ -153,6 +153,16 @@ const hashInvalidLora = { hashInvalid: true, }; +// Mirrors the shape served for page-imported recipes whose CivitAI version +// exposes no sha256: an exact modelVersionId but no modelId and no hash. +const versionOnlyLora = { + name: 'version-lora', + modelName: 'Version Only LoRA', + inLibrary: false, + modelVersionId: 3221586, + modelVersionName: 'V1 KREA-2', +}; + const recipeWithResources = { id: 'recipe-resources', file_path: '/recipes/resources.json', @@ -171,6 +181,7 @@ const recipeWithResources = { hashInvalidLora, { name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false }, hashOnlyLora, + versionOnlyLora, ], }; @@ -281,6 +292,57 @@ describe('RecipeModal resource item interactions', () => { ); }); + it('renders a download action (not reconnect) for a version-only LoRA', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeWithResources); + await flushWiring(); + + const item = document.querySelector('[data-lora-index="6"]'); + expect(item).not.toBeNull(); + expect(item.classList.contains('missing-locally')).toBe(true); + // Missing from the local library (badge) but still downloadable by its + // exact CivitAI version id, so the row offers Download, not Reconnect. + expect(item.querySelector('.missing-badge')).not.toBeNull(); + expect(item.querySelector('.lora-download')).not.toBeNull(); + expect(item.querySelector('.lora-reconnect')).toBeNull(); + }); + + it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => { + const recipeModal = await createRecipeModal(); + const requests = []; + // Isolated copy keeps mutations out of the shared fixture. + const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); + fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); + global.fetch = vi.fn(async (url) => { + requests.push(String(url)); + if (String(url).includes('/civitai/model/version/3221586')) { + return { + ok: true, + json: async () => ({ id: 3221586, modelId: 56789, name: 'V1 KREA-2' }), + }; + } + return { ok: true, json: async () => ({}) }; + }); + recipeModal.showRecipeDetails(isolatedRecipe); + await flushWiring(); + + const item = document.querySelector('[data-lora-index="6"]'); + item.querySelector('.lora-download').click(); + + await vi.waitFor(() => { + expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1); + }); + expect( + requests.some(u => u.includes('/civitai/model/version/3221586')) + ).toBe(true); + expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith( + 'loras', + 56789, + 3221586, + expect.objectContaining({ source: 'recipe-modal' }) + ); + }); + it('does not navigate when a missing LoRA row is clicked', async () => { const recipeModal = await createRecipeModal(); const navigateSpy = vi