Direct file download (#215)

This commit is contained in:
moldwebs
2026-02-26 14:37:51 +08:00
committed by GitHub
parent 60837ae35f
commit 0fdbc5d7be
8 changed files with 448 additions and 17 deletions
+1
View File
@@ -35,6 +35,7 @@
"@primevue/themes": "^4.0.7",
"@vueuse/core": "^11.3.0",
"dayjs": "^1.11.13",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-metadata-block": "^1.0.6",
+3 -9
View File
@@ -17,6 +17,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.13
js-yaml:
specifier: ^4.1.0
version: 4.1.0
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -405,55 +408,46 @@ packages:
resolution: {integrity: sha512-9pxQJSPwFsVi0ttOmqLY4JJ9pg9t1gKhK0JDbV1yUEETSx55fdyCjt39eBQ54OQCzAF0nVGO6LfEH1KnCPvelA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.22.0':
resolution: {integrity: sha512-YJ5Ku5BmNJZb58A4qSEo3JlIG4d3G2lWyBi13ABlXzO41SsdnUKi3HQHe83VpwBVG4jHFTW65jOQb8qyoR+qzg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.22.0':
resolution: {integrity: sha512-U4G4u7f+QCqHlVg1Nlx+qapZy+QoG+NV6ux+upo/T7arNGwKvKP2kmGM4W5QTbdewWFgudQxi3kDNST9GT1/mg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.22.0':
resolution: {integrity: sha512-aQpNlKmx3amwkA3a5J6nlXSahE1ijl0L9KuIjVOUhfOh7uw2S4piR3mtpxpRtbnK809SBtyPsM9q15CPTsY7HQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.22.0':
resolution: {integrity: sha512-9fx6Zj/7vve/Fp4iexUFRKb5+RjLCff6YTRQl4CoDhdMfDoobWmhAxQWV3NfShMzQk1Q/iCnageFyGfqnsmeqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.22.0':
resolution: {integrity: sha512-VWQiCcN7zBgZYLjndIEh5tamtnKg5TGxyZPWcN9zBtXBwfcGSZ5cHSdQZfQH/GB4uRxk0D3VYbOEe/chJhPGLQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.22.0':
resolution: {integrity: sha512-EHmPnPWvyYqncObwqrosb/CpH3GOjE76vWVs0g4hWsDRUVhg61hBmlVg5TPXqF+g+PvIbqkC7i3h8wbn4Gp2Fg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.22.0':
resolution: {integrity: sha512-tsSWy3YQzmpjDKnQ1Vcpy3p9Z+kMFbSIesCdMNgLizDWFhrLZIoN21JSq01g+MZMDFF+Y1+4zxgrlqPjid5ohg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.22.0':
resolution: {integrity: sha512-anr1Y11uPOQrpuU8XOikY5lH4Qu94oS6j0xrulHk3NkLDq19MlX8Ng/pVipjxBJ9a2l3+F39REZYyWQFkZ4/fw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.22.0':
resolution: {integrity: sha512-7LB+Bh+Ut7cfmO0m244/asvtIGQr5pG5Rvjz/l1Rnz1kDzM02pSX9jPaS0p+90H5I1x4d1FkCew+B7MOnoatNw==}
+168 -3
View File
@@ -14,6 +14,27 @@
</template>
</ResponseInput>
<!-- Direct file URL indicator with folder selection -->
<div v-if="isDirectFile && modelUrl" class="flex flex-col gap-2">
<div
class="flex items-center gap-2 rounded bg-green-50 p-2 text-sm text-green-600"
>
<i class="pi pi-check-circle"></i>
<span>Direct file download detected</span>
</div>
<!-- Model Type/Folder Selection for direct downloads -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium">{{ $t('modelType') }}:</label>
<ResponseSelect
v-model="selectedModelType"
:items="modelTypeOptions"
:type="'drop'"
class="flex-1"
/>
</div>
</div>
<div v-show="data.length > 0">
<ResponseSelect
v-model="current"
@@ -79,8 +100,13 @@ import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { VersionModel, WithResolved } from 'types/typings'
import { previewUrlToFile } from 'utils/common'
import { ref } from 'vue'
import {
getFilenameFromUrl,
getModelTypeFromFilename,
isDirectFileUrl,
previewUrlToFile,
} from 'utils/common'
import { computed, ref, watch } from 'vue'
const { isMobile } = useConfig()
const { toast } = useToast()
@@ -89,14 +115,153 @@ const dialog = useDialog()
const modelUrl = ref<string>()
// Model type selection for direct downloads
const selectedModelType = ref<string>('checkpoints')
const modelTypeOptions = computed(() => [
{
label: 'Checkpoints',
value: 'checkpoints',
command: () => {
selectedModelType.value = 'checkpoints'
},
},
{
label: 'LoRA',
value: 'loras',
command: () => {
selectedModelType.value = 'loras'
},
},
{
label: 'ControlNet',
value: 'controlnet',
command: () => {
selectedModelType.value = 'controlnet'
},
},
{
label: 'VAE',
value: 'vae',
command: () => {
selectedModelType.value = 'vae'
},
},
{
label: 'Embeddings',
value: 'embeddings',
command: () => {
selectedModelType.value = 'embeddings'
},
},
{
label: 'Upscale Models',
value: 'upscale_models',
command: () => {
selectedModelType.value = 'upscale_models'
},
},
{
label: 'Diffusers',
value: 'diffusers',
command: () => {
selectedModelType.value = 'diffusers'
},
},
{
label: 'CLIP',
value: 'clip',
command: () => {
selectedModelType.value = 'clip'
},
},
{
label: 'CLIP Vision',
value: 'clip_vision',
command: () => {
selectedModelType.value = 'clip_vision'
},
},
{
label: 'UNet/Diffusion Models',
value: 'diffusion_models',
command: () => {
selectedModelType.value = 'diffusion_models'
},
},
{
label: 'Style Models',
value: 'style_models',
command: () => {
selectedModelType.value = 'style_models'
},
},
{
label: 'Hypernetworks',
value: 'hypernetworks',
command: () => {
selectedModelType.value = 'hypernetworks'
},
},
{
label: 'GLIGEN',
value: 'gligen',
command: () => {
selectedModelType.value = 'gligen'
},
},
{
label: 'PhotoMaker',
value: 'photomaker',
command: () => {
selectedModelType.value = 'photomaker'
},
},
{
label: 'VAE Approx',
value: 'vae_approx',
command: () => {
selectedModelType.value = 'vae_approx'
},
},
{
label: 'Classifiers',
value: 'classifiers',
command: () => {
selectedModelType.value = 'classifiers'
},
},
])
const isDirectFile = computed(() =>
modelUrl.value ? isDirectFileUrl(modelUrl.value) : false,
)
const { current, currentModel, data, search } = useModelSearch()
const searchModelsByUrl = async () => {
if (modelUrl.value) {
await search(modelUrl.value)
const modelType = isDirectFile.value ? selectedModelType.value : undefined
await search(modelUrl.value, modelType)
}
}
// Watch for direct file URL changes and set intelligent default
watch(modelUrl, (newUrl) => {
if (newUrl && isDirectFileUrl(newUrl)) {
const filename = getFilenameFromUrl(newUrl)
const suggestedType = getModelTypeFromFilename(filename)
selectedModelType.value = suggestedType
}
})
// Watch for model type changes on direct files and refresh the model
watch(selectedModelType, async () => {
if (isDirectFile.value && modelUrl.value) {
await search(modelUrl.value, selectedModelType.value)
}
})
const createDownTask = async (data: WithResolved<VersionModel>) => {
loading.show()
+171 -3
View File
@@ -11,7 +11,12 @@ import {
VersionModel,
VersionModelFile,
} from 'types/typings'
import { bytesToSize } from 'utils/common'
import {
bytesToSize,
getFilenameFromUrl,
getModelTypeFromFilename,
isDirectFileUrl,
} from 'utils/common'
import { onBeforeMount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import yaml from 'yaml'
@@ -191,7 +196,13 @@ export const useModelSearch = () => {
const regexp = /---\n([\s\S]*?)\n---/
const yamlMetadataMatch = item.description.match(regexp)
const yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '')
let yamlMetadata: any = {}
try {
yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '') || {}
} catch (e) {
console.warn('Failed to parse YAML metadata:', e)
yamlMetadata = {}
}
yamlMetadata.hashes = file.hashes
yamlMetadata.metadata = file.metadata
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
@@ -219,12 +230,169 @@ export const useModelSearch = () => {
return fileSelectionItem
}
const handleSearchByUrl = async (url: string) => {
const createDirectFileModel = (
url: string,
modelType?: string,
): VersionModel => {
try {
const filename = getFilenameFromUrl(url)
const parts = filename.split('.')
const extension = `.${parts.pop()}`
const basename = parts.join('.') || 'model'
const detectedModelType = modelType || getModelTypeFromFilename(filename)
// Create a proper YAML metadata structure for direct files
const yamlMetadata = {
source: 'direct-link',
original_url: url,
filename: filename,
modelType: detectedModelType,
downloadPlatform: 'Direct Link',
}
const description = `---
${Object.entries(yamlMetadata)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')}
---
# Direct File Download
This is a direct download link to a model file. The file size will be determined during download.
**Source:** ${url}
**Filename:** ${filename}
**Type:** ${detectedModelType}`
return {
id: `direct-${Date.now()}`,
basename,
extension,
sizeBytes: 0, // Will be determined during download
type: detectedModelType,
subFolder: '',
pathIndex: 0,
isFolder: false,
preview: '',
description,
metadata: {
source: 'direct-link',
original_url: url,
},
shortname: basename,
downloadPlatform: 'Direct Link',
downloadUrl: url,
hashes: {},
files: [
{
id: 1,
sizeKB: 0, // Unknown until download starts
name: filename,
type: 'Model',
metadata: {
source: 'direct-link',
original_url: url,
},
hashes: {},
downloadUrl: url,
},
],
}
} catch (error) {
console.error('Error creating direct file model:', error)
// Return a fallback model
const fallbackType = modelType || 'checkpoints'
return {
id: `direct-${Date.now()}`,
basename: 'model',
extension: '.bin',
sizeBytes: 0,
type: fallbackType,
subFolder: '',
pathIndex: 0,
isFolder: false,
preview: '',
description: `---
source: direct-link
original_url: ${url}
filename: model.bin
modelType: ${fallbackType}
downloadPlatform: Direct Link
---
# Direct File Download
This is a direct download link to a model file.`,
metadata: {
source: 'direct-link',
original_url: url,
},
shortname: 'model',
downloadPlatform: 'Direct Link',
downloadUrl: url,
hashes: {},
files: [
{
id: 1,
sizeKB: 0,
name: 'model.bin',
type: 'Model',
metadata: {
source: 'direct-link',
original_url: url,
},
hashes: {},
downloadUrl: url,
},
],
}
}
}
const handleSearchByUrl = async (url: string, modelType?: string) => {
if (!url) {
return Promise.resolve([])
}
loading.show()
// Check if this is a direct file URL
if (isDirectFileUrl(url)) {
try {
// Create a mock model for direct file download
const directModel = createDirectFileModel(url, modelType)
const resolvedItem = genFileSelectionItem(directModel)
data.value = [
{
label: directModel.shortname,
value: directModel.id,
item: resolvedItem,
command() {
current.value = directModel.id
},
},
]
current.value = data.value[0]?.value
currentModel.value = data.value[0]?.item
loading.hide()
return [directModel]
} catch (error) {
console.error('Error processing direct file URL:', error)
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: `Failed to process direct file URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
life: 5000,
})
return []
}
}
// Original logic for model page URLs
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
.then((resData: VersionModel[]) => {
data.value = resData.map((item) => {
+2 -1
View File
@@ -9,7 +9,8 @@
"downloadTask": "Download Task",
"createDownloadTask": "Create Download Task",
"parseModelUrl": "Parse Model URL",
"pleaseInputModelUrl": "Input a URL from civitai.com or huggingface.co",
"pleaseInputModelUrl": "Input a URL from civitai.com, huggingface.co, or direct file link (.safetensors, .ckpt, etc.)",
"selectModelTypeForDirect": "Select the folder/type for this model",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
+2 -1
View File
@@ -9,7 +9,8 @@
"downloadTask": "下载任务",
"createDownloadTask": "创建下载任务",
"parseModelUrl": "解析模型URL",
"pleaseInputModelUrl": "输入 civitai.com 或 huggingface.co 的 URL",
"pleaseInputModelUrl": "输入 civitai.com, huggingface.co 的 URL 或直接文件链接 (.safetensors, .ckpt 等)",
"selectModelTypeForDirect": "为此模型选择文件夹/类型",
"cancel": "取消",
"save": "保存",
"delete": "删除",
+100
View File
@@ -37,3 +37,103 @@ export const previewUrlToFile = async (url: string) => {
return file
})
}
// Model file extensions that are supported for direct download
export const MODEL_FILE_EXTENSIONS = [
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.onnx',
'.tflite',
'.pb',
'.h5',
'.pkl',
'.pickle',
'.json', // for configuration files
'.yaml',
'.yml',
]
/**
* Checks if a URL points directly to a downloadable model file
*/
export const isDirectFileUrl = (url: string): boolean => {
if (!url || typeof url !== 'string') {
return false
}
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
// Check if the URL ends with a model file extension
return MODEL_FILE_EXTENSIONS.some((ext) => pathname.endsWith(ext))
} catch {
return false
}
}
/**
* Extracts filename from a URL
*/
export const getFilenameFromUrl = (url: string): string => {
if (!url || typeof url !== 'string') {
return 'model.bin'
}
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const filename = pathname.split('/').pop() || ''
// If no filename with extension found, generate one
if (!filename || !filename.includes('.')) {
const extension =
MODEL_FILE_EXTENSIONS.find((ext) =>
pathname.toLowerCase().endsWith(ext),
) || '.bin'
return `model${extension}`
}
return filename
} catch {
return 'model.bin'
}
}
/**
* Determines model type based on file extension (fallback only)
* Note: This is now primarily used as a fallback when no manual selection is made
*/
export const getModelTypeFromFilename = (filename: string): string => {
if (!filename || typeof filename !== 'string') {
return 'checkpoints'
}
const extension = filename.toLowerCase().split('.').pop()
switch (extension) {
case 'safetensors':
case 'ckpt':
case 'pt':
case 'pth':
return 'checkpoints' // Default for these extensions, but user can override
case 'bin':
return 'diffusers'
case 'onnx':
return 'onnx'
case 'tflite':
return 'tflite'
case 'pb':
return 'tensorflow'
case 'h5':
return 'keras'
case 'pkl':
case 'pickle':
return 'embeddings'
default:
return 'checkpoints'
}
}
File diff suppressed because one or more lines are too long