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
+93 -91
View File
@@ -16,22 +16,31 @@ import { z } from 'zod'
import {
installedSpeechModelSchema,
speechModelCatalogEntrySchema,
speechModelCatalogViewEntrySchema,
speechModelIdSchema,
speechModelSnapshotSchema,
type InstalledSpeechModel,
type SpeechModelCatalogEntry,
type SpeechModelCatalogViewEntry,
type SpeechModelFileSpec,
type SpeechModelOperation,
type SpeechModelSnapshot
} from '../../shared/speech-model-contracts'
import {
MODEL_DOWNLOAD_SOURCES,
getModelDownloadAvailability,
resolveModelDownloadPackage,
type ModelDownloadSource,
type ResolvedModelArtifactFile
} from '../../shared/model-download-contracts'
import { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
import {
exportModelArchive,
extractModelArchive
} from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MAX_REDIRECTS = 3
const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json'
const PARTIAL_SUFFIX = '.partial'
@@ -56,6 +65,9 @@ export type SpeechModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
catalog?: readonly SpeechModelCatalogEntry[]
getDownloadSource?: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number
}
@@ -72,6 +84,23 @@ function cloneCatalogEntry(
return speechModelCatalogEntrySchema.parse(entry)
}
function toCatalogView(entry: SpeechModelCatalogEntry) {
const { repositoryUrls, files, ...metadata } = entry
void repositoryUrls
return speechModelCatalogViewEntrySchema.parse({
...metadata,
files: files.map((file) => ({
name: file.name,
role: file.role,
size: file.size,
sha256: file.sha256
})),
downloadAvailability: MODEL_DOWNLOAD_SOURCES.map((source) =>
getModelDownloadAvailability(files, source)
)
})
}
function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError')
}
@@ -102,17 +131,6 @@ function safeChild(parent: string, name: string): string {
return child
}
function validateDownloadUrl(value: string): URL {
const url = new URL(value)
if (
url.protocol !== 'http:' &&
url.protocol !== 'https:'
) {
throw new Error('模型下载地址必须使用 HTTP 或 HTTPS')
}
return url
}
async function hashFile(
path: string,
signal?: AbortSignal
@@ -147,6 +165,10 @@ export class SpeechModelManager {
private readonly transport: typeof fetch
private readonly catalog: SpeechModelCatalogEntry[]
private readonly catalogViews: SpeechModelCatalogViewEntry[]
private readonly getDownloadSource: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>()
@@ -160,23 +182,31 @@ export class SpeechModelManager {
'speech'
)
this.transport = options.fetch
this.getDownloadSource =
options.getDownloadSource ?? (() => 'modelscope')
this.catalog = (options.catalog ?? SPEECH_MODEL_CATALOG).map(
cloneCatalogEntry
)
if (new Set(this.catalog.map((entry) => entry.id)).size !== this.catalog.length) {
throw new Error('语音模型目录包含重复 ID')
}
this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
}
async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot()
const installed = await this.readInstalled()
const selected = await this.readSelection()
const [installed, selected, selectedDownloadSource] =
await Promise.all([
this.readInstalled(),
this.readSelection(),
this.getDownloadSource()
])
const installedIds = new Set(installed.map((model) => model.id))
return speechModelSnapshotSchema.parse({
rootDirectory: this.rootDirectory,
catalog: this.catalog.map(cloneCatalogEntry),
selectedDownloadSource,
catalog: this.catalogViews,
installed,
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
@@ -190,6 +220,19 @@ export class SpeechModelManager {
return this.snapshot()
}
getRepositoryUrl(
modelId: string,
source: ModelDownloadSource
): string {
const entry = this.requireCatalogEntry(modelId)
resolveModelDownloadPackage(entry.files, source)
const repositoryUrl = entry.repositoryUrls[source]
if (!repositoryUrl) {
throw new Error('当前下载源暂不提供此模型的仓库')
}
return repositoryUrl
}
async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined
> {
@@ -216,6 +259,7 @@ export class SpeechModelManager {
async install(
modelId: string,
downloadSource?: ModelDownloadSource,
externalSignal?: AbortSignal
): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId)
@@ -224,27 +268,17 @@ export class SpeechModelManager {
entry.manualReason ?? '该模型只能从本地目录导入'
)
}
const downloadableFiles = entry.files.filter(
(
file
): file is SpeechModelFileSpec & {
download: NonNullable<SpeechModelFileSpec['download']>
} => file.download !== undefined
const selectedDownloadSource =
downloadSource ?? (await this.getDownloadSource())
const resolvedPackage = resolveModelDownloadPackage(
entry.files,
selectedDownloadSource
)
if (downloadableFiles.length !== entry.files.length) {
throw new Error('模型下载元数据不完整')
}
const totalBytes = downloadableFiles.reduce(
(total, file) => total + file.download.size,
0
)
if (!Number.isSafeInteger(totalBytes)) {
throw new RangeError('模型总大小超出安全范围')
}
const operation = this.beginOperation(
entry.id,
'download',
totalBytes
resolvedPackage.totalBytes,
resolvedPackage.source
)
const detachExternalAbort = this.attachExternalSignal(
externalSignal,
@@ -255,7 +289,7 @@ export class SpeechModelManager {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of downloadableFiles) {
for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
@@ -409,9 +443,8 @@ export class SpeechModelManager {
!recorded ||
recorded.size <= 0 ||
recorded.size > this.maxFileBytes ||
(expected.download &&
(recorded.size !== expected.download.size ||
recorded.sha256 !== expected.download.sha256))
recorded.size !== expected.size ||
recorded.sha256 !== expected.sha256
) {
throw new Error(`语音模型文件不可导出:${expected.name}`)
}
@@ -440,8 +473,7 @@ export class SpeechModelManager {
): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId)
const expectedTotal = entry.files.reduce(
(total, file) =>
total + (file.download?.size ?? this.maxFileBytes),
(total, file) => total + file.size,
0
)
const maximumTotalBytes = Math.min(
@@ -488,9 +520,8 @@ export class SpeechModelManager {
if (
!archived ||
archived.size > this.maxFileBytes ||
(expected.download &&
(archived.size !== expected.download.size ||
archived.sha256 !== expected.download.sha256))
archived.size !== expected.size ||
archived.sha256 !== expected.sha256
) {
throw new Error(
`语音模型 ZIP 与当前模型目录不匹配:${expected.name}`
@@ -544,7 +575,8 @@ export class SpeechModelManager {
private beginOperation(
modelId: string,
kind: SpeechModelOperation['kind'],
totalBytes: number | null
totalBytes: number | null,
downloadSource?: ModelDownloadSource
): ActiveOperation {
if (this.operations.has(modelId)) {
throw new Error('该模型已有进行中的操作')
@@ -557,7 +589,8 @@ export class SpeechModelManager {
phase: 'preparing',
currentFile: null,
completedBytes: 0,
totalBytes
totalBytes,
...(downloadSource ? { downloadSource } : {})
}
}
this.operations.set(modelId, operation)
@@ -605,55 +638,25 @@ export class SpeechModelManager {
return directory
}
private async fetchFollowingRedirects(
initialUrl: string,
signal: AbortSignal
): Promise<Response> {
let url = validateDownloadUrl(initialUrl)
for (let redirectCount = 0; ; redirectCount += 1) {
ensureNotAborted(signal)
const response = await this.transport(url, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal
})
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirectCount >= MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new Error('模型下载重定向次数过多')
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error('模型下载重定向缺少地址')
}
url = validateDownloadUrl(new URL(location, url).toString())
continue
}
return response
}
}
private async downloadFile(
file: SpeechModelFileSpec & {
download: NonNullable<SpeechModelFileSpec['download']>
},
file: ResolvedModelArtifactFile<SpeechModelFileSpec['role']>,
destination: string,
operation: ActiveOperation,
signal: AbortSignal
): Promise<void> {
if (
file.download.size > this.maxFileBytes ||
file.download.size <= 0
file.size > this.maxFileBytes ||
file.size <= 0
) {
throw new RangeError(`模型文件大小超出限制:${file.name}`)
}
const response = await this.fetchFollowingRedirects(
file.download.url,
signal
)
const response = await fetchModelDownloadResponse({
transport: this.transport,
initialUrl: file.target.url,
redirectHosts: file.target.redirectHosts,
signal,
modelLabel: '模型'
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(`模型下载失败:HTTP ${response.status}`)
@@ -666,7 +669,7 @@ export class SpeechModelManager {
const parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength !== file.download.size
parsedLength !== file.size
) {
await response.body.cancel().catch(() => undefined)
throw new Error(`模型文件大小不匹配:${file.name}`)
@@ -687,7 +690,7 @@ export class SpeechModelManager {
}
written += result.value.byteLength
if (
written > file.download.size ||
written > file.size ||
written > this.maxFileBytes
) {
await reader.cancel()
@@ -703,10 +706,10 @@ export class SpeechModelManager {
} finally {
await handle.close()
}
if (written !== file.download.size) {
if (written !== file.size) {
throw new Error(`模型文件大小不匹配:${file.name}`)
}
if (hash.digest('hex') !== file.download.sha256) {
if (hash.digest('hex') !== file.sha256) {
throw new Error(`模型文件校验失败:${file.name}`)
}
await rename(partialPath, destination)
@@ -741,10 +744,9 @@ export class SpeechModelManager {
throw new RangeError(`模型文件大小无效:${expectedFile.name}`)
}
if (
expectedFile.download &&
(sourceFileInfo.size !== expectedFile.download.size ||
(await hashFile(sourceFile, signal)).sha256 !==
expectedFile.download.sha256)
sourceFileInfo.size !== expectedFile.size ||
(await hashFile(sourceFile, signal)).sha256 !==
expectedFile.sha256
) {
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
}