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:
mesalogo
2026-08-19 13:43:16 +08:00
parent c41d7e8b96
commit 43e1d162dc
44 changed files with 6083 additions and 839 deletions
+198 -30
View File
@@ -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)
})
})