mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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.
This commit is contained in:
@@ -2877,12 +2877,14 @@ class RecipeModal {
|
|||||||
|
|
||||||
canDownloadLora(lora) {
|
canDownloadLora(lora) {
|
||||||
if (!lora) return false;
|
if (!lora) return false;
|
||||||
const modelId = lora.modelId || lora.modelID || lora.model_id;
|
|
||||||
const versionId = lora.id || lora.modelVersionId;
|
const versionId = lora.id || lora.modelVersionId;
|
||||||
// Direct download needs both identifiers; a hash alone is enough
|
// A bare CivitAI version id is enough: it uniquely pins the exact
|
||||||
// because downloadRecipeLora resolves it to a version on demand —
|
// file, and downloadRecipeLora resolves the owning model id from the
|
||||||
// the same fallback the bulk "download missing" flow uses.
|
// version on demand (the same fallback the bulk "download missing"
|
||||||
return !!((modelId && versionId) || lora.hash);
|
// 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) {
|
renderCivitaiLink(url) {
|
||||||
@@ -2991,6 +2993,9 @@ class RecipeModal {
|
|||||||
* Resolve the Civitai model/version identifiers needed for download.
|
* Resolve the Civitai model/version identifiers needed for download.
|
||||||
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
|
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
|
||||||
* it through the same endpoint the bulk "download missing" flow uses.
|
* 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) {
|
async resolveLoraDownloadIdentifiers(lora) {
|
||||||
let modelId = lora.modelId || lora.modelID || lora.model_id;
|
let modelId = lora.modelId || lora.modelID || lora.model_id;
|
||||||
@@ -3001,21 +3006,41 @@ class RecipeModal {
|
|||||||
return { modelId, versionId, versionName };
|
return { modelId, versionId, versionName };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lora.hash) {
|
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
|
||||||
return null;
|
// 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}`);
|
// Version-only entries (page-imported recipes whose CivitAI versions
|
||||||
const versionInfo = await response.json();
|
// expose no sha256): the version id still pins the exact file, so
|
||||||
if (versionInfo?.error) {
|
// resolve the owning model id from the version endpoint on demand.
|
||||||
return null;
|
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;
|
return null;
|
||||||
versionId = versionInfo.id;
|
|
||||||
versionName = versionInfo.name || versionName;
|
|
||||||
|
|
||||||
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ const hashInvalidLora = {
|
|||||||
hashInvalid: true,
|
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 = {
|
const recipeWithResources = {
|
||||||
id: 'recipe-resources',
|
id: 'recipe-resources',
|
||||||
file_path: '/recipes/resources.json',
|
file_path: '/recipes/resources.json',
|
||||||
@@ -171,6 +181,7 @@ const recipeWithResources = {
|
|||||||
hashInvalidLora,
|
hashInvalidLora,
|
||||||
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
|
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
|
||||||
hashOnlyLora,
|
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 () => {
|
it('does not navigate when a missing LoRA row is clicked', async () => {
|
||||||
const recipeModal = await createRecipeModal();
|
const recipeModal = await createRecipeModal();
|
||||||
const navigateSpy = vi
|
const navigateSpy = vi
|
||||||
|
|||||||
Reference in New Issue
Block a user