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
+50 -3
View File
@@ -63,6 +63,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -70,14 +71,16 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 6,
version: 7,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -102,6 +105,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -124,6 +128,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -146,6 +151,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -168,6 +174,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'combined'
@@ -194,9 +201,10 @@ describe('ApplicationSettingsStore', () => {
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 6,
version: 7,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
@@ -222,6 +230,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured'
@@ -231,6 +240,39 @@ describe('ApplicationSettingsStore', () => {
)
})
it('lazily migrates version 6 to the default ModelScope source', async () => {
const { filePath, store } = await createStore()
const versionSix = {
version: 6,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured',
lastSeenReleaseNotesVersion: '0.8.18'
}
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
versionSix
)
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 7,
modelDownloadSource: 'hugging-face'
})
})
it('strictly rejects incomplete full settings', () => {
for (const input of [
{},
@@ -254,6 +296,7 @@ describe('ApplicationSettingsStore', () => {
for (const input of [
{},
{ checkUpdatesOnStartup: 'true' },
{ modelDownloadSource: 'automatic' },
{ anotherSetting: true },
null
]) {
@@ -274,6 +317,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -387,14 +431,16 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 6,
version: 7,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -419,6 +465,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
+30 -4
View File
@@ -20,7 +20,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 6
const CURRENT_SETTINGS_VERSION = 7
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -47,20 +47,28 @@ const versionThreeStoredApplicationSettingsSchema = z
.strict()
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ updateSource: true })
.omit({ updateSource: true, modelDownloadSource: true })
.extend({
version: z.literal(4)
})
.strict()
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ updateSource: true })
.omit({ updateSource: true, modelDownloadSource: true })
.extend({
version: z.literal(5),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ modelDownloadSource: true })
.extend({
version: z.literal(6),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -75,6 +83,7 @@ type StoredApplicationSettings = z.infer<
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
@@ -131,13 +140,24 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionSixResult =
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
if (versionSixResult.success) {
this.settings = {
...versionSixResult.data,
version: CURRENT_SETTINGS_VERSION,
modelDownloadSource: 'modelscope'
}
return this.settings
}
const versionFiveResult =
versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFiveResult.success) {
this.settings = {
...versionFiveResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github'
updateSource: 'github',
modelDownloadSource: 'modelscope'
}
return this.settings
}
@@ -148,6 +168,7 @@ export class ApplicationSettingsStore {
...versionFourResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
lastSeenReleaseNotesVersion: null
}
return this.settings
@@ -159,6 +180,7 @@ export class ApplicationSettingsStore {
...versionThreeResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
@@ -171,6 +193,7 @@ export class ApplicationSettingsStore {
...versionTwoResult.data,
version: CURRENT_SETTINGS_VERSION,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
@@ -185,6 +208,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup:
legacyResult.data.checkUpdatesOnStartup,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
@@ -225,6 +249,7 @@ export class ApplicationSettingsStore {
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
magicNotesEnabled: stored.magicNotesEnabled,
magicNoteCommentMode: stored.magicNoteCommentMode,
magicNoteCommentFormat: stored.magicNoteCommentFormat,
@@ -257,6 +282,7 @@ export class ApplicationSettingsStore {
return {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
magicNotesEnabled: next.magicNotesEnabled,
magicNoteCommentMode: next.magicNoteCommentMode,
magicNoteCommentFormat: next.magicNoteCommentFormat
+101 -105
View File
@@ -2,19 +2,66 @@ import {
documentOcrModelCatalogEntrySchema,
type DocumentOcrModelCatalogEntry
} from '../shared/document-parsing-contracts'
import {
huggingFaceTarget,
modelScopeTarget
} from './model-download-targets'
const detectionRevision =
'7d7f5d128d9309ebf6de4f21f404dd583afdbae3'
const recognitionRevision =
'afba04b618200c5f4824531c6e42c957c6439d9a'
const smallDetectionRevision =
'956a0b620a4017cc04056c692be1703b0025d028'
const smallRecognitionRevision =
'296d43bc0ebced0fd9c605174aa5962e49810ab6'
const mediumDetectionRevision =
'c317b40325be40bfaaff58c8dcece2a075294f8a'
const mediumRecognitionRevision =
'db5d610d492a14e3c34dc1fd4e9339bd369f79e6'
const repositories = {
tinyDetection: 'PaddlePaddle/PP-OCRv6_tiny_det_onnx',
tinyRecognition: 'PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
smallDetection: 'PaddlePaddle/PP-OCRv6_small_det_onnx',
smallRecognition: 'PaddlePaddle/PP-OCRv6_small_rec_onnx',
mediumDetection: 'PaddlePaddle/PP-OCRv6_medium_det_onnx',
mediumRecognition: 'PaddlePaddle/PP-OCRv6_medium_rec_onnx'
} as const
const modelScopeRevisions = {
tinyDetection: '7d7f5d128d9309ebf6de4f21f404dd583afdbae3',
tinyRecognition: 'afba04b618200c5f4824531c6e42c957c6439d9a',
smallDetection: '956a0b620a4017cc04056c692be1703b0025d028',
smallRecognition: '296d43bc0ebced0fd9c605174aa5962e49810ab6',
mediumDetection: 'c317b40325be40bfaaff58c8dcece2a075294f8a',
mediumRecognition: 'db5d610d492a14e3c34dc1fd4e9339bd369f79e6'
} as const
const huggingFaceRevisions = {
tinyDetection: '2ba1506c0380b8f0b03dd142459aac66d4421f6c',
tinyRecognition: '2612ab37152ae0a677521bae4e1e3d4fb4cf7c30',
smallDetection: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62',
smallRecognition: 'b8f84f0b80c529de40b4fbb3544b84fa7233a513',
mediumDetection: '61323801669c338b7891481ec7bac61ce31b576a',
mediumRecognition: '50c7eacafc52fa7bcf4194e8cd08e46f8558504b'
} as const
type RepositoryKey = keyof typeof repositories
function targets(
repositoryKey: RepositoryKey,
file: string
) {
const repository = repositories[repositoryKey]
return {
modelscope: modelScopeTarget(
repository,
modelScopeRevisions[repositoryKey],
file
),
'hugging-face': huggingFaceTarget(
repository,
huggingFaceRevisions[repositoryKey],
file
)
}
}
function repositoryUrls(repositoryKey: RepositoryKey) {
const repository = repositories[repositoryKey]
return {
modelscope: `https://modelscope.cn/models/${repository}`,
'hugging-face': `https://huggingface.co/${repository}`
}
}
export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] =
documentOcrModelCatalogEntrySchema.array().parse([
@@ -28,54 +75,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
quality: 'basic',
speed: 'fast',
recommended: false,
repositoryUrl:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_tiny_rec_onnx',
repositoryUrls: repositoryUrls('tinyRecognition'),
license: {
name: 'Apache License 2.0',
notice:
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
'检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
},
files: [
{
name: 'detection.onnx',
role: 'detection',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_tiny_det_onnx/resolve/' +
`${detectionRevision}/inference.onnx`,
size: 1_780_590,
sha256:
'193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8'
}
size: 1_780_590,
sha256:
'193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8',
targets: targets('tinyDetection', 'inference.onnx')
},
{
name: 'recognition.onnx',
role: 'recognition',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_tiny_rec_onnx/resolve/' +
`${recognitionRevision}/inference.onnx`,
size: 4_462_639,
sha256:
'9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6'
}
size: 4_462_639,
sha256:
'9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6',
targets: targets('tinyRecognition', 'inference.onnx')
},
{
name: 'dictionary.yml',
role: 'dictionary',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_tiny_rec_onnx/resolve/' +
`${recognitionRevision}/inference.yml`,
size: 55_571,
sha256:
'66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1'
}
size: 55_571,
sha256:
'66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1',
targets: targets('tinyRecognition', 'inference.yml')
}
]
},
@@ -89,54 +119,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
quality: 'balanced',
speed: 'balanced',
recommended: true,
repositoryUrl:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_small_rec_onnx',
repositoryUrls: repositoryUrls('smallRecognition'),
license: {
name: 'Apache License 2.0',
notice:
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
'检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
},
files: [
{
name: 'detection.onnx',
role: 'detection',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_small_det_onnx/resolve/' +
`${smallDetectionRevision}/inference.onnx`,
size: 9_880_512,
sha256:
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e'
}
size: 9_880_512,
sha256:
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e',
targets: targets('smallDetection', 'inference.onnx')
},
{
name: 'recognition.onnx',
role: 'recognition',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_small_rec_onnx/resolve/' +
`${smallRecognitionRevision}/inference.onnx`,
size: 21_159_378,
sha256:
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
}
size: 21_159_378,
sha256:
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634',
targets: targets('smallRecognition', 'inference.onnx')
},
{
name: 'dictionary.yml',
role: 'dictionary',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_small_rec_onnx/resolve/' +
`${smallRecognitionRevision}/inference.yml`,
size: 150_579,
sha256:
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
}
size: 150_579,
sha256:
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1',
targets: targets('smallRecognition', 'inference.yml')
}
]
},
@@ -150,54 +163,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
quality: 'high',
speed: 'slow',
recommended: false,
repositoryUrl:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_medium_rec_onnx',
repositoryUrls: repositoryUrls('mediumRecognition'),
license: {
name: 'Apache License 2.0',
notice:
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
'检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
},
files: [
{
name: 'detection.onnx',
role: 'detection',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_medium_det_onnx/resolve/' +
`${mediumDetectionRevision}/inference.onnx`,
size: 62_032_837,
sha256:
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
}
size: 62_032_837,
sha256:
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1',
targets: targets('mediumDetection', 'inference.onnx')
},
{
name: 'recognition.onnx',
role: 'recognition',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_medium_rec_onnx/resolve/' +
`${mediumRecognitionRevision}/inference.onnx`,
size: 76_554_979,
sha256:
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
}
size: 76_554_979,
sha256:
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba',
targets: targets('mediumRecognition', 'inference.onnx')
},
{
name: 'dictionary.yml',
role: 'dictionary',
download: {
url:
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_medium_rec_onnx/resolve/' +
`${mediumRecognitionRevision}/inference.yml`,
size: 150_580,
sha256:
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
}
size: 150_580,
sha256:
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129',
targets: targets('mediumRecognition', 'inference.yml')
}
]
}
+127 -54
View File
@@ -70,8 +70,12 @@ function catalog(
quality: 'balanced',
speed: 'fast',
recommended: true,
repositoryUrl:
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
repositoryUrls: {
modelscope:
'https://modelscope.cn/models/example/test-model',
'hugging-face':
'https://huggingface.co/example/test-model'
},
license: {
name: 'Apache License 2.0',
notice: 'Test license notice.',
@@ -80,10 +84,27 @@ function catalog(
files: files.map((file) => ({
name: file.name,
role: file.role,
download: {
url: `https://modelscope.cn/models/example/resolve/revision/${file.name}`,
size: file.bytes.byteLength,
sha256: sha256(file.bytes)
size: file.bytes.byteLength,
sha256: sha256(file.bytes),
targets: {
modelscope: {
url:
'https://modelscope.cn/models/example/test-model/' +
`resolve/${'a'.repeat(40)}/${file.name}`,
repositoryUrl:
'https://modelscope.cn/models/example/test-model',
revision: 'a'.repeat(40),
redirectHosts: []
},
'hugging-face': {
url:
'https://huggingface.co/example/test-model/' +
`resolve/${'b'.repeat(40)}/${file.name}`,
repositoryUrl:
'https://huggingface.co/example/test-model',
revision: 'b'.repeat(40),
redirectHosts: []
}
}
}))
}
@@ -124,10 +145,12 @@ async function createManager(
throw new Error('Test OCR catalog is empty')
}
const files = new Map(
entry.files.map((file) => [
file.download.url,
modelBytes[file.role]
])
entry.files.flatMap((file) =>
Object.values(file.targets).map((target) => [
target.url,
modelBytes[file.role]
] as const)
)
)
const transport = vi.fn(async (input: string | URL | Request) => {
const url =
@@ -174,7 +197,7 @@ describe('DocumentOcrModelManager', () => {
})
})
it('uses immutable SHA-256 verified ModelScope catalog files', () => {
it('uses immutable byte-identical ModelScope and Hugging Face files', () => {
expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3)
expect(
new Set(DOCUMENT_OCR_MODEL_CATALOG.map((entry) => entry.id)).size
@@ -186,20 +209,28 @@ describe('DocumentOcrModelManager', () => {
).toEqual(['pp-ocrv6-small'])
for (const entry of DOCUMENT_OCR_MODEL_CATALOG) {
expect(entry.repositoryUrls.modelscope).toMatch(
/^https:\/\/modelscope\.cn\/models\/PaddlePaddle\//u
)
expect(entry.repositoryUrls['hugging-face']).toMatch(
/^https:\/\/huggingface\.co\/PaddlePaddle\//u
)
for (const file of entry.files) {
expect(file.download.url).toMatch(
expect(file.targets.modelscope?.url).toMatch(
/^https:\/\/modelscope\.cn\/models\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
)
expect(file.download.sha256).toMatch(/^[a-f0-9]{64}$/u)
expect(file.download.size).toBeGreaterThan(0)
expect(file.targets['hugging-face']?.url).toMatch(
/^https:\/\/huggingface\.co\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
)
expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u)
expect(file.size).toBeGreaterThan(0)
}
}
expect(
DOCUMENT_OCR_MODEL_CATALOG.find(
(entry) => entry.id === 'pp-ocrv6-small'
)
).toMatchObject({
const small = DOCUMENT_OCR_MODEL_CATALOG.find(
(entry) => entry.id === 'pp-ocrv6-small'
)
expect(small).toMatchObject({
languages: ['50 种语言'],
quality: 'balanced',
speed: 'balanced',
@@ -207,30 +238,29 @@ describe('DocumentOcrModelManager', () => {
files: [
{
role: 'detection',
download: {
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/956a0b620a4017cc04056c692be1703b0025d028/inference.onnx',
size: 9_880_512,
sha256:
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e'
size: 9_880_512,
sha256:
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e',
targets: {
modelscope: {
revision: '956a0b620a4017cc04056c692be1703b0025d028'
},
'hugging-face': {
revision: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62'
}
}
},
{
role: 'recognition',
download: {
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.onnx',
size: 21_159_378,
sha256:
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
}
size: 21_159_378,
sha256:
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
},
{
role: 'dictionary',
download: {
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.yml',
size: 150_579,
sha256:
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
}
size: 150_579,
sha256:
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
}
]
})
@@ -246,30 +276,21 @@ describe('DocumentOcrModelManager', () => {
files: [
{
role: 'detection',
download: {
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_det_onnx/resolve/c317b40325be40bfaaff58c8dcece2a075294f8a/inference.onnx',
size: 62_032_837,
sha256:
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
}
size: 62_032_837,
sha256:
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
},
{
role: 'recognition',
download: {
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.onnx',
size: 76_554_979,
sha256:
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
}
size: 76_554_979,
sha256:
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
},
{
role: 'dictionary',
download: {
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.yml',
size: 150_580,
sha256:
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
}
size: 150_580,
sha256:
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
}
]
})
@@ -296,6 +317,58 @@ describe('DocumentOcrModelManager', () => {
expect(new TextDecoder().decode(assets.dictionary)).toContain(
"!\n\"\n'\n"
)
const snapshot = await manager.getSnapshot()
expect(snapshot.selectedDownloadSource).toBe('modelscope')
expect(snapshot.catalog[0]?.files[0]).not.toHaveProperty('targets')
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/')
})
it('downloads the same canonical package from Hugging Face', async () => {
const { manager } = await createManager()
await expect(
manager.install('pp-ocrv6-tiny', 'hugging-face')
).resolves.toMatchObject({
id: 'pp-ocrv6-tiny',
source: 'download'
})
})
it('does not request another source when selected coverage is missing', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-document-ocr-model-')
)
temporaryDirectories.push(directory)
const detection = Buffer.from('detection')
const recognition = Buffer.from('recognition')
const dictionary = dictionaryYaml()
const sourceCatalog = catalog(
detection,
recognition,
dictionary
).map((entry) => ({
...entry,
repositoryUrls: {
modelscope: entry.repositoryUrls.modelscope
},
files: entry.files.map((file) => ({
...file,
targets: {
modelscope: file.targets.modelscope
}
}))
}))
const transport = vi.fn<typeof fetch>()
const manager = new DocumentOcrModelManager({
userDataDirectory: directory,
fetch: transport,
catalog: sourceCatalog
})
await expect(
manager.install('pp-ocrv6-tiny', 'hugging-face')
).rejects.toThrow('当前下载源')
expect(transport).not.toHaveBeenCalled()
})
it('rejects an imported model whose hash does not match', async () => {
+99 -74
View File
@@ -15,26 +15,35 @@ import { dirname, resolve } from 'node:path'
import {
documentOcrAssetsSchema,
documentOcrModelCatalogEntrySchema,
documentOcrModelCatalogViewEntrySchema,
documentOcrModelSnapshotSchema,
documentParsingModelStatusSchema,
installedDocumentOcrModelSchema,
localOcrModelIdSchema,
type DocumentOcrAssets,
type DocumentOcrModelCatalogEntry,
type DocumentOcrModelCatalogViewEntry,
type DocumentOcrModelFile,
type DocumentOcrModelOperation,
type DocumentOcrModelSnapshot,
type InstalledDocumentOcrModel
} from '../shared/document-parsing-contracts'
import {
MODEL_DOWNLOAD_SOURCES,
getModelDownloadAvailability,
resolveModelDownloadPackage,
type ModelDownloadSource,
type ResolvedModelArtifactFile
} from '../shared/model-download-contracts'
import { DOCUMENT_OCR_MODEL_CATALOG } from './document-ocr-model-catalog'
import {
exportModelArchive,
extractModelArchive
} from './model-archive'
import { fetchModelDownloadResponse } from './model-download-transport'
const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const MAX_REDIRECTS = 3
const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
@@ -50,6 +59,9 @@ export type DocumentOcrModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
catalog?: readonly DocumentOcrModelCatalogEntry[]
getDownloadSource?: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number
}
@@ -69,6 +81,23 @@ function cloneCatalogEntry(
return documentOcrModelCatalogEntrySchema.parse(entry)
}
function toCatalogView(entry: DocumentOcrModelCatalogEntry) {
const { repositoryUrls, files, ...metadata } = entry
void repositoryUrls
return documentOcrModelCatalogViewEntrySchema.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 safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
@@ -77,14 +106,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('OCR 模型下载地址必须使用 HTTP 或 HTTPS')
}
return url
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return Uint8Array.from(buffer).buffer
}
@@ -157,6 +178,10 @@ export class DocumentOcrModelManager {
private readonly transport: typeof fetch
private readonly catalog: DocumentOcrModelCatalogEntry[]
private readonly catalogViews: DocumentOcrModelCatalogViewEntry[]
private readonly getDownloadSource: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>()
private readonly verifiedModels = new Map<string, Promise<void>>()
@@ -171,6 +196,8 @@ export class DocumentOcrModelManager {
'document-ocr'
)
this.transport = options.fetch
this.getDownloadSource =
options.getDownloadSource ?? (() => 'modelscope')
this.catalog = (options.catalog ?? DOCUMENT_OCR_MODEL_CATALOG).map(
cloneCatalogEntry
)
@@ -180,6 +207,7 @@ export class DocumentOcrModelManager {
) {
throw new Error('OCR 模型目录包含重复 ID')
}
this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
if (
!Number.isSafeInteger(this.maxFileBytes) ||
@@ -192,10 +220,15 @@ export class DocumentOcrModelManager {
async getSnapshot(): Promise<DocumentOcrModelSnapshot> {
await this.ensureRoot()
const [selectedDownloadSource, installed] = await Promise.all([
this.getDownloadSource(),
this.readInstalled()
])
return documentOcrModelSnapshotSchema.parse({
rootDirectory: this.rootDirectory,
catalog: this.catalog.map(cloneCatalogEntry),
installed: await this.readInstalled(),
selectedDownloadSource,
catalog: this.catalogViews,
installed,
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
}))
@@ -234,7 +267,7 @@ export class DocumentOcrModelManager {
available: false,
verified: false,
runtime: entry.runtime,
detail: '模型尚未安装或校验失败,请从 ModelScope 下载'
detail: '模型尚未安装或校验失败,请从当前模型下载源获取'
})
}
}
@@ -243,19 +276,37 @@ export class DocumentOcrModelManager {
return this.loadVerifiedAssets(this.requireCatalogEntry(modelId))
}
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('当前下载源暂不提供此 OCR 模型的仓库')
}
return repositoryUrl
}
async install(
modelId: string,
downloadSource?: ModelDownloadSource,
externalSignal?: AbortSignal
): Promise<InstalledDocumentOcrModel> {
const entry = this.requireCatalogEntry(modelId)
const totalBytes = entry.files.reduce(
(total, file) => total + file.download.size,
0
const selectedDownloadSource =
downloadSource ?? (await this.getDownloadSource())
const resolvedPackage = resolveModelDownloadPackage(
entry.files,
selectedDownloadSource
)
const operation = this.beginOperation(
entry.id,
'download',
resolvedPackage.totalBytes,
resolvedPackage.source
)
if (!Number.isSafeInteger(totalBytes)) {
throw new RangeError('OCR 模型总大小超出安全范围')
}
const operation = this.beginOperation(entry.id, 'download', totalBytes)
const detachAbort = this.attachExternalSignal(
externalSignal,
operation.controller
@@ -265,7 +316,7 @@ export class DocumentOcrModelManager {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of entry.files) {
for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
@@ -376,8 +427,8 @@ export class DocumentOcrModelManager {
)
if (
!recorded ||
recorded.size !== expected.download.size ||
recorded.sha256 !== expected.download.sha256
recorded.size !== expected.size ||
recorded.sha256 !== expected.sha256
) {
throw new Error(`OCR 模型文件校验失败:${expected.name}`)
}
@@ -406,7 +457,7 @@ export class DocumentOcrModelManager {
): Promise<InstalledDocumentOcrModel> {
const entry = this.requireCatalogEntry(modelId)
const expectedTotal = entry.files.reduce(
(total, file) => total + file.download.size,
(total, file) => total + file.size,
0
)
const operation = this.beginOperation(
@@ -448,8 +499,8 @@ export class DocumentOcrModelManager {
)
if (
!archived ||
archived.size !== expected.download.size ||
archived.sha256 !== expected.download.sha256
archived.size !== expected.size ||
archived.sha256 !== expected.sha256
) {
throw new Error(
`OCR 模型 ZIP 与当前模型目录不匹配:${expected.name}`
@@ -536,7 +587,8 @@ export class DocumentOcrModelManager {
private beginOperation(
modelId: string,
kind: DocumentOcrModelOperation['kind'],
totalBytes: number | null
totalBytes: number | null,
downloadSource?: ModelDownloadSource
): ActiveOperation {
if (this.operations.has(modelId)) {
throw new Error('该 OCR 模型已有进行中的操作')
@@ -549,7 +601,8 @@ export class DocumentOcrModelManager {
phase: 'preparing',
currentFile: null,
completedBytes: 0,
totalBytes
totalBytes,
...(downloadSource ? { downloadSource } : {})
}
}
this.operations.set(modelId, operation)
@@ -597,50 +650,22 @@ export class DocumentOcrModelManager {
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('OCR 模型下载重定向次数过多')
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error('OCR 模型下载重定向缺少地址')
}
url = validateDownloadUrl(new URL(location, url).toString())
continue
}
return response
}
}
private async downloadFile(
file: DocumentOcrModelFile,
file: ResolvedModelArtifactFile<DocumentOcrModelFile['role']>,
destination: string,
operation: ActiveOperation,
signal: AbortSignal
): Promise<void> {
if (file.download.size > this.maxFileBytes) {
if (file.size > this.maxFileBytes) {
throw new RangeError(`OCR 模型文件过大:${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: 'OCR 模型'
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(`OCR 模型下载失败:HTTP ${response.status}`)
@@ -651,7 +676,7 @@ export class DocumentOcrModelManager {
const declaredLength = response.headers.get('content-length')
if (
declaredLength !== null &&
Number(declaredLength) !== file.download.size
Number(declaredLength) !== file.size
) {
await response.body.cancel().catch(() => undefined)
throw new Error(`OCR 模型文件大小不匹配:${file.name}`)
@@ -671,7 +696,7 @@ export class DocumentOcrModelManager {
}
written += result.value.byteLength
if (
written > file.download.size ||
written > file.size ||
written > this.maxFileBytes
) {
await reader.cancel()
@@ -688,8 +713,8 @@ export class DocumentOcrModelManager {
await handle.close()
}
if (
written !== file.download.size ||
hash.digest('hex') !== file.download.sha256
written !== file.size ||
hash.digest('hex') !== file.sha256
) {
throw new Error(`OCR 模型文件校验失败:${file.name}`)
}
@@ -724,8 +749,8 @@ export class DocumentOcrModelManager {
}
const actual = await hashFile(path, signal)
if (
actual.size !== file.download.size ||
actual.sha256 !== file.download.sha256
actual.size !== file.size ||
actual.sha256 !== file.sha256
) {
throw new Error(`本地 OCR 模型文件校验失败:${file.name}`)
}
@@ -832,8 +857,8 @@ export class DocumentOcrModelManager {
const actual = await hashFile(safeChild(directory, file.name))
if (
!installed ||
actual.size !== file.download.size ||
actual.sha256 !== file.download.sha256 ||
actual.size !== file.size ||
actual.sha256 !== file.sha256 ||
actual.size !== installed.size ||
actual.sha256 !== installed.sha256
) {
@@ -879,8 +904,8 @@ export class DocumentOcrModelManager {
}
if (
!installed ||
actual.size !== file.download.size ||
actual.sha256 !== file.download.sha256 ||
actual.size !== file.size ||
actual.sha256 !== file.sha256 ||
actual.size !== installed.size ||
actual.sha256 !== installed.sha256
) {
+6 -2
View File
@@ -440,7 +440,9 @@ if (hasSingleInstanceLock) {
)
documentOcrModelManager = new DocumentOcrModelManager({
userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch
fetch: globalThis.fetch,
getDownloadSource: async () =>
(await applicationSettingsStore.get()).modelDownloadSource
})
documentOcrBroker = new DocumentOcrBroker(mainWindow)
const documentParsingService = new DocumentParsingService(
@@ -456,7 +458,9 @@ if (hasSingleInstanceLock) {
})
const speechModelManager = new SpeechModelManager({
userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch
fetch: globalThis.fetch,
getDownloadSource: async () =>
(await applicationSettingsStore.get()).modelDownloadSource
})
const speechTranscriptionService = new SpeechTranscriptionService(
speechModelManager
+173
View File
@@ -430,6 +430,179 @@ describe('registerIpcHandlers update source routing', () => {
})
})
describe('registerIpcHandlers model download source routing', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('uses the persisted source and rejects stale renderer requests', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const speechSnapshot = {
rootDirectory: 'C:\\models\\speech',
selectedDownloadSource: 'hugging-face',
catalog: [],
installed: [],
operations: [],
selectedModelId: null
}
const ocrSnapshot = {
settings: {},
status: {},
ocrModels: {
rootDirectory: 'C:\\models\\ocr',
selectedDownloadSource: 'hugging-face',
catalog: [],
installed: [],
operations: []
}
}
const speechModelManager = {
install: vi.fn(async () => undefined),
getSnapshot: vi.fn(async () => speechSnapshot),
getRepositoryUrl: vi.fn(
() => 'https://huggingface.co/example/speech'
)
}
const documentOcrModelManager = {
install: vi.fn(async () => undefined),
getRepositoryUrl: vi.fn(
() => 'https://huggingface.co/example/ocr'
)
}
const documentParsingService = {
snapshot: vi.fn(async () => ocrSnapshot)
}
const applicationSettingsStore = {
get: vi.fn(async () => ({
modelDownloadSource: 'hugging-face'
}))
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{ claimDueSchedules: vi.fn(() => []) } as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
applicationSettingsStore as never,
undefined,
speechModelManager as never,
undefined,
undefined,
undefined,
undefined,
undefined,
documentParsingService as never,
documentOcrModelManager as never
)
await expect(
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
event,
{
modelId: 'speech-model',
expectedDownloadSource: 'hugging-face'
}
)
).resolves.toEqual(speechSnapshot)
expect(speechModelManager.install).toHaveBeenCalledWith(
'speech-model',
'hugging-face'
)
await expect(
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
event,
{
modelId: 'speech-model',
expectedDownloadSource: 'modelscope'
}
)
).rejects.toThrow('模型下载源已变化')
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
applicationSettingsStore.get.mockRejectedValueOnce(
new Error('settings unavailable')
)
await expect(
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
event,
{
modelId: 'speech-model',
expectedDownloadSource: 'hugging-face'
}
)
).rejects.toThrow('settings unavailable')
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsInstall
)?.(event, {
modelId: 'ocr-model',
expectedDownloadSource: 'hugging-face'
})
).resolves.toEqual(ocrSnapshot)
expect(documentOcrModelManager.install).toHaveBeenCalledWith(
'ocr-model',
'hugging-face'
)
await expect(
electronMocks.handlers.get(
ipcChannels.speechModelsOpenRepository
)?.(event, { modelId: 'speech-model' })
).resolves.toBeUndefined()
expect(speechModelManager.getRepositoryUrl).toHaveBeenCalledWith(
'speech-model',
'hugging-face'
)
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
'https://huggingface.co/example/speech'
)
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsOpenRepository
)?.(event, { modelId: 'ocr-model' })
).resolves.toBeUndefined()
expect(
documentOcrModelManager.getRepositoryUrl
).toHaveBeenCalledWith('ocr-model', 'hugging-face')
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
'https://huggingface.co/example/ocr'
)
await dispose()
})
})
vi.mock('electron', () => ({
app: {
getName: vi.fn(() => 'GoodBuddy'),
+43 -24
View File
@@ -109,6 +109,7 @@ import { applicationSettingsUpdateSchema } from '../shared/application-settings-
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
import {
speechModelActionInputSchema,
speechModelInstallInputSchema,
speechModelSelectionInputSchema
} from '../shared/speech-model-contracts'
import {
@@ -119,6 +120,7 @@ import {
} from '../shared/embedding-contracts'
import {
documentOcrModelActionInputSchema,
documentOcrModelInstallInputSchema,
documentOcrFailureSchema,
documentOcrResultSchema,
documentParsingSettingsUpdateSchema,
@@ -3513,16 +3515,25 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.documentOcrModelsInstall,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!documentOcrModelManager || !documentParsingService) {
if (
!documentOcrModelManager ||
!documentParsingService ||
!applicationSettingsStore
) {
throw new Error('本地 OCR 模型服务不可用')
}
const { modelId } =
documentOcrModelActionInputSchema.parse(input)
const { modelId, expectedDownloadSource } =
documentOcrModelInstallInputSchema.parse(input)
const { modelDownloadSource: selectedDownloadSource } =
await applicationSettingsStore.get()
if (selectedDownloadSource !== expectedDownloadSource) {
throw new Error('模型下载源已变化,请刷新后重试')
}
return trackExecution(
documentOcrModelManager
.install(modelId)
.install(modelId, selectedDownloadSource)
.then(() => documentParsingService.snapshot())
)
}
@@ -3611,19 +3622,19 @@ export function registerIpcHandlers(
ipcChannels.documentOcrModelsOpenRepository,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!documentOcrModelManager) {
if (!documentOcrModelManager || !applicationSettingsStore) {
throw new Error('本地 OCR 模型服务不可用')
}
const { modelId } =
documentOcrModelActionInputSchema.parse(input)
const snapshot = await documentOcrModelManager.getSnapshot()
const entry = snapshot.catalog.find(
(candidate) => candidate.id === modelId
const { modelDownloadSource: selectedDownloadSource } =
await applicationSettingsStore.get()
await shell.openExternal(
documentOcrModelManager.getRepositoryUrl(
modelId,
selectedDownloadSource
)
)
if (!entry) {
throw new Error('未知的 OCR 模型')
}
await shell.openExternal(entry.repositoryUrl)
}
)
@@ -3759,15 +3770,21 @@ export function registerIpcHandlers(
registerHandler(
ipcChannels.speechModelsInstall,
(event, input: unknown) => {
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!speechModelManager) {
if (!speechModelManager || !applicationSettingsStore) {
throw new Error('语音模型服务不可用')
}
const { modelId } = speechModelActionInputSchema.parse(input)
const { modelId, expectedDownloadSource } =
speechModelInstallInputSchema.parse(input)
const { modelDownloadSource: selectedDownloadSource } =
await applicationSettingsStore.get()
if (selectedDownloadSource !== expectedDownloadSource) {
throw new Error('模型下载源已变化,请刷新后重试')
}
return trackExecution(
speechModelManager
.install(modelId)
.install(modelId, selectedDownloadSource)
.then(() => speechModelManager.getSnapshot())
)
}
@@ -3862,16 +3879,18 @@ export function registerIpcHandlers(
ipcChannels.speechModelsOpenRepository,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!speechModelManager) {
if (!speechModelManager || !applicationSettingsStore) {
throw new Error('语音模型服务不可用')
}
const { modelId } = speechModelActionInputSchema.parse(input)
const snapshot = await speechModelManager.getSnapshot()
const entry = snapshot.catalog.find((item) => item.id === modelId)
if (!entry) {
throw new Error('未知的语音模型')
}
await shell.openExternal(entry.repositoryUrl)
const { modelDownloadSource: selectedDownloadSource } =
await applicationSettingsStore.get()
await shell.openExternal(
speechModelManager.getRepositoryUrl(
modelId,
selectedDownloadSource
)
)
}
)
+34
View File
@@ -0,0 +1,34 @@
import {
MODEL_DOWNLOAD_REDIRECT_HOSTS,
type ModelArtifactTarget
} from '../shared/model-download-contracts'
export function modelScopeTarget(
repository: string,
revision: string,
file: string
): ModelArtifactTarget {
const repositoryUrl = `https://modelscope.cn/models/${repository}`
return {
url: `${repositoryUrl}/resolve/${revision}/${file}`,
repositoryUrl,
revision,
redirectHosts: []
}
}
export function huggingFaceTarget(
repository: string,
revision: string,
file: string
): ModelArtifactTarget {
const repositoryUrl = `https://huggingface.co/${repository}`
return {
url: `${repositoryUrl}/resolve/${revision}/${file}`,
repositoryUrl,
revision,
redirectHosts: [
...MODEL_DOWNLOAD_REDIRECT_HOSTS['hugging-face']
]
}
}
+71
View File
@@ -0,0 +1,71 @@
const MAX_REDIRECTS = 3
const redirectStatuses = new Set([301, 302, 303, 307, 308])
function validateDownloadUrl(value: string, modelLabel: string): URL {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
(url.port !== '' && url.port !== '443') ||
url.username ||
url.password ||
url.hash
) {
throw new Error(
`${modelLabel}下载地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL`
)
}
return url
}
export async function fetchModelDownloadResponse(options: {
transport: typeof fetch
initialUrl: string
redirectHosts: readonly string[]
signal: AbortSignal
modelLabel: string
}): Promise<Response> {
let url = validateDownloadUrl(options.initialUrl, options.modelLabel)
const initialHost = url.hostname
const allowedRedirectHosts = new Set(options.redirectHosts)
for (let redirectCount = 0; ; redirectCount += 1) {
if (options.signal.aborted) {
throw new DOMException('The operation was aborted', 'AbortError')
}
const response = await options.transport(url, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal: options.signal
})
if (!redirectStatuses.has(response.status)) {
return response
}
if (redirectCount >= MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new Error(
`${options.modelLabel}下载重定向次数过多`
)
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error(
`${options.modelLabel}下载重定向缺少地址`
)
}
const nextUrl = validateDownloadUrl(
new URL(location, url).toString(),
options.modelLabel
)
if (
nextUrl.hostname !== initialHost &&
!allowedRedirectHosts.has(nextUrl.hostname)
) {
throw new Error(
`${options.modelLabel}下载重定向到未声明的主机`
)
}
url = nextUrl
}
}
+214 -155
View File
@@ -2,11 +2,33 @@ import {
speechModelCatalogEntrySchema,
type SpeechModelCatalogEntry
} from '../../shared/speech-model-contracts'
import {
huggingFaceTarget,
modelScopeTarget
} from '../model-download-targets'
const senseVoiceModelScopeRepository =
'pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue'
const senseVoiceModelScopeRevision =
'73eca47697f980daa3d16112404174b6b950b514'
const senseVoiceHuggingFaceRepository =
'csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17'
const senseVoiceHuggingFaceRevision =
'2365baeacb507f821a0c8120fcee3d484dba7a07'
const whisperTinyModelScopeRepository =
'pengzhendong/sherpa-onnx-whisper-tiny'
const whisperTinyModelScopeRevision =
'33a655645234f82ce833cf27b689d9c2212e693f'
const whisperTinyHuggingFaceRepository =
'csukuangfj/sherpa-onnx-whisper-tiny'
const whisperTinyHuggingFaceRevision =
'65176e2deb88badc814a94058666cadccc29b61c'
/**
* This catalog intentionally contains metadata only. Model weights are never
* bundled with GoodBuddy. Entries remain manual-only until every downloadable
* file has a pinned revision, byte size, and independently verified SHA-256.
* Model weights are never bundled with GoodBuddy. Canonical file identity is
* source-independent; a source target is included only after its bytes match
* the declared size and SHA-256.
*/
export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
speechModelCatalogEntrySchema.array().parse([
@@ -21,13 +43,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high',
speed: 'fast',
recommended: true,
repositoryUrl:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue',
repositoryUrls: {
modelscope:
`https://modelscope.cn/models/${senseVoiceModelScopeRepository}`,
'hugging-face':
`https://huggingface.co/${senseVoiceHuggingFaceRepository}`
},
license: {
name: '模型仓库自定义许可(Model License',
notice:
'SenseVoiceSmall 权重采用模型仓库声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。',
'SenseVoiceSmall 权重采用上游声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。',
url: 'https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE'
},
manualOnly: false,
@@ -35,29 +60,39 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{
name: 'model.int8.onnx',
role: 'model',
download: {
url:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' +
'resolve/73eca47697f980daa3d16112404174b6b950b514/' +
'model.int8.onnx',
size: 239_233_841,
sha256:
'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51'
size: 239_233_841,
sha256:
'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
targets: {
modelscope: modelScopeTarget(
senseVoiceModelScopeRepository,
senseVoiceModelScopeRevision,
'model.int8.onnx'
),
'hugging-face': huggingFaceTarget(
senseVoiceHuggingFaceRepository,
senseVoiceHuggingFaceRevision,
'model.int8.onnx'
)
}
},
{
name: 'tokens.txt',
role: 'tokens',
download: {
url:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' +
'resolve/73eca47697f980daa3d16112404174b6b950b514/' +
'tokens.txt',
size: 315_894,
sha256:
'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc'
size: 315_894,
sha256:
'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
targets: {
modelscope: modelScopeTarget(
senseVoiceModelScopeRepository,
senseVoiceModelScopeRevision,
'tokens.txt'
),
'hugging-face': huggingFaceTarget(
senseVoiceHuggingFaceRepository,
senseVoiceHuggingFaceRevision,
'tokens.txt'
)
}
}
]
@@ -73,9 +108,12 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'basic',
speed: 'fast',
recommended: false,
repositoryUrl:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-whisper-tiny',
repositoryUrls: {
modelscope:
`https://modelscope.cn/models/${whisperTinyModelScopeRepository}`,
'hugging-face':
`https://huggingface.co/${whisperTinyHuggingFaceRepository}`
},
license: {
name: 'MIT License',
notice:
@@ -87,43 +125,58 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{
name: 'tiny-encoder.int8.onnx',
role: 'encoder',
download: {
url:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-whisper-tiny/resolve/' +
'33a655645234f82ce833cf27b689d9c2212e693f/' +
'tiny-encoder.int8.onnx',
size: 12_937_772,
sha256:
'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434'
size: 12_937_772,
sha256:
'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434',
targets: {
modelscope: modelScopeTarget(
whisperTinyModelScopeRepository,
whisperTinyModelScopeRevision,
'tiny-encoder.int8.onnx'
),
'hugging-face': huggingFaceTarget(
whisperTinyHuggingFaceRepository,
whisperTinyHuggingFaceRevision,
'tiny-encoder.int8.onnx'
)
}
},
{
name: 'tiny-decoder.int8.onnx',
role: 'decoder',
download: {
url:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-whisper-tiny/resolve/' +
'33a655645234f82ce833cf27b689d9c2212e693f/' +
'tiny-decoder.int8.onnx',
size: 89_855_401,
sha256:
'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925'
size: 89_855_401,
sha256:
'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925',
targets: {
modelscope: modelScopeTarget(
whisperTinyModelScopeRepository,
whisperTinyModelScopeRevision,
'tiny-decoder.int8.onnx'
),
'hugging-face': huggingFaceTarget(
whisperTinyHuggingFaceRepository,
whisperTinyHuggingFaceRevision,
'tiny-decoder.int8.onnx'
)
}
},
{
name: 'tiny-tokens.txt',
role: 'tokens',
download: {
url:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-whisper-tiny/resolve/' +
'33a655645234f82ce833cf27b689d9c2212e693f/' +
'tiny-tokens.txt',
size: 816_730,
sha256:
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
size: 816_730,
sha256:
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
targets: {
modelscope: modelScopeTarget(
whisperTinyModelScopeRepository,
whisperTinyModelScopeRevision,
'tiny-tokens.txt'
),
'hugging-face': huggingFaceTarget(
whisperTinyHuggingFaceRepository,
whisperTinyHuggingFaceRevision,
'tiny-tokens.txt'
)
}
}
]
@@ -139,9 +192,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high',
speed: 'fast',
recommended: true,
repositoryUrl:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-paraformer-bilingual-zh-en',
repositoryUrls: {
'hugging-face':
'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en'
},
license: {
name: 'MIT License',
notice:
@@ -156,29 +210,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{
name: 'model.int8.onnx',
role: 'model',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' +
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' +
'model.int8.onnx',
size: 223_385_835,
sha256:
'9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7'
size: 223_385_835,
sha256:
'9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
'model.int8.onnx'
)
}
},
{
name: 'tokens.txt',
role: 'tokens',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' +
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' +
'tokens.txt',
size: 75_756,
sha256:
'59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6'
size: 75_756,
sha256:
'59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
'tokens.txt'
)
}
}
]
@@ -194,9 +248,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high',
speed: 'balanced',
recommended: false,
repositoryUrl:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
repositoryUrls: {
'hugging-face':
'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en'
},
license: {
name: 'Apache License 2.0',
notice:
@@ -211,29 +266,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{
name: 'model.int8.onnx',
role: 'model',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' +
'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' +
'model.int8.onnx',
size: 244_684_152,
sha256:
'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144'
size: 244_684_152,
sha256:
'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
'8d90151338178bb433354c9fb677bd3acb8023cd',
'model.int8.onnx'
)
}
},
{
name: 'tokens.txt',
role: 'tokens',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' +
'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' +
'tokens.txt',
size: 118_931,
sha256:
'8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492'
size: 118_931,
sha256:
'8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
'8d90151338178bb433354c9fb677bd3acb8023cd',
'tokens.txt'
)
}
}
]
@@ -249,8 +304,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'balanced',
speed: 'balanced',
recommended: false,
repositoryUrl:
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small',
repositoryUrls: {
'hugging-face':
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small'
},
license: {
name: 'MIT License',
notice:
@@ -262,43 +319,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{
name: 'small-encoder.int8.onnx',
role: 'encoder',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-whisper-small/resolve/' +
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
'small-encoder.int8.onnx',
size: 112_442_483,
sha256:
'4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9'
size: 112_442_483,
sha256:
'4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-whisper-small',
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
'small-encoder.int8.onnx'
)
}
},
{
name: 'small-decoder.int8.onnx',
role: 'decoder',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-whisper-small/resolve/' +
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
'small-decoder.int8.onnx',
size: 262_226_114,
sha256:
'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee'
size: 262_226_114,
sha256:
'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-whisper-small',
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
'small-decoder.int8.onnx'
)
}
},
{
name: 'small-tokens.txt',
role: 'tokens',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-whisper-small/resolve/' +
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
'small-tokens.txt',
size: 816_730,
sha256:
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
size: 816_730,
sha256:
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-whisper-small',
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
'small-tokens.txt'
)
}
}
]
@@ -314,8 +371,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high',
speed: 'slow',
recommended: false,
repositoryUrl:
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium',
repositoryUrls: {
'hugging-face':
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium'
},
license: {
name: 'MIT License',
notice:
@@ -327,43 +386,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{
name: 'medium-encoder.int8.onnx',
role: 'encoder',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-whisper-medium/resolve/' +
'8c31d28503847560985df21f90e14f0c736e075e/' +
'medium-encoder.int8.onnx',
size: 374_196_283,
sha256:
'1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e'
size: 374_196_283,
sha256:
'1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-whisper-medium',
'8c31d28503847560985df21f90e14f0c736e075e',
'medium-encoder.int8.onnx'
)
}
},
{
name: 'medium-decoder.int8.onnx',
role: 'decoder',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-whisper-medium/resolve/' +
'8c31d28503847560985df21f90e14f0c736e075e/' +
'medium-decoder.int8.onnx',
size: 571_059_257,
sha256:
'595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747'
size: 571_059_257,
sha256:
'595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-whisper-medium',
'8c31d28503847560985df21f90e14f0c736e075e',
'medium-decoder.int8.onnx'
)
}
},
{
name: 'medium-tokens.txt',
role: 'tokens',
download: {
url:
'https://huggingface.co/csukuangfj/' +
'sherpa-onnx-whisper-medium/resolve/' +
'8c31d28503847560985df21f90e14f0c736e075e/' +
'medium-tokens.txt',
size: 816_730,
sha256:
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
size: 816_730,
sha256:
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
targets: {
'hugging-face': huggingFaceTarget(
'csukuangfj/sherpa-onnx-whisper-medium',
'8c31d28503847560985df21f90e14f0c736e075e',
'medium-tokens.txt'
)
}
}
]
+250 -45
View File
@@ -35,12 +35,36 @@ function sha256(value: Uint8Array): string {
}
function manualCatalog(): SpeechModelCatalogEntry[] {
return SPEECH_MODEL_CATALOG.map((entry) => ({
const entry = SPEECH_MODEL_CATALOG.find(
(candidate) => candidate.id === 'sensevoice-small-int8'
)
if (!entry) {
throw new Error('SenseVoice test catalog entry is missing')
}
const modelBytes = new TextEncoder().encode('model')
const tokenBytes = new TextEncoder().encode('tokens')
return [{
...entry,
manualOnly: true,
manualReason: entry.manualReason ?? '测试使用本地目录导入。',
files: entry.files.map(({ name, role }) => ({ name, role }))
}))
manualReason: '测试使用本地目录导入。',
repositoryUrls: {},
files: [
{
name: 'model.int8.onnx',
role: 'model',
size: modelBytes.byteLength,
sha256: sha256(modelBytes),
targets: {}
},
{
name: 'tokens.txt',
role: 'tokens',
size: tokenBytes.byteLength,
sha256: sha256(tokenBytes),
targets: {}
}
]
}]
}
function downloadableCatalog(
@@ -58,8 +82,12 @@ function downloadableCatalog(
quality: 'balanced',
speed: 'balanced',
recommended: false,
repositoryUrl:
'https://modelscope.cn/models/example/download-test-model',
repositoryUrls: {
modelscope:
'https://modelscope.cn/models/example/download-test-model',
'hugging-face':
'https://huggingface.co/example/download-test-model'
},
license: {
name: 'MIT License',
notice: 'Test-only model metadata.',
@@ -70,23 +98,53 @@ function downloadableCatalog(
{
name: 'model.onnx',
role: 'model',
download: {
url:
'https://modelscope.cn/models/example/download-test-model/' +
`resolve/${'a'.repeat(40)}/model.onnx`,
size: modelBytes.byteLength,
sha256: sha256(modelBytes)
size: modelBytes.byteLength,
sha256: sha256(modelBytes),
targets: {
modelscope: {
url:
'https://modelscope.cn/models/example/download-test-model/' +
`resolve/${'a'.repeat(40)}/model.onnx`,
repositoryUrl:
'https://modelscope.cn/models/example/download-test-model',
revision: 'a'.repeat(40),
redirectHosts: []
},
'hugging-face': {
url:
'https://huggingface.co/example/download-test-model/' +
`resolve/${'b'.repeat(40)}/model.onnx`,
repositoryUrl:
'https://huggingface.co/example/download-test-model',
revision: 'b'.repeat(40),
redirectHosts: []
}
}
},
{
name: 'tokens.txt',
role: 'tokens',
download: {
url:
'https://modelscope.cn/models/example/download-test-model/' +
`resolve/${'a'.repeat(40)}/tokens.txt`,
size: tokenBytes.byteLength,
sha256: sha256(tokenBytes)
size: tokenBytes.byteLength,
sha256: sha256(tokenBytes),
targets: {
modelscope: {
url:
'https://modelscope.cn/models/example/download-test-model/' +
`resolve/${'a'.repeat(40)}/tokens.txt`,
repositoryUrl:
'https://modelscope.cn/models/example/download-test-model',
revision: 'a'.repeat(40),
redirectHosts: []
},
'hugging-face': {
url:
'https://huggingface.co/example/download-test-model/' +
`resolve/${'b'.repeat(40)}/tokens.txt`,
repositoryUrl:
'https://huggingface.co/example/download-test-model',
revision: 'b'.repeat(40),
redirectHosts: []
}
}
}
]
@@ -131,10 +189,19 @@ describe('speech model catalog', () => {
license: { name: 'MIT License' }
})
expect(
senseVoice?.files.every((file) => file.download !== undefined)
senseVoice?.files.every(
(file) =>
file.targets.modelscope !== undefined &&
file.targets['hugging-face'] !== undefined
)
).toBe(true)
expect(
whisper?.files.every(
(file) =>
file.targets.modelscope !== undefined &&
file.targets['hugging-face'] !== undefined
)
).toBe(true)
expect(whisper?.files.every((file) => file.download !== undefined))
.toBe(true)
expect(whisper?.files.map((file) => file.name)).toEqual([
'tiny-encoder.int8.onnx',
'tiny-decoder.int8.onnx',
@@ -163,15 +230,21 @@ describe('speech model catalog', () => {
})
expect(SPEECH_MODEL_CATALOG).toHaveLength(6)
for (const entry of SPEECH_MODEL_CATALOG) {
expect(entry.repositoryUrl).toMatch(
/^https:\/\/(?:modelscope\.cn\/models\/|huggingface\.co\/)/u
)
for (const file of entry.files) {
expect(file.download?.url).toMatch(
/^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
)
expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u)
expect(file.size).toBeGreaterThan(0)
for (const target of Object.values(file.targets)) {
expect(target?.url).toMatch(
/^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
)
}
}
}
expect(
paraformerBilingual?.files.some(
(file) => file.targets.modelscope !== undefined
)
).toBe(false)
})
})
@@ -242,10 +315,14 @@ describe('SpeechModelManager downloads', () => {
).toBe(false)
await manager.select('download-test-model')
await expect(manager.snapshot()).resolves.toMatchObject({
const snapshot = await manager.snapshot()
expect(snapshot).toMatchObject({
selectedDownloadSource: 'modelscope',
selectedModelId: 'download-test-model',
operations: []
})
expect(snapshot.catalog[0]?.files[0]).not.toHaveProperty('targets')
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/')
await manager.remove('download-test-model')
await expect(manager.snapshot()).resolves.toMatchObject({
selectedModelId: null,
@@ -253,7 +330,63 @@ describe('SpeechModelManager downloads', () => {
})
})
it('accepts arbitrary HTTP hosts and cross-host redirects', async () => {
it('freezes the operation source when the global setting changes', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
const tokenBytes = new TextEncoder().encode('tokens')
let selectedSource: 'modelscope' | 'hugging-face' = 'modelscope'
let releaseFirstRequest: (() => void) | undefined
let markFirstRequestStarted: (() => void) | undefined
const firstRequestStarted = new Promise<void>((resolveStarted) => {
markFirstRequestStarted = resolveStarted
})
const firstRequestGate = new Promise<void>((resolveRequest) => {
releaseFirstRequest = resolveRequest
})
let requestCount = 0
const transport = vi.fn<typeof fetch>(async (input) => {
requestCount += 1
if (requestCount === 1) {
markFirstRequestStarted?.()
await firstRequestGate
}
const bytes = String(input).endsWith('model.onnx')
? modelBytes
: tokenBytes
return new Response(bytes, {
headers: { 'content-length': String(bytes.byteLength) }
})
})
const manager = new SpeechModelManager({
userDataDirectory: userData,
catalog: downloadableCatalog(modelBytes, tokenBytes),
fetch: transport,
getDownloadSource: () => selectedSource
})
const installing = manager.install('download-test-model')
await firstRequestStarted
selectedSource = 'hugging-face'
await expect(manager.snapshot()).resolves.toMatchObject({
selectedDownloadSource: 'hugging-face',
operations: [
{
modelId: 'download-test-model',
kind: 'download',
downloadSource: 'modelscope'
}
]
})
releaseFirstRequest?.()
await installing
expect(
transport.mock.calls.every(
([input]) => new URL(String(input)).hostname === 'modelscope.cn'
)
).toBe(true)
})
it('follows only source-declared HTTPS redirect hosts', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
const tokenBytes = new TextEncoder().encode('tokens')
@@ -261,24 +394,24 @@ describe('SpeechModelManager downloads', () => {
...entry,
files: entry.files.map((file) => ({
...file,
download: file.download
? {
...file.download,
url: file.download.url.replace(
'https://modelscope.cn',
'http://models.internal.example'
)
}
: undefined
targets: {
...file.targets,
'hugging-face': file.targets['hugging-face']
? {
...file.targets['hugging-face'],
redirectHosts: ['cdn-lfs.hf.co']
}
: undefined
}
}))
}))
const transport = vi.fn<typeof fetch>(async (input) => {
const url = new URL(String(input))
if (url.hostname === 'models.internal.example') {
if (url.hostname === 'huggingface.co') {
return new Response(null, {
status: 302,
headers: {
location: `https://cdn.example.net${url.pathname}`
location: `https://cdn-lfs.hf.co${url.pathname}`
}
})
}
@@ -296,19 +429,91 @@ describe('SpeechModelManager downloads', () => {
})
await expect(
redirected.install('download-test-model')
redirected.install('download-test-model', 'hugging-face')
).resolves.toMatchObject({ id: 'download-test-model' })
expect(transport).toHaveBeenCalledTimes(4)
expect(
transport.mock.calls.map(([input]) => new URL(String(input)).hostname)
).toEqual([
'models.internal.example',
'cdn.example.net',
'models.internal.example',
'cdn.example.net'
'huggingface.co',
'cdn-lfs.hf.co',
'huggingface.co',
'cdn-lfs.hf.co'
])
})
it('rejects undeclared redirect hosts without following them', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
const transport = vi.fn<typeof fetch>(async () =>
new Response(null, {
status: 302,
headers: {
location: 'https://untrusted.example/model.onnx'
}
})
)
const manager = new SpeechModelManager({
userDataDirectory: userData,
catalog: downloadableCatalog(modelBytes),
fetch: transport
})
await expect(
manager.install('download-test-model')
).rejects.toThrow('未声明的主机')
expect(transport).toHaveBeenCalledTimes(1)
})
it('does not request another source when selected coverage is missing', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
const catalog = downloadableCatalog(modelBytes).map((entry) => ({
...entry,
repositoryUrls: {
'hugging-face': entry.repositoryUrls['hugging-face']
},
files: entry.files.map((file) => ({
...file,
targets: {
'hugging-face': file.targets['hugging-face']
}
}))
}))
const transport = vi.fn<typeof fetch>()
const manager = new SpeechModelManager({
userDataDirectory: userData,
catalog,
fetch: transport
})
await expect(
manager.install('download-test-model', 'modelscope')
).rejects.toThrow('当前下载源')
expect(transport).not.toHaveBeenCalled()
})
it('does not request another source after a download failure', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
const transport = vi.fn<typeof fetch>(
async () => new Response(null, { status: 503 })
)
const manager = new SpeechModelManager({
userDataDirectory: userData,
catalog: downloadableCatalog(modelBytes),
fetch: transport
})
await expect(
manager.install('download-test-model', 'modelscope')
).rejects.toThrow('HTTP 503')
expect(transport).toHaveBeenCalledTimes(1)
expect(
new URL(String(transport.mock.calls[0]?.[0])).hostname
).toBe('modelscope.cn')
})
it('rejects bad digests without installing', async () => {
const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected')
+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}`)
}