diff --git a/src/platform/missingModel/components/MissingModelRow.vue b/src/platform/missingModel/components/MissingModelRow.vue index bfe31a77d0..511252057b 100644 --- a/src/platform/missingModel/components/MissingModelRow.vue +++ b/src/platform/missingModel/components/MissingModelRow.vue @@ -35,7 +35,7 @@ variant="secondary" size="sm" class="h-8 shrink-0 rounded-lg text-sm" - @click="copyToClipboard(model.representative.url!)" + @click="copyToClipboard(toBrowsableUrl(model.representative.url!))" > {{ t('rightSidePanel.missingModels.copyUrl') }} @@ -201,7 +201,8 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { isCloud } from '@/platform/distribution/types' import { downloadModel, - isModelDownloadable + isModelDownloadable, + toBrowsableUrl } from '@/platform/missingModel/missingModelDownload' import { formatSize } from '@/utils/formatUtil' diff --git a/src/platform/missingModel/missingModelDownload.test.ts b/src/platform/missingModel/missingModelDownload.test.ts index 1f2ef46d3f..96f007adf4 100644 --- a/src/platform/missingModel/missingModelDownload.test.ts +++ b/src/platform/missingModel/missingModelDownload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { fetchModelMetadata } from './missingModelDownload' +import { fetchModelMetadata, toBrowsableUrl } from './missingModelDownload' const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) @@ -140,3 +140,41 @@ describe('fetchModelMetadata', () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) }) + +describe('toBrowsableUrl', () => { + it('replaces /resolve/ with /blob/ in HuggingFace URLs', () => { + expect( + toBrowsableUrl( + 'https://huggingface.co/org/model/resolve/main/file.safetensors' + ) + ).toBe('https://huggingface.co/org/model/blob/main/file.safetensors') + }) + + it('returns non-HuggingFace URLs unchanged', () => { + const url = + 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth' + expect(toBrowsableUrl(url)).toBe(url) + }) + + it('preserves query params in HuggingFace URLs', () => { + expect( + toBrowsableUrl( + 'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors?download=true' + ) + ).toBe( + 'https://huggingface.co/bfl/FLUX.1/blob/main/model.safetensors?download=true' + ) + }) + + it('converts Civitai api/download URL to model page', () => { + expect( + toBrowsableUrl('https://civitai.com/api/download/models/12345') + ).toBe('https://civitai.com/models/12345') + }) + + it('converts Civitai api/v1 URL to model page', () => { + expect(toBrowsableUrl('https://civitai.com/api/v1/models/12345')).toBe( + 'https://civitai.com/models/12345' + ) + }) +}) diff --git a/src/platform/missingModel/missingModelDownload.ts b/src/platform/missingModel/missingModelDownload.ts index 007b8cad94..27ffb9c31d 100644 --- a/src/platform/missingModel/missingModelDownload.ts +++ b/src/platform/missingModel/missingModelDownload.ts @@ -31,6 +31,21 @@ interface ModelWithUrl { directory: string } +/** + * Converts a model download URL to a browsable page URL. + * - HuggingFace: `/resolve/` → `/blob/` (file page with model info) + * - Civitai: strips `/api/download` or `/api/v1` prefix (model page) + */ +export function toBrowsableUrl(url: string): string { + if (isCivitaiModelUrl(url)) { + return url.replace('/api/download/', '/').replace('/api/v1/', '/') + } + if (url.includes('huggingface.co')) { + return url.replace('/resolve/', '/blob/') + } + return url +} + export function isModelDownloadable(model: ModelWithUrl): boolean { if (WHITE_LISTED_URLS.has(model.url)) return true if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))