feat: add explicit managed model download sources
Managed speech and OCR downloads previously used catalog-specific source URLs without a global selection. Platform Features now lets users choose ModelScope by default or Hugging Face, while Main validates and freezes that source for each download. Verified coverage remains explicit: downloads never mix artifacts or silently switch sources, and installed models plus ZIP imports stay source-independent. Release note: 可在“设置 → 平台功能 → 通用设置”中选择 ModelScope 或 Hugging Face 作为后续语音输入与 OCR 模型下载源;缺少完整已验证文件或下载失败时不会静默换源。
This commit is contained in:
@@ -5,6 +5,9 @@ import {
|
||||
speechModelSnapshotSchema
|
||||
} from './speech-model-contracts'
|
||||
|
||||
const revision = 'a'.repeat(40)
|
||||
const repositoryUrl =
|
||||
'https://huggingface.co/example/test-speech-model'
|
||||
const downloadableEntry = {
|
||||
id: 'test-speech-model',
|
||||
displayName: 'Test speech model',
|
||||
@@ -15,7 +18,9 @@ const downloadableEntry = {
|
||||
quality: 'balanced' as const,
|
||||
speed: 'balanced' as const,
|
||||
recommended: false,
|
||||
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
|
||||
repositoryUrls: {
|
||||
'hugging-face': repositoryUrl
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Test license notice.',
|
||||
@@ -26,36 +31,175 @@ const downloadableEntry = {
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/test/resolve/main/model.onnx',
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64)
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
url: `${repositoryUrl}/resolve/${revision}/model.onnx`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
redirectHosts: ['cdn-lfs.hf.co']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const catalogView = {
|
||||
id: downloadableEntry.id,
|
||||
displayName: downloadableEntry.displayName,
|
||||
description: downloadableEntry.description,
|
||||
languages: downloadableEntry.languages,
|
||||
family: downloadableEntry.family,
|
||||
quantization: downloadableEntry.quantization,
|
||||
quality: downloadableEntry.quality,
|
||||
speed: downloadableEntry.speed,
|
||||
recommended: downloadableEntry.recommended,
|
||||
license: downloadableEntry.license,
|
||||
manualOnly: false,
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: false,
|
||||
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('speech model contracts', () => {
|
||||
it('requires verified download metadata for every automatic file', () => {
|
||||
it('requires canonical identity and one complete verified source', () => {
|
||||
expect(speechModelCatalogEntrySchema.parse(downloadableEntry)).toEqual(
|
||||
downloadableEntry
|
||||
)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [{ name: 'model.onnx', role: 'model' }]
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model',
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...downloadableEntry.files[0]!.targets[
|
||||
'hugging-face'
|
||||
],
|
||||
url:
|
||||
'https://huggingface.co/example/another-model/' +
|
||||
`resolve/${revision}/model.onnx`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects mutable revisions and source-host mismatches', () => {
|
||||
const target =
|
||||
downloadableEntry.files[0]!.targets['hugging-face']
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...target,
|
||||
revision: 'main',
|
||||
url: `${repositoryUrl}/resolve/main/model.onnx`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
modelscope: {
|
||||
...target
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...target,
|
||||
redirectHosts: ['modelscope.cn']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps repository links consistent with source targets', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/example/another-model'
|
||||
}
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a reason for manual-only models and rejects duplicate files', () => {
|
||||
const manualFile = {
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {}
|
||||
}
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
manualOnly: true,
|
||||
files: [
|
||||
{ name: 'model.onnx', role: 'model' },
|
||||
{ name: 'model.onnx', role: 'tokens' }
|
||||
manualFile,
|
||||
{ ...manualFile, role: 'tokens' }
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
@@ -63,13 +207,55 @@ describe('speech model contracts', () => {
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
manualOnly: true,
|
||||
manualReason: '上游没有可核验的大小和摘要。',
|
||||
files: [{ name: 'model.onnx', role: 'model' }]
|
||||
manualReason: '上游没有可核验的下载目标。',
|
||||
files: [manualFile]
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects traversal, unknown fields, and malformed snapshots', () => {
|
||||
it('keeps renderer snapshots URL-free and validates frozen sources', () => {
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [catalogView],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1,
|
||||
totalBytes: 12,
|
||||
downloadSource: 'hugging-face'
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [catalogView],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1,
|
||||
totalBytes: 12
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects traversal and unknown local-directory fields', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
@@ -88,23 +274,5 @@ describe('speech model contracts', () => {
|
||||
copyEverything: true
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
catalog: [downloadableEntry],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: -1,
|
||||
totalBytes: 12
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user