fix: convert download URLs to browsable page URLs in Copy Url button (#10228)

## Summary

- Copy Url button in MissingModelRow now copies a browsable page URL
instead of a direct download URL
- HuggingFace: `/resolve/` → `/blob/` (file page with model info and
download button)
- Civitai: strips `/api/download` or `/api/v1` prefix (model page)

## Changes

- Add `toBrowsableUrl()` to `missingModelDownload.ts` — converts
download URLs to browsable page URLs for HuggingFace and Civitai
- Update `MissingModelRow.vue` to use `toBrowsableUrl()` when copying
- Add 5 unit tests covering HuggingFace, Civitai, and non-matching URL
cases

## Test plan

- [x] Unit tests pass (14/14) — `toBrowsableUrl` covered by 5 dedicated
tests
- [x] Lint, format, typecheck pass
- [x] Manual: load workflow with missing HuggingFace models, click Copy
Url, verify copied URL opens the file page
- [x] Manual: load workflow with missing Civitai models, click Copy Url,
verify copied URL opens the model page

### Why no E2E test

The Copy Url button is only visible when `!isCloud &&
model.representative.url && !isAssetSupported`. Writing an E2E test for
the clipboard content would require:

1. A test fixture with a real HuggingFace/Civitai URL (fragile — depends
on external availability)
2. Granting `clipboard-read` permission in Playwright context
3. Ensuring `isAssetSupported` evaluates to `false` against the local
test server's node definitions

The URL transformation logic is a pure function, fully covered by unit
tests. E2E clipboard content verification has no existing patterns in
the codebase and would be environment-dependent and flaky.

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
This commit is contained in:
jaeone94
2026-03-18 13:19:52 +09:00
committed by GitHub
parent 942938d058
commit a3cf6fcde0
3 changed files with 57 additions and 3 deletions

View File

@@ -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') }}
</Button>
@@ -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'

View File

@@ -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'
)
})
})

View File

@@ -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)))