mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
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:
@@ -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,24 +2755,20 @@ class RecipeModal {
|
|||||||
</button>
|
</button>
|
||||||
`);
|
`);
|
||||||
} else {
|
} else {
|
||||||
if (this.canDownloadLora(lora)) {
|
// needsReconnect already implies canDownloadLora() here, so the
|
||||||
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
// download action is unconditional.
|
||||||
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||||
controls.push(`
|
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||||
<button type="button" class="resource-action primary compact lora-download" data-lora-index="${loraIndex}"
|
controls.push(`
|
||||||
title="${escapeHtml(downloadTooltip)}" aria-label="${escapeHtml(downloadTooltip)}">
|
<button type="button" class="resource-action primary compact lora-download" data-lora-index="${loraIndex}"
|
||||||
<i class="fas fa-download" aria-hidden="true"></i>
|
title="${escapeHtml(downloadTooltip)}" aria-label="${escapeHtml(downloadTooltip)}">
|
||||||
<span>${escapeHtml(downloadLabel)}</span>
|
<i class="fas fa-download" aria-hidden="true"></i>
|
||||||
</button>
|
<span>${escapeHtml(downloadLabel)}</span>
|
||||||
`);
|
</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', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const downloadManagerMock = {
|
||||||
|
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||||
|
_lastDownloadError: '',
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||||
downloadManager: {
|
downloadManager: downloadManagerMock,
|
||||||
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
|
||||||
_lastDownloadError: '',
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user