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) => ({