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) {
|
||||
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,10 +3006,10 @@ 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) {
|
||||
@@ -3018,6 +3023,26 @@ class RecipeModal {
|
||||
return modelId && versionId ? { modelId, versionId, versionName } : 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;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed download flips inLibrary flags server-side; re-fetch the
|
||||
* recipe and reconcile both this modal's resources section and the
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user