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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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']
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
+11
-4
@@ -72,6 +72,7 @@ import type {
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
ApplicationSettingsUpdate,
|
||||
ModelDownloadSource,
|
||||
VersionCheckResult
|
||||
} from '../shared/application-settings-contracts'
|
||||
import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts'
|
||||
@@ -404,10 +405,13 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.speechModelsGet
|
||||
) as Promise<SpeechModelSnapshot>,
|
||||
install: (modelId: string) =>
|
||||
install: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.speechModelsInstall,
|
||||
{ modelId }
|
||||
{ modelId, expectedDownloadSource }
|
||||
) as Promise<SpeechModelSnapshot>,
|
||||
cancel: (modelId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
@@ -481,10 +485,13 @@ const desktopApi: DesktopApi = {
|
||||
ipcChannels.documentParsingTest,
|
||||
{ purpose }
|
||||
) as Promise<DocumentParsingDiagnostic | undefined>,
|
||||
installOcrModel: (modelId: string) =>
|
||||
installOcrModel: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.documentOcrModelsInstall,
|
||||
{ modelId }
|
||||
{ modelId, expectedDownloadSource }
|
||||
) as Promise<DocumentParsingSnapshot>,
|
||||
cancelOcrModelOperation: (modelId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
|
||||
@@ -1084,6 +1084,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1176,6 +1177,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1183,6 +1185,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1276,6 +1279,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1283,6 +1287,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7206,6 +7211,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7213,6 +7219,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7253,6 +7260,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7260,6 +7268,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7292,6 +7301,7 @@ describe('App', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -7315,6 +7325,9 @@ describe('App', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '平台功能' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
|
||||
@@ -31,8 +31,6 @@ const modelEntry = {
|
||||
quality: 'basic' as const,
|
||||
speed: 'fast' as const,
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice: '使用前请阅读模型许可证。',
|
||||
@@ -42,29 +40,32 @@ const modelEntry = {
|
||||
{
|
||||
name: 'detection.onnx',
|
||||
role: 'detection' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/detection.onnx',
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'recognition.onnx',
|
||||
role: 'recognition' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/recognition.onnx',
|
||||
size: 2_000,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
size: 2_000,
|
||||
sha256: 'b'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'dictionary.yml',
|
||||
role: 'dictionary' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/dictionary.yml',
|
||||
size: 500,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
size: 500,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true,
|
||||
totalBytes: 3_500
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 3_500
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -101,6 +102,7 @@ const snapshot: DocumentParsingSnapshot = {
|
||||
},
|
||||
ocrModels: {
|
||||
rootDirectory: 'C:\\Users\\test\\models\\document-ocr',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [modelEntry, secondModelEntry, thirdModelEntry],
|
||||
installed: [
|
||||
{
|
||||
@@ -111,8 +113,8 @@ const snapshot: DocumentParsingSnapshot = {
|
||||
files: secondModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
],
|
||||
@@ -137,7 +139,7 @@ const test = vi.fn(async () => ({
|
||||
warnings: []
|
||||
}))
|
||||
const installOcrModel =
|
||||
vi.fn<() => Promise<DocumentParsingSnapshot>>(async () => ({
|
||||
vi.fn(async (): Promise<DocumentParsingSnapshot> => ({
|
||||
...snapshot,
|
||||
status: {
|
||||
...snapshot.status,
|
||||
@@ -159,8 +161,8 @@ const installOcrModel =
|
||||
files: modelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
]
|
||||
@@ -305,7 +307,10 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(installOcrModel).toHaveBeenCalledWith('pp-ocrv6-tiny')
|
||||
expect(installOcrModel).toHaveBeenCalledWith(
|
||||
'pp-ocrv6-tiny',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -336,8 +341,8 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
files: thirdModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
} satisfies InstalledDocumentOcrModel
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
DocumentParsingDiagnostic,
|
||||
DocumentOcrModelCatalogEntry,
|
||||
DocumentOcrModelCatalogViewEntry,
|
||||
DocumentOcrModelOperation,
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string): string {
|
||||
@@ -54,9 +55,9 @@ function formatBytes(bytes: number): string {
|
||||
: `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: DocumentOcrModelCatalogEntry): number {
|
||||
function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
|
||||
return entry.files.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
}
|
||||
@@ -207,7 +208,8 @@ function DiagnosticDialog({
|
||||
}
|
||||
|
||||
export function DocumentParsingSettingsSection({
|
||||
onNotify
|
||||
onNotify,
|
||||
onOpenModelDownloadSourceSettings
|
||||
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
|
||||
@@ -439,6 +441,13 @@ export function DocumentParsingSettingsSection({
|
||||
const modelProgress = modelOperation
|
||||
? progressPercent(modelOperation)
|
||||
: undefined
|
||||
const modelDownloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.ocrModels.selectedDownloadSource
|
||||
)
|
||||
const modelSourceAvailable =
|
||||
modelDownloadAvailability?.available === true
|
||||
const pendingModelSelection =
|
||||
draft.localOcrModelId !== snapshot.settings.localOcrModelId
|
||||
const settingsDirty =
|
||||
@@ -701,7 +710,17 @@ export function DocumentParsingSettingsSection({
|
||||
{entryDisplayName} ·{' '}
|
||||
{installed
|
||||
? t('documentParsing.ocr.installedOption')
|
||||
: t('documentParsing.ocr.downloadableOption')}
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.ocrModels
|
||||
.selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('documentParsing.ocr.downloadableOption')
|
||||
: t(
|
||||
'documentParsing.ocr.sourceUnavailableOption'
|
||||
)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -720,6 +739,13 @@ export function DocumentParsingSettingsSection({
|
||||
<code>{snapshot.ocrModels.rootDirectory}</code>
|
||||
{t('documentParsing.ocr.storageSuffix')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('documentParsing.ocr.downloadSource', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
|
||||
{model ? (
|
||||
<article className="document-ocr-model">
|
||||
@@ -735,7 +761,11 @@ export function DocumentParsingSettingsSection({
|
||||
</div>
|
||||
<p>{modelDescription}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">ModelScope</span>
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.languages
|
||||
.map((language) =>
|
||||
@@ -774,9 +804,15 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.openRepository',
|
||||
{ name: modelDisplayName }
|
||||
{
|
||||
name: modelDisplayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
className="secondary-button document-ocr-model__repository"
|
||||
disabled={!modelSourceAvailable}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.documentParsing
|
||||
?.openOcrModelRepository(model.id)
|
||||
@@ -784,7 +820,11 @@ export function DocumentParsingSettingsSection({
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
{t('documentParsing.ocr.openRepository')}
|
||||
{t('documentParsing.ocr.openRepository', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -806,7 +846,12 @@ export function DocumentParsingSettingsSection({
|
||||
? 'documentParsing.ocr.operations.installing'
|
||||
: modelOperation.kind === 'import'
|
||||
? 'documentParsing.ocr.operations.importing'
|
||||
: 'documentParsing.ocr.operations.downloading'
|
||||
: 'documentParsing.ocr.operations.downloading',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${modelOperation.downloadSource}`
|
||||
)
|
||||
}
|
||||
)
|
||||
: t('documentParsing.ocr.installed')}
|
||||
</span>
|
||||
@@ -879,41 +924,61 @@ export function DocumentParsingSettingsSection({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(model.id)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: modelDisplayName }
|
||||
{modelSourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(
|
||||
model.id,
|
||||
snapshot.ocrModels
|
||||
.selectedDownloadSource
|
||||
)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: modelDisplayName }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
)}
|
||||
{!modelSourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={
|
||||
onOpenModelDownloadSourceSettings
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'documentParsing.ocr.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.importModelZip',
|
||||
@@ -951,6 +1016,16 @@ export function DocumentParsingSettingsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installedModel && !modelSourceAvailable && (
|
||||
<p className="settings-warning">
|
||||
{t('documentParsing.ocr.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{modelOperation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -169,6 +169,7 @@ const onAnalysisEvent = vi.fn<
|
||||
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -180,6 +181,7 @@ beforeEach(() => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -680,6 +682,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -711,6 +714,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'narrative'
|
||||
@@ -825,6 +829,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
MagicNoteCommentMode
|
||||
MagicNoteCommentMode,
|
||||
ModelDownloadSource
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
PageTabs,
|
||||
SegmentedControl
|
||||
} from './WorkspacePrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
@@ -13,14 +18,21 @@ import {
|
||||
|
||||
type PlatformFeaturesSettingsSectionProps = {
|
||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
}
|
||||
|
||||
type PlatformFeaturesTab = 'general' | 'magic-notes'
|
||||
|
||||
export function PlatformFeaturesSettingsSection({
|
||||
onMagicNotesEnabledChange
|
||||
onMagicNotesEnabledChange,
|
||||
onNotify
|
||||
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PlatformFeaturesTab>('general')
|
||||
const [settings, setSettings] = useState<ApplicationSettings>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sourceError, setSourceError] = useState<string>()
|
||||
const [error, setError] = useState<string | undefined>(() =>
|
||||
window.goodbuddy.updates
|
||||
? undefined
|
||||
@@ -52,6 +64,45 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const changeModelDownloadSource = async (
|
||||
modelDownloadSource: ModelDownloadSource
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (
|
||||
!updates ||
|
||||
!settings ||
|
||||
settings.modelDownloadSource === modelDownloadSource
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSourceError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
modelDownloadSource
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t(
|
||||
'platformFeatures.modelDownloadSource.notification',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${nextSettings.modelDownloadSource}`
|
||||
)
|
||||
}
|
||||
),
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
} catch {
|
||||
setSourceError(
|
||||
t('platformFeatures.errors.saveModelDownloadSourceFailed')
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
@@ -120,84 +171,220 @@ export function PlatformFeaturesSettingsSection({
|
||||
headingId="platform-features-heading"
|
||||
/>
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
<section
|
||||
aria-label={t('platformFeatures.label')}
|
||||
className="settings-section"
|
||||
>
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>{t('platformFeatures.magicNotes.description')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings?.magicNotesEnabled ?? false}
|
||||
disabled={!settings || saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
<div className="platform-features-tabs">
|
||||
<PageTabs
|
||||
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
|
||||
idPrefix="platform-features"
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{
|
||||
id: 'general',
|
||||
label: t('platformFeatures.tabs.general')
|
||||
},
|
||||
{
|
||||
id: 'magic-notes',
|
||||
label: t('platformFeatures.tabs.magicNotes')
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>{t('platformFeatures.magicNotes.commentMode')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentModeAria')}
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t('platformFeatures.magicNotes.modes.immediate')
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t('platformFeatures.magicNotes.modes.afterSaveAuto')
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-general"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'general'}
|
||||
id="platform-features-panel-general"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>
|
||||
{t('platformFeatures.modelDownloadSource.cardTitle')}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'platformFeatures.modelDownloadSource.cardDescription'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="model-download-source">
|
||||
<legend>
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</legend>
|
||||
<p>
|
||||
{t('platformFeatures.modelDownloadSource.description')}
|
||||
</p>
|
||||
{(
|
||||
['modelscope', 'hugging-face'] as const
|
||||
).map((source) => (
|
||||
<label
|
||||
className={
|
||||
source === settings.modelDownloadSource
|
||||
? 'model-download-source__option model-download-source__option--selected'
|
||||
: 'model-download-source__option'
|
||||
}
|
||||
key={source}
|
||||
>
|
||||
<input
|
||||
checked={source === settings.modelDownloadSource}
|
||||
disabled={saving}
|
||||
name="model-download-source"
|
||||
onChange={() =>
|
||||
void changeModelDownloadSource(source)
|
||||
}
|
||||
type="radio"
|
||||
value={source}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t(`modelDownloadSources.${source}`)}</strong>
|
||||
<small>
|
||||
{t(
|
||||
`platformFeatures.modelDownloadSource.options.${source}`
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{sourceError && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{sourceError}
|
||||
</p>
|
||||
)}
|
||||
<p className="model-download-source__current">
|
||||
{t('platformFeatures.modelDownloadSource.current', {
|
||||
source: t(
|
||||
`modelDownloadSources.${settings.modelDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('platformFeatures.modelDownloadSource.activeDownloadNote')}
|
||||
</p>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-magic-notes"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'magic-notes'}
|
||||
id="platform-features-panel-magic-notes"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.description')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings.magicNotesEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
}
|
||||
]}
|
||||
value={settings?.magicNoteCommentMode ?? 'immediate'}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>{t('platformFeatures.magicNotes.commentFormat')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria')}
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t('platformFeatures.magicNotes.formats.combined')
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t('platformFeatures.magicNotes.formats.narrative')
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t('platformFeatures.magicNotes.formats.structured')
|
||||
}
|
||||
]}
|
||||
value={settings?.magicNoteCommentFormat ?? 'combined'}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentMode')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentModeAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.immediate'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveAuto'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentMode}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentFormat')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentFormatAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.combined'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.narrative'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.structured'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentFormat}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -396,6 +396,7 @@ const diagnoseEmbedding = vi.fn(
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -423,14 +424,25 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://example.com/sensevoice',
|
||||
license: {
|
||||
name: 'Model License',
|
||||
notice: 'Review the model license before use.',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: []
|
||||
files: [],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'paraformer-bilingual-zh-en-int8',
|
||||
@@ -442,20 +454,32 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://example.com/paraformer',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Review the model license before use.',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: []
|
||||
files: [],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const createSpeechModelSnapshot = (
|
||||
selectedModelId: string | null = 'sensevoice-small-int8'
|
||||
): SpeechModelSnapshot => ({
|
||||
rootDirectory: 'C:\\Users\\test\\models\\speech',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: speechCatalog,
|
||||
installed: speechCatalog.map((model) => ({
|
||||
id: model.id,
|
||||
@@ -562,6 +586,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
applicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -1033,6 +1058,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
@@ -1066,6 +1094,111 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the global model download source from General settings', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onNotify={onNotify}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
expect(
|
||||
await screen.findByRole('tab', { name: '通用设置' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
const modelScope = screen.getByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '显示魔法笔记入口' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(huggingFace)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
})
|
||||
)
|
||||
expect(huggingFace).toBeChecked()
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '模型下载源已切换为 Hugging Face。',
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '魔法笔记' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '显示魔法笔记入口' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not guess a model download source when settings fail to load', async () => {
|
||||
getApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('read failed')
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
|
||||
expect(
|
||||
await screen.findByText('读取平台功能设置失败')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: /ModelScope/u })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('当前选择:ModelScope')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the confirmed model download source when saving fails', async () => {
|
||||
updateApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('save failed')
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
const modelScope = await screen.findByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
fireEvent.click(huggingFace)
|
||||
|
||||
expect(
|
||||
await screen.findByText('保存模型下载源失败,请重试')
|
||||
).toBeInTheDocument()
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
@@ -1095,6 +1228,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
|
||||
@@ -1610,6 +1610,7 @@ export function SettingsPanel({
|
||||
{activeTab === 'platform-features' && (
|
||||
<PlatformFeaturesSettingsSection
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'runtime' && (
|
||||
@@ -2924,6 +2925,9 @@ export function SettingsPanel({
|
||||
{modelType === 'speech' && (
|
||||
<SpeechModelSettingsSection
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
setActiveTab('platform-features')
|
||||
}
|
||||
onSelectedModelIdChange={(modelId, changed) => {
|
||||
setSpeechModelDraftId(modelId)
|
||||
setSpeechModelSelectionDirty(changed)
|
||||
@@ -3119,7 +3123,12 @@ export function SettingsPanel({
|
||||
)}
|
||||
|
||||
{activeTab === 'document-parsing' && (
|
||||
<DocumentParsingSettingsSection onNotify={onNotify} />
|
||||
<DocumentParsingSettingsSection
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
setActiveTab('platform-features')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
|
||||
@@ -21,7 +21,6 @@ const entry = {
|
||||
quality: 'high' as const,
|
||||
speed: 'fast' as const,
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://huggingface.co/example/model',
|
||||
license: {
|
||||
name: '模型仓库自定义许可',
|
||||
notice: '使用前请阅读许可。',
|
||||
@@ -32,26 +31,33 @@ const entry = {
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/model/resolve/revision/model.int8.onnx',
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/model/resolve/revision/tokens.txt',
|
||||
size: 100,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
size: 100,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const snapshot: SpeechModelSnapshot = {
|
||||
rootDirectory: 'C:\\Users\\test\\models\\speech',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [entry],
|
||||
installed: [],
|
||||
operations: [],
|
||||
@@ -105,7 +111,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(screen.queryByText('Model details')).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Open the SenseVoiceSmall INT8 model repository'
|
||||
name: 'Open the ModelScope repository for SenseVoiceSmall INT8'
|
||||
})
|
||||
)
|
||||
expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
@@ -159,7 +165,10 @@ describe('SpeechModelSettingsSection', () => {
|
||||
}))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
'sensevoice-small-int8',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
@@ -176,7 +185,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
family: 'whisper' as const,
|
||||
files: [
|
||||
{
|
||||
...entry.files[0],
|
||||
...entry.files[0]!,
|
||||
name: 'tiny-encoder.int8.onnx',
|
||||
role: 'encoder' as const
|
||||
}
|
||||
@@ -212,10 +221,81 @@ describe('SpeechModelSettingsSection', () => {
|
||||
}))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
'whisper-tiny-multilingual',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an unavailable source explicit and offers General settings', async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
const onOpenModelDownloadSourceSettings = vi.fn()
|
||||
const unavailableSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
catalog: [
|
||||
{
|
||||
...entry,
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: false,
|
||||
unavailableReason:
|
||||
'当前下载源暂不提供此模型的完整已验证文件'
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
speechModels: {
|
||||
getSnapshot: vi.fn(async () => unavailableSnapshot),
|
||||
install: vi.fn(),
|
||||
cancel: vi.fn(async () => true),
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
importArchive: vi.fn(),
|
||||
exportArchive: vi.fn(),
|
||||
openRepository: vi.fn(),
|
||||
openModelsDirectory: vi.fn()
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(
|
||||
<SpeechModelSettingsSection
|
||||
onOpenModelDownloadSourceSettings={
|
||||
onOpenModelDownloadSourceSettings
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText('当前来源不可下载')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: '下载 SenseVoiceSmall INT8'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '打开 SenseVoiceSmall INT8 的 ModelScope 模型仓库'
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '前往通用设置' })
|
||||
)
|
||||
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('imports and exports verified speech model ZIP archives', async () => {
|
||||
const installedSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
@@ -306,7 +386,8 @@ describe('SpeechModelSettingsSection', () => {
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.int8.onnx',
|
||||
completedBytes: 550,
|
||||
totalBytes: 1_100
|
||||
totalBytes: 1_100,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -332,6 +413,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(await screen.findByRole('progressbar', {
|
||||
name: 'SenseVoiceSmall INT8下载进度'
|
||||
})).toHaveValue(50)
|
||||
expect(screen.getByText('正在从 ModelScope 下载')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '取消 SenseVoiceSmall INT8 操作'
|
||||
}))
|
||||
@@ -350,7 +432,8 @@ describe('SpeechModelSettingsSection', () => {
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.int8.onnx',
|
||||
completedBytes: 550,
|
||||
totalBytes: 1_100
|
||||
totalBytes: 1_100,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TFunction } from 'i18next'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
SpeechModelCatalogEntry,
|
||||
SpeechModelCatalogViewEntry,
|
||||
SpeechModelOperation,
|
||||
SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
@@ -27,6 +27,7 @@ type SpeechModelSettingsSectionProps = {
|
||||
changed: boolean
|
||||
) => void
|
||||
onSelectionInvalidated?: (modelId: string | null) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -36,14 +37,8 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: SpeechModelCatalogEntry): number | undefined {
|
||||
const downloads = entry.files.map((file) => file.download)
|
||||
return downloads.every(Boolean)
|
||||
? downloads.reduce(
|
||||
(total, download) => total + (download?.size ?? 0),
|
||||
0
|
||||
)
|
||||
: undefined
|
||||
function catalogSize(entry: SpeechModelCatalogViewEntry): number {
|
||||
return entry.files.reduce((total, file) => total + file.size, 0)
|
||||
}
|
||||
|
||||
function progressPercent(operation: SpeechModelOperation): number | undefined {
|
||||
@@ -65,11 +60,19 @@ function operationLabel(
|
||||
if (operation.phase === 'preparing') {
|
||||
return operation.kind === 'import'
|
||||
? t('speech.operations.preparingImport')
|
||||
: t('speech.operations.preparingDownload')
|
||||
: t('speech.operations.preparingDownloadFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
return operation.kind === 'import'
|
||||
? t('speech.operations.importing')
|
||||
: t('speech.operations.downloading')
|
||||
: t('speech.operations.downloadingFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function SpeechModelSettingsSection({
|
||||
@@ -77,7 +80,8 @@ export function SpeechModelSettingsSection({
|
||||
persistedSelectedModelId,
|
||||
selectedModelId,
|
||||
onSelectedModelIdChange,
|
||||
onSelectionInvalidated
|
||||
onSelectionInvalidated,
|
||||
onOpenModelDownloadSourceSettings
|
||||
}: SpeechModelSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [snapshot, setSnapshot] = useState<SpeechModelSnapshot>()
|
||||
@@ -306,6 +310,11 @@ export function SpeechModelSettingsSection({
|
||||
? progressPercent(operation)
|
||||
: undefined
|
||||
const size = model ? catalogSize(model) : undefined
|
||||
const downloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source === snapshot.selectedDownloadSource
|
||||
)
|
||||
const sourceAvailable = downloadAvailability?.available === true
|
||||
const selected = model?.id === effectiveSelectedModelId
|
||||
const inUse = model?.id === effectivePersistedModelId
|
||||
const pendingSelection =
|
||||
@@ -322,7 +331,9 @@ export function SpeechModelSettingsSection({
|
||||
? t('speech.status.installed')
|
||||
: model?.manualOnly
|
||||
? t('speech.status.manualImport')
|
||||
: t('speech.status.availableToDownload')
|
||||
: sourceAvailable
|
||||
? t('speech.status.availableToDownload')
|
||||
: t('speech.status.sourceUnavailable')
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -354,6 +365,13 @@ export function SpeechModelSettingsSection({
|
||||
<code>{snapshot.rootDirectory}</code>
|
||||
{t('speech.storageSuffix')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('speech.downloadSource', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
{error && <p className="settings-warning" role="alert">{error}</p>}
|
||||
|
||||
<label className="field document-ocr-model-selector">
|
||||
@@ -383,7 +401,14 @@ export function SpeechModelSettingsSection({
|
||||
{optionName} ·{' '}
|
||||
{installedById.has(entry.id)
|
||||
? t('speech.status.installed')
|
||||
: t('speech.status.availableToDownload')}
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('speech.status.availableToDownload')
|
||||
: t('speech.status.sourceUnavailable')}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -411,9 +436,15 @@ export function SpeechModelSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
className="icon-button speech-model-card__repository"
|
||||
disabled={!sourceAvailable}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels?.openRepository(
|
||||
model.id
|
||||
@@ -421,7 +452,12 @@ export function SpeechModelSettingsSection({
|
||||
}
|
||||
title={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
@@ -430,6 +466,11 @@ export function SpeechModelSettingsSection({
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{t('speech.family.' + model.family)}
|
||||
</span>
|
||||
@@ -540,7 +581,7 @@ export function SpeechModelSettingsSection({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!model.manualOnly && (
|
||||
{!model.manualOnly && sourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.downloadModel',
|
||||
@@ -553,7 +594,8 @@ export function SpeechModelSettingsSection({
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.install(
|
||||
model.id
|
||||
model.id,
|
||||
snapshot.selectedDownloadSource
|
||||
),
|
||||
t('speech.notifications.installed', {
|
||||
name: displayName
|
||||
@@ -567,6 +609,19 @@ export function SpeechModelSettingsSection({
|
||||
{t('speech.actions.download')}
|
||||
</button>
|
||||
)}
|
||||
{!model.manualOnly &&
|
||||
!sourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onOpenModelDownloadSourceSettings}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'speech.actions.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.importModelZip',
|
||||
@@ -596,6 +651,16 @@ export function SpeechModelSettingsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installed && !sourceAvailable && !model.manualOnly && (
|
||||
<p className="settings-warning">
|
||||
{t('speech.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{operation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('UpdateSettingsSection', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { settings as chineseSettings } from '../zh-CN/settings'
|
||||
|
||||
export const settings = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
center: {
|
||||
eyebrow: 'Settings',
|
||||
title: 'Settings',
|
||||
@@ -472,7 +476,7 @@ export const settings = {
|
||||
ocrReady:
|
||||
'The model is installed, SHA-256 verified, and available offline',
|
||||
ocrUnavailable:
|
||||
'The model is not installed or failed verification. Download it from ModelScope.',
|
||||
'The model is not installed or failed verification. Get it from the current model download source.',
|
||||
partialNotice:
|
||||
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scenario modes can use local OCR for scanned PDFs.'
|
||||
},
|
||||
@@ -524,11 +528,13 @@ export const settings = {
|
||||
'This model selection is not active yet. Save settings to switch.',
|
||||
installedOption: 'Installed',
|
||||
downloadableOption: 'Available to download',
|
||||
sourceUnavailableOption: 'Unavailable from current source',
|
||||
unavailableOption: 'Unavailable in this version',
|
||||
openModelsDirectory: 'Open model folder',
|
||||
storagePrefix: 'Models are installed on demand in',
|
||||
storageSuffix:
|
||||
' and can be exported as ZIP archives for offline devices.',
|
||||
downloadSource: 'Current model download source: {{source}}',
|
||||
recommended: 'Recommended',
|
||||
quality: {
|
||||
label: 'Quality: {{value}}',
|
||||
@@ -576,18 +582,21 @@ export const settings = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
cancel: 'Cancel',
|
||||
openRepository: 'Open ModelScope',
|
||||
openRepository: 'Open {{source}}',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
catalogUnavailable:
|
||||
'No OCR model catalog is available in this version.',
|
||||
selectedModelUnavailable:
|
||||
'The saved OCR model is unavailable in this version. Select and install another model above.',
|
||||
installBeforeSelecting:
|
||||
'Download this model first. It will become the current model after installation.',
|
||||
'Download or import this model first. It will become the current model after installation.',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
privacyNotice:
|
||||
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
|
||||
operations: {
|
||||
preparing: 'Preparing model files',
|
||||
downloading: 'Downloading from ModelScope',
|
||||
downloading: 'Downloading from {{source}}',
|
||||
importing: 'Importing model ZIP',
|
||||
installing: 'Verifying and installing'
|
||||
},
|
||||
@@ -598,7 +607,7 @@ export const settings = {
|
||||
deleteModel: 'Delete {{name}}',
|
||||
cancelOperation: 'Cancel {{name}} operation',
|
||||
downloadProgress: '{{name}} download progress',
|
||||
openRepository: 'Open the ModelScope page for {{name}}'
|
||||
openRepository: 'Open the {{source}} page for {{name}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
} from '../zh-CN/settingsSections'
|
||||
|
||||
export const settingsSections = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
speech: {
|
||||
title: 'Speech models',
|
||||
description:
|
||||
@@ -12,6 +16,7 @@ export const settingsSections = {
|
||||
storagePrefix: 'Models are stored in',
|
||||
storageSuffix:
|
||||
'. Automatic downloads pin the source revision and verify SHA-256 hashes. Export a ZIP on an online device and import it directly on an offline device.',
|
||||
downloadSource: 'Current model download source: {{source}}',
|
||||
modelSelector: 'Current speech model',
|
||||
modelSelectorDescription:
|
||||
'Choose an installed model, then select Save settings to switch speech recognition models.',
|
||||
@@ -45,9 +50,9 @@ export const settingsSections = {
|
||||
operations: {
|
||||
installing: 'Verifying and installing',
|
||||
preparingImport: 'Preparing import',
|
||||
preparingDownload: 'Preparing download',
|
||||
preparingDownloadFrom: 'Preparing to download from {{source}}',
|
||||
importing: 'Importing',
|
||||
downloading: 'Downloading',
|
||||
downloadingFrom: 'Downloading from {{source}}',
|
||||
processingFile: 'Processing {{file}}'
|
||||
},
|
||||
status: {
|
||||
@@ -56,6 +61,7 @@ export const settingsSections = {
|
||||
installed: 'Installed',
|
||||
manualImport: 'Manual import',
|
||||
availableToDownload: 'Available to download',
|
||||
sourceUnavailable: 'Unavailable from current source',
|
||||
unknownSize: 'Unknown size'
|
||||
},
|
||||
tags: {
|
||||
@@ -66,6 +72,7 @@ export const settingsSections = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
download: 'Download',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP'
|
||||
},
|
||||
@@ -76,7 +83,7 @@ export const settingsSections = {
|
||||
importModelZip: 'Import {{name}} from a ZIP archive',
|
||||
exportModelZip: 'Export {{name}} as a ZIP archive',
|
||||
downloadProgress: '{{name}} download progress',
|
||||
openRepository: 'Open the {{name}} model repository'
|
||||
openRepository: 'Open the {{source}} repository for {{name}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
@@ -84,6 +91,8 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} exported as ZIP',
|
||||
removed: 'Speech model deleted'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
languages: {
|
||||
中文: 'Chinese',
|
||||
粤语: 'Cantonese',
|
||||
@@ -211,6 +220,7 @@ export const settingsSections = {
|
||||
'No roles yet. Create a role to configure its system prompt.'
|
||||
},
|
||||
platformFeatures: {
|
||||
loading: 'Loading platform feature settings…',
|
||||
errors: {
|
||||
serviceUnavailable:
|
||||
'Application settings are not available in this version',
|
||||
@@ -219,9 +229,34 @@ export const settingsSections = {
|
||||
saveCommentModeFailed:
|
||||
'Could not save the AI comment mode. Try again.',
|
||||
saveCommentFormatFailed:
|
||||
'Could not save the AI comment format. Try again.'
|
||||
'Could not save the AI comment format. Try again.',
|
||||
saveModelDownloadSourceFailed:
|
||||
'Could not save the model download source. Try again.'
|
||||
},
|
||||
label: 'Platform feature options',
|
||||
tabs: {
|
||||
ariaLabel: 'Platform feature settings',
|
||||
general: 'General',
|
||||
magicNotes: 'Magic Notes'
|
||||
},
|
||||
modelDownloadSource: {
|
||||
cardTitle: 'Local models',
|
||||
cardDescription:
|
||||
'Manage how GoodBuddy-managed local models are downloaded',
|
||||
title: 'Model download source',
|
||||
description:
|
||||
'Choose the platform for future GoodBuddy-managed local model downloads. Installed models, ZIP imports, Ollama models, and app updates are not affected.',
|
||||
options: {
|
||||
modelscope:
|
||||
'Default. Use when your network prioritizes access to ModelScope.',
|
||||
'hugging-face':
|
||||
'Use when your network can access Hugging Face reliably.'
|
||||
},
|
||||
current: 'Current selection: {{source}}',
|
||||
activeDownloadNote:
|
||||
'Downloads already in progress keep the source they started with. New downloads use the current selection.',
|
||||
notification: 'Model download source changed to {{source}}.'
|
||||
},
|
||||
magicNotes: {
|
||||
title: 'Magic Notes',
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const settings = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
center: {
|
||||
eyebrow: '设置',
|
||||
title: '设置中心',
|
||||
@@ -431,7 +435,7 @@ export const settings = {
|
||||
localOcr: '本地 OCR',
|
||||
localOcrModel: '当前 OCR:{{name}}',
|
||||
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从当前模型下载源获取',
|
||||
partialNotice:
|
||||
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。'
|
||||
},
|
||||
@@ -477,10 +481,12 @@ export const settings = {
|
||||
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
|
||||
installedOption: '已安装',
|
||||
downloadableOption: '可下载',
|
||||
sourceUnavailableOption: '当前来源不可下载',
|
||||
unavailableOption: '当前版本不可用',
|
||||
openModelsDirectory: '打开模型目录',
|
||||
storagePrefix: '模型按需安装到',
|
||||
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
|
||||
downloadSource: '当前模型下载源:{{source}}',
|
||||
recommended: '推荐',
|
||||
quality: {
|
||||
label: '质量:{{value}}',
|
||||
@@ -528,16 +534,20 @@ export const settings = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
cancel: '取消',
|
||||
openRepository: '打开 ModelScope',
|
||||
openRepository: '打开 {{source}}',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
|
||||
selectedModelUnavailable:
|
||||
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
|
||||
installBeforeSelecting: '请先下载该模型;下载完成后会自动设为当前模型。',
|
||||
installBeforeSelecting:
|
||||
'请先下载或导入该模型;安装完成后会自动设为当前模型。',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
privacyNotice:
|
||||
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
|
||||
operations: {
|
||||
preparing: '正在准备模型文件',
|
||||
downloading: '正在从 ModelScope 下载',
|
||||
downloading: '正在从 {{source}} 下载',
|
||||
importing: '正在导入模型 ZIP',
|
||||
installing: '正在校验并安装'
|
||||
},
|
||||
@@ -548,7 +558,7 @@ export const settings = {
|
||||
deleteModel: '删除 {{name}}',
|
||||
cancelOperation: '取消 {{name}} 操作',
|
||||
downloadProgress: '{{name}} 下载进度',
|
||||
openRepository: '打开 {{name}} 的 ModelScope 页面'
|
||||
openRepository: '打开 {{name}} 的 {{source}} 页面'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const settingsSections = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
speech: {
|
||||
title: '语音模型',
|
||||
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
|
||||
@@ -6,6 +10,7 @@ export const settingsSections = {
|
||||
storagePrefix: '模型保存在',
|
||||
storageSuffix:
|
||||
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
|
||||
downloadSource: '当前模型下载源:{{source}}',
|
||||
modelSelector: '当前语音模型',
|
||||
modelSelectorDescription:
|
||||
'选择已安装模型后,点击“保存设置”切换语音识别模型。',
|
||||
@@ -37,9 +42,9 @@ export const settingsSections = {
|
||||
operations: {
|
||||
installing: '正在校验并安装',
|
||||
preparingImport: '正在准备导入',
|
||||
preparingDownload: '正在准备下载',
|
||||
preparingDownloadFrom: '正在准备从 {{source}} 下载',
|
||||
importing: '正在导入',
|
||||
downloading: '正在下载',
|
||||
downloadingFrom: '正在从 {{source}} 下载',
|
||||
processingFile: '正在处理 {{file}}'
|
||||
},
|
||||
status: {
|
||||
@@ -48,6 +53,7 @@ export const settingsSections = {
|
||||
installed: '已安装',
|
||||
manualImport: '手动导入',
|
||||
availableToDownload: '可下载',
|
||||
sourceUnavailable: '当前来源不可下载',
|
||||
unknownSize: '大小未知'
|
||||
},
|
||||
tags: {
|
||||
@@ -58,6 +64,7 @@ export const settingsSections = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
download: '下载',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP'
|
||||
},
|
||||
@@ -68,7 +75,7 @@ export const settingsSections = {
|
||||
importModelZip: '从 ZIP 导入 {{name}}',
|
||||
exportModelZip: '将 {{name}} 导出为 ZIP',
|
||||
downloadProgress: '{{name}}下载进度',
|
||||
openRepository: '打开 {{name}} 模型仓库'
|
||||
openRepository: '打开 {{name}} 的 {{source}} 模型仓库'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
@@ -76,6 +83,8 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} 已导出为 ZIP',
|
||||
removed: '语音模型已删除'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
languages: {
|
||||
中文: '中文',
|
||||
粤语: '粤语',
|
||||
@@ -197,14 +206,36 @@ export const settingsSections = {
|
||||
empty: '还没有角色。新建角色后,可以为它配置系统提示词。'
|
||||
},
|
||||
platformFeatures: {
|
||||
loading: '正在读取平台功能设置…',
|
||||
errors: {
|
||||
serviceUnavailable: '当前版本未提供应用设置服务',
|
||||
readFailed: '读取平台功能设置失败',
|
||||
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
|
||||
saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
|
||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试'
|
||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
|
||||
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
|
||||
},
|
||||
label: '平台功能选项',
|
||||
tabs: {
|
||||
ariaLabel: '平台功能设置',
|
||||
general: '通用设置',
|
||||
magicNotes: '魔法笔记'
|
||||
},
|
||||
modelDownloadSource: {
|
||||
cardTitle: '本地模型',
|
||||
cardDescription: '管理 GoodBuddy 托管本地模型的获取方式',
|
||||
title: '模型下载源',
|
||||
description:
|
||||
'选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama 模型和应用更新不受影响。',
|
||||
options: {
|
||||
modelscope: '默认,适合优先访问 ModelScope 的网络环境。',
|
||||
'hugging-face': '适合可以稳定访问 Hugging Face 的网络环境。'
|
||||
},
|
||||
current: '当前选择:{{source}}',
|
||||
activeDownloadNote:
|
||||
'正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。',
|
||||
notification: '模型下载源已切换为 {{source}}。'
|
||||
},
|
||||
magicNotes: {
|
||||
title: '魔法笔记',
|
||||
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
|
||||
|
||||
@@ -5189,6 +5189,83 @@ button > svg {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.platform-features-tabs {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.platform-features-tabs + .settings-section,
|
||||
.platform-features-tabs ~ .settings-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.platform-features-tabs ~ .settings-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-download-source {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-download-source legend {
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.model-download-source > p {
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.model-download-source__option {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-download-source__option--selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-selected);
|
||||
}
|
||||
|
||||
.model-download-source__option:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.model-download-source__option input {
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.model-download-source__option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-download-source__option small {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.model-download-source__current {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
|
||||
import { modelDownloadSourceSchema } from './model-download-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export {
|
||||
modelDownloadSourceSchema,
|
||||
type ModelDownloadSource
|
||||
} from './model-download-contracts'
|
||||
|
||||
export const magicNoteCommentModeSchema = z.enum([
|
||||
'immediate',
|
||||
'after-save-auto',
|
||||
@@ -22,6 +28,7 @@ const applicationPreferencesSchema = z
|
||||
.object({
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
updateSource: updateSourceSchema,
|
||||
modelDownloadSource: modelDownloadSourceSchema,
|
||||
magicNotesEnabled: z.boolean(),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema,
|
||||
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
||||
@@ -48,6 +55,7 @@ export type ApplicationSettingsUpdate = z.infer<
|
||||
typeof applicationSettingsUpdateSchema
|
||||
>
|
||||
|
||||
|
||||
export type VersionCheckFile = {
|
||||
name: string
|
||||
size: number
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
ApplicationSettingsUpdate,
|
||||
ModelDownloadSource,
|
||||
VersionCheckResult
|
||||
} from './application-settings-contracts'
|
||||
import type { ReleaseNotesSnapshot } from './release-notes-contracts'
|
||||
@@ -1373,7 +1374,10 @@ export type DesktopApi = {
|
||||
}
|
||||
speechModels?: {
|
||||
getSnapshot: () => Promise<SpeechModelSnapshot>
|
||||
install: (modelId: string) => Promise<SpeechModelSnapshot>
|
||||
install: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) => Promise<SpeechModelSnapshot>
|
||||
cancel: (modelId: string) => Promise<boolean>
|
||||
remove: (modelId: string) => Promise<SpeechModelSnapshot>
|
||||
select: (modelId: string | null) => Promise<SpeechModelSnapshot>
|
||||
@@ -1405,7 +1409,8 @@ export type DesktopApi = {
|
||||
purpose: DocumentParsingTestPurpose
|
||||
) => Promise<DocumentParsingDiagnostic | undefined>
|
||||
installOcrModel: (
|
||||
modelId: string
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) => Promise<DocumentParsingSnapshot>
|
||||
cancelOcrModelOperation: (modelId: string) => Promise<boolean>
|
||||
removeOcrModel: (
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
modelArtifactIdentitySchema,
|
||||
modelDownloadAvailabilitySchema,
|
||||
modelDownloadSourceSchema
|
||||
} from './model-download-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export const maximumDocumentExtractedCharacters = 5_000_000
|
||||
@@ -36,9 +42,8 @@ export const localOcrModelIdSchema = z
|
||||
.max(96)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)
|
||||
|
||||
const documentOcrSha256Schema = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
const documentOcrSha256Schema =
|
||||
modelArtifactIdentitySchema.shape.sha256
|
||||
|
||||
export const documentOcrModelFileRoleSchema = z.enum([
|
||||
'detection',
|
||||
@@ -46,13 +51,8 @@ export const documentOcrModelFileRoleSchema = z.enum([
|
||||
'dictionary'
|
||||
])
|
||||
|
||||
export const documentOcrModelDownloadSchema = z
|
||||
.object({
|
||||
url: z.url().max(2_048),
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: documentOcrSha256Schema
|
||||
})
|
||||
.strict()
|
||||
export const documentOcrModelArtifactSchema =
|
||||
modelArtifactIdentitySchema
|
||||
|
||||
export const documentOcrModelFileSchema = z
|
||||
.object({
|
||||
@@ -62,7 +62,9 @@ export const documentOcrModelFileSchema = z
|
||||
.max(255)
|
||||
.regex(/^[^/\\\0]+$/u),
|
||||
role: documentOcrModelFileRoleSchema,
|
||||
download: documentOcrModelDownloadSchema
|
||||
size: documentOcrModelArtifactSchema.shape.size,
|
||||
sha256: documentOcrModelArtifactSchema.shape.sha256,
|
||||
targets: documentOcrModelArtifactSchema.shape.targets
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -76,7 +78,12 @@ export const documentOcrModelCatalogEntrySchema = z
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
repositoryUrls: z
|
||||
.object({
|
||||
modelscope: z.url().max(2_048).optional(),
|
||||
'hugging-face': z.url().max(2_048).optional()
|
||||
})
|
||||
.strict(),
|
||||
license: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -108,8 +115,64 @@ export const documentOcrModelCatalogEntrySchema = z
|
||||
message: 'OCR 模型文件角色不能重复'
|
||||
})
|
||||
}
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
const targets = entry.files
|
||||
.map((file) => file.targets[source])
|
||||
.filter((target) => target !== undefined)
|
||||
if (
|
||||
targets.length > 0 &&
|
||||
(!entry.repositoryUrls[source] ||
|
||||
!targets.some(
|
||||
(target) =>
|
||||
target.repositoryUrl === entry.repositoryUrls[source]
|
||||
))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['repositoryUrls', source],
|
||||
message:
|
||||
'OCR 模型仓库地址必须对应到该下载源的一个文件目标'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
!MODEL_DOWNLOAD_SOURCES.some((source) =>
|
||||
entry.files.every((file) => file.targets[source])
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files'],
|
||||
message: 'OCR 模型必须至少由一个下载源提供完整文件'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const documentOcrModelCatalogViewEntrySchema =
|
||||
z
|
||||
.object({
|
||||
id: localOcrModelIdSchema,
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
|
||||
runtime: z.literal('onnxruntime-web-wasm'),
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
license: documentOcrModelCatalogEntrySchema.shape.license,
|
||||
files: z
|
||||
.array(
|
||||
documentOcrModelFileSchema.omit({
|
||||
targets: true
|
||||
})
|
||||
)
|
||||
.length(3),
|
||||
downloadAvailability: z
|
||||
.array(modelDownloadAvailabilitySchema)
|
||||
.length(MODEL_DOWNLOAD_SOURCES.length)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const installedDocumentOcrModelSchema = z
|
||||
.object({
|
||||
id: localOcrModelIdSchema,
|
||||
@@ -138,14 +201,28 @@ export const documentOcrModelOperationSchema = z
|
||||
phase: z.enum(['preparing', 'transferring', 'installing']),
|
||||
currentFile: z.string().min(1).max(255).nullable(),
|
||||
completedBytes: z.number().int().nonnegative().safe(),
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable()
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable(),
|
||||
downloadSource: modelDownloadSourceSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((operation, context) => {
|
||||
if (
|
||||
(operation.kind === 'download') !==
|
||||
(operation.downloadSource !== undefined)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['downloadSource'],
|
||||
message: 'OCR 下载操作必须且仅能包含下载源'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const documentOcrModelSnapshotSchema = z
|
||||
.object({
|
||||
rootDirectory: z.string().min(1).max(32_768),
|
||||
catalog: z.array(documentOcrModelCatalogEntrySchema).max(16),
|
||||
selectedDownloadSource: modelDownloadSourceSchema,
|
||||
catalog: z.array(documentOcrModelCatalogViewEntrySchema).max(16),
|
||||
installed: z.array(installedDocumentOcrModelSchema).max(16),
|
||||
operations: z.array(documentOcrModelOperationSchema).max(8)
|
||||
})
|
||||
@@ -157,6 +234,13 @@ export const documentOcrModelActionInputSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentOcrModelInstallInputSchema = z
|
||||
.object({
|
||||
modelId: localOcrModelIdSchema,
|
||||
expectedDownloadSource: modelDownloadSourceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentParsingSettingsSchema = z
|
||||
.object({
|
||||
chatWorkflow: chatDocumentWorkflowSchema,
|
||||
@@ -349,6 +433,9 @@ export type DocumentOcrModelFile = z.infer<
|
||||
export type DocumentOcrModelCatalogEntry = z.infer<
|
||||
typeof documentOcrModelCatalogEntrySchema
|
||||
>
|
||||
export type DocumentOcrModelCatalogViewEntry = z.infer<
|
||||
typeof documentOcrModelCatalogViewEntrySchema
|
||||
>
|
||||
export type InstalledDocumentOcrModel = z.infer<
|
||||
typeof installedDocumentOcrModelSchema
|
||||
>
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
application: {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
const immutableRevisionPattern = /^[a-f0-9]{40,64}$/u
|
||||
const hostNamePattern =
|
||||
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$/u
|
||||
|
||||
export const modelDownloadSourceSchema = z.enum([
|
||||
'modelscope',
|
||||
'hugging-face'
|
||||
])
|
||||
|
||||
export type ModelDownloadSource = z.infer<
|
||||
typeof modelDownloadSourceSchema
|
||||
>
|
||||
|
||||
export const MODEL_DOWNLOAD_SOURCES = modelDownloadSourceSchema.options
|
||||
export const MODEL_DOWNLOAD_REDIRECT_HOSTS = {
|
||||
modelscope: [],
|
||||
'hugging-face': [
|
||||
'cdn-lfs.hf.co',
|
||||
'cdn-lfs-us-1.hf.co',
|
||||
'cdn-lfs-eu-1.hf.co',
|
||||
'cas-bridge.xethub.hf.co'
|
||||
]
|
||||
} as const satisfies Record<
|
||||
ModelDownloadSource,
|
||||
readonly string[]
|
||||
>
|
||||
|
||||
function isSourceHost(
|
||||
source: ModelDownloadSource,
|
||||
hostname: string
|
||||
): boolean {
|
||||
return source === 'modelscope'
|
||||
? hostname === 'modelscope.cn' || hostname === 'www.modelscope.cn'
|
||||
: hostname === 'huggingface.co'
|
||||
}
|
||||
|
||||
export const modelArtifactTargetSchema = z
|
||||
.object({
|
||||
url: z.url().max(2_048),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
revision: z.string().regex(immutableRevisionPattern),
|
||||
redirectHosts: z
|
||||
.array(z.string().max(253).regex(hostNamePattern))
|
||||
.max(16)
|
||||
.default([])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((target, context) => {
|
||||
for (const [key, value] of [
|
||||
['url', target.url],
|
||||
['repositoryUrl', target.repositoryUrl]
|
||||
] as const) {
|
||||
const parsed = new URL(value)
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
(parsed.port !== '' && parsed.port !== '443') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.hash
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [key],
|
||||
message:
|
||||
'模型地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL'
|
||||
})
|
||||
}
|
||||
}
|
||||
const encodedRevision = encodeURIComponent(target.revision)
|
||||
const downloadUrl = new URL(target.url)
|
||||
const repositoryUrl = new URL(target.repositoryUrl)
|
||||
const repositoryPath = repositoryUrl.pathname.replace(/\/+$/u, '')
|
||||
if (
|
||||
downloadUrl.origin !== repositoryUrl.origin ||
|
||||
!downloadUrl.pathname.startsWith(
|
||||
`${repositoryPath}/resolve/${encodedRevision}/`
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['url'],
|
||||
message: '模型下载地址必须属于声明仓库并包含固定 Revision'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const modelArtifactTargetsSchema = z
|
||||
.object({
|
||||
modelscope: modelArtifactTargetSchema.optional(),
|
||||
'hugging-face': modelArtifactTargetSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((targets, context) => {
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
const target = targets[source]
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
const downloadHost = new URL(target.url).hostname
|
||||
const repositoryHost = new URL(target.repositoryUrl).hostname
|
||||
if (
|
||||
!isSourceHost(source, downloadHost) ||
|
||||
!isSourceHost(source, repositoryHost)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [source],
|
||||
message: '模型地址与声明的下载源不匹配'
|
||||
})
|
||||
}
|
||||
const allowedRedirectHosts: ReadonlySet<string> = new Set(
|
||||
MODEL_DOWNLOAD_REDIRECT_HOSTS[source]
|
||||
)
|
||||
target.redirectHosts.forEach((hostname, index) => {
|
||||
if (!allowedRedirectHosts.has(hostname)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [source, 'redirectHosts', index],
|
||||
message: '模型重定向主机不属于声明的下载源'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const modelDownloadAvailabilitySchema = z
|
||||
.object({
|
||||
source: modelDownloadSourceSchema,
|
||||
available: z.boolean(),
|
||||
totalBytes: z.number().int().positive().safe().optional(),
|
||||
unavailableReason: z.string().trim().min(1).max(500).optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((availability, context) => {
|
||||
if (availability.available && availability.totalBytes === undefined) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['totalBytes'],
|
||||
message: '可下载模型必须提供总大小'
|
||||
})
|
||||
}
|
||||
if (
|
||||
!availability.available &&
|
||||
availability.unavailableReason === undefined
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['unavailableReason'],
|
||||
message: '不可下载模型必须说明原因'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type ModelArtifactTarget = z.infer<
|
||||
typeof modelArtifactTargetSchema
|
||||
>
|
||||
export type ModelArtifactTargets = z.infer<
|
||||
typeof modelArtifactTargetsSchema
|
||||
>
|
||||
export type ModelDownloadAvailability = z.infer<
|
||||
typeof modelDownloadAvailabilitySchema
|
||||
>
|
||||
|
||||
export type ResolvableModelArtifactFile<Role extends string = string> = {
|
||||
name: string
|
||||
role: Role
|
||||
size: number
|
||||
sha256: string
|
||||
targets: ModelArtifactTargets
|
||||
}
|
||||
|
||||
export type ResolvedModelArtifactFile<Role extends string = string> = {
|
||||
name: string
|
||||
role: Role
|
||||
size: number
|
||||
sha256: string
|
||||
target: ModelArtifactTarget
|
||||
}
|
||||
|
||||
export type ResolvedModelPackage<Role extends string = string> = {
|
||||
source: ModelDownloadSource
|
||||
totalBytes: number
|
||||
files: ResolvedModelArtifactFile<Role>[]
|
||||
}
|
||||
|
||||
export function getModelDownloadAvailability(
|
||||
files: readonly ResolvableModelArtifactFile[],
|
||||
source: ModelDownloadSource
|
||||
): ModelDownloadAvailability {
|
||||
const available =
|
||||
files.length > 0 && files.every((file) => file.targets[source])
|
||||
if (!available) {
|
||||
return modelDownloadAvailabilitySchema.parse({
|
||||
source,
|
||||
available: false,
|
||||
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
|
||||
})
|
||||
}
|
||||
const totalBytes = files.reduce((total, file) => total + file.size, 0)
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
|
||||
throw new RangeError('模型总大小超出安全范围')
|
||||
}
|
||||
return modelDownloadAvailabilitySchema.parse({
|
||||
source,
|
||||
available: true,
|
||||
totalBytes
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveModelDownloadPackage<Role extends string>(
|
||||
files: readonly ResolvableModelArtifactFile<Role>[],
|
||||
source: ModelDownloadSource
|
||||
): ResolvedModelPackage<Role> {
|
||||
const availability = getModelDownloadAvailability(files, source)
|
||||
if (!availability.available || availability.totalBytes === undefined) {
|
||||
throw new Error(
|
||||
availability.unavailableReason ??
|
||||
'当前下载源暂不提供此模型的完整已验证文件'
|
||||
)
|
||||
}
|
||||
return {
|
||||
source,
|
||||
totalBytes: availability.totalBytes,
|
||||
files: files.map((file) => {
|
||||
const target = file.targets[source]
|
||||
if (!target) {
|
||||
throw new Error('模型下载元数据不完整')
|
||||
}
|
||||
return {
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
target
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const modelArtifactIdentitySchema = z
|
||||
.object({
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(sha256Pattern),
|
||||
targets: modelArtifactTargetsSchema
|
||||
})
|
||||
.strict()
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
speechModelSnapshotSchema
|
||||
} from './speech-model-contracts'
|
||||
|
||||
const revision = 'a'.repeat(40)
|
||||
const repositoryUrl =
|
||||
'https://huggingface.co/example/test-speech-model'
|
||||
const downloadableEntry = {
|
||||
id: 'test-speech-model',
|
||||
displayName: 'Test speech model',
|
||||
@@ -15,7 +18,9 @@ const downloadableEntry = {
|
||||
quality: 'balanced' as const,
|
||||
speed: 'balanced' as const,
|
||||
recommended: false,
|
||||
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
|
||||
repositoryUrls: {
|
||||
'hugging-face': repositoryUrl
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Test license notice.',
|
||||
@@ -26,36 +31,175 @@ const downloadableEntry = {
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/test/resolve/main/model.onnx',
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64)
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
url: `${repositoryUrl}/resolve/${revision}/model.onnx`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
redirectHosts: ['cdn-lfs.hf.co']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const catalogView = {
|
||||
id: downloadableEntry.id,
|
||||
displayName: downloadableEntry.displayName,
|
||||
description: downloadableEntry.description,
|
||||
languages: downloadableEntry.languages,
|
||||
family: downloadableEntry.family,
|
||||
quantization: downloadableEntry.quantization,
|
||||
quality: downloadableEntry.quality,
|
||||
speed: downloadableEntry.speed,
|
||||
recommended: downloadableEntry.recommended,
|
||||
license: downloadableEntry.license,
|
||||
manualOnly: false,
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: false,
|
||||
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('speech model contracts', () => {
|
||||
it('requires verified download metadata for every automatic file', () => {
|
||||
it('requires canonical identity and one complete verified source', () => {
|
||||
expect(speechModelCatalogEntrySchema.parse(downloadableEntry)).toEqual(
|
||||
downloadableEntry
|
||||
)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [{ name: 'model.onnx', role: 'model' }]
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model',
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...downloadableEntry.files[0]!.targets[
|
||||
'hugging-face'
|
||||
],
|
||||
url:
|
||||
'https://huggingface.co/example/another-model/' +
|
||||
`resolve/${revision}/model.onnx`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects mutable revisions and source-host mismatches', () => {
|
||||
const target =
|
||||
downloadableEntry.files[0]!.targets['hugging-face']
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...target,
|
||||
revision: 'main',
|
||||
url: `${repositoryUrl}/resolve/main/model.onnx`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
modelscope: {
|
||||
...target
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...target,
|
||||
redirectHosts: ['modelscope.cn']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps repository links consistent with source targets', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/example/another-model'
|
||||
}
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a reason for manual-only models and rejects duplicate files', () => {
|
||||
const manualFile = {
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {}
|
||||
}
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
manualOnly: true,
|
||||
files: [
|
||||
{ name: 'model.onnx', role: 'model' },
|
||||
{ name: 'model.onnx', role: 'tokens' }
|
||||
manualFile,
|
||||
{ ...manualFile, role: 'tokens' }
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
@@ -63,13 +207,55 @@ describe('speech model contracts', () => {
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
manualOnly: true,
|
||||
manualReason: '上游没有可核验的大小和摘要。',
|
||||
files: [{ name: 'model.onnx', role: 'model' }]
|
||||
manualReason: '上游没有可核验的下载目标。',
|
||||
files: [manualFile]
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects traversal, unknown fields, and malformed snapshots', () => {
|
||||
it('keeps renderer snapshots URL-free and validates frozen sources', () => {
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [catalogView],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1,
|
||||
totalBytes: 12,
|
||||
downloadSource: 'hugging-face'
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [catalogView],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1,
|
||||
totalBytes: 12
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects traversal and unknown local-directory fields', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
@@ -88,23 +274,5 @@ describe('speech model contracts', () => {
|
||||
copyEverything: true
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
catalog: [downloadableEntry],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: -1,
|
||||
totalBytes: 12
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
modelArtifactIdentitySchema,
|
||||
modelDownloadAvailabilitySchema,
|
||||
modelDownloadSourceSchema
|
||||
} from './model-download-contracts'
|
||||
|
||||
const safeIdentifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
|
||||
const safeFileNamePattern =
|
||||
/^(?!\.{1,2}$)(?!.*(?:^|[\\/])\.{1,2}(?:[\\/]|$))[^/\\\0]+$/u
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
export const SPEECH_TRANSCRIPTION_SAMPLE_RATE = 16_000
|
||||
export const SPEECH_TRANSCRIPTION_MAX_SECONDS = 20
|
||||
export const SPEECH_TRANSCRIPTION_MAX_SAMPLES =
|
||||
@@ -29,19 +34,15 @@ export const speechModelFileRoleSchema = z.enum([
|
||||
'configuration'
|
||||
])
|
||||
|
||||
export const speechModelDownloadSchema = z
|
||||
.object({
|
||||
url: z.url().max(2_048),
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(sha256Pattern)
|
||||
})
|
||||
.strict()
|
||||
export const speechModelArtifactSchema = modelArtifactIdentitySchema
|
||||
|
||||
export const speechModelFileSpecSchema = z
|
||||
.object({
|
||||
name: speechModelFileNameSchema,
|
||||
role: speechModelFileRoleSchema,
|
||||
download: speechModelDownloadSchema.optional()
|
||||
size: speechModelArtifactSchema.shape.size,
|
||||
sha256: speechModelArtifactSchema.shape.sha256,
|
||||
targets: speechModelArtifactSchema.shape.targets
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -64,7 +65,12 @@ export const speechModelCatalogEntrySchema = z
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
repositoryUrls: z
|
||||
.object({
|
||||
modelscope: z.url().max(2_048).optional(),
|
||||
'hugging-face': z.url().max(2_048).optional()
|
||||
})
|
||||
.strict(),
|
||||
license: speechModelLicenseSchema,
|
||||
manualOnly: z.boolean(),
|
||||
manualReason: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -86,24 +92,72 @@ export const speechModelCatalogEntrySchema = z
|
||||
message: '仅手动导入的模型必须说明原因'
|
||||
})
|
||||
}
|
||||
if (entry.manualOnly) {
|
||||
return
|
||||
}
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
const targets = entry.files
|
||||
.map((file) => file.targets[source])
|
||||
.filter((target) => target !== undefined)
|
||||
if (
|
||||
targets.length > 0 &&
|
||||
(!entry.repositoryUrls[source] ||
|
||||
targets.some(
|
||||
(target) =>
|
||||
target.repositoryUrl !== entry.repositoryUrls[source]
|
||||
))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['repositoryUrls', source],
|
||||
message: '模型仓库地址必须与该下载源的文件目标一致'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
!entry.manualOnly &&
|
||||
entry.files.some((file) => file.download === undefined)
|
||||
!MODEL_DOWNLOAD_SOURCES.some((source) =>
|
||||
entry.files.every((file) => file.targets[source])
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files'],
|
||||
message: '可下载模型的每个文件都必须提供已验证的大小和 SHA-256'
|
||||
message: '可下载模型必须至少由一个下载源提供完整文件'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const speechModelCatalogViewEntrySchema =
|
||||
z
|
||||
.object({
|
||||
id: speechModelIdSchema,
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
|
||||
family: z.enum(['sensevoice', 'whisper', 'paraformer']),
|
||||
quantization: z.enum(['int8', 'fp16', 'fp32']),
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
license: speechModelLicenseSchema,
|
||||
manualOnly: z.boolean(),
|
||||
manualReason: z.string().trim().min(1).max(500).optional(),
|
||||
files: z
|
||||
.array(speechModelFileSpecSchema.omit({ targets: true }))
|
||||
.min(1)
|
||||
.max(32),
|
||||
downloadAvailability: z
|
||||
.array(modelDownloadAvailabilitySchema)
|
||||
.length(MODEL_DOWNLOAD_SOURCES.length)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const speechModelInstalledFileSchema = z
|
||||
.object({
|
||||
name: speechModelFileNameSchema,
|
||||
role: speechModelFileRoleSchema,
|
||||
size: z.number().int().nonnegative().safe(),
|
||||
sha256: z.string().regex(sha256Pattern)
|
||||
sha256: speechModelArtifactSchema.shape.sha256
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -124,14 +178,28 @@ export const speechModelOperationSchema = z
|
||||
phase: z.enum(['preparing', 'transferring', 'installing']),
|
||||
currentFile: speechModelFileNameSchema.nullable(),
|
||||
completedBytes: z.number().int().nonnegative().safe(),
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable()
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable(),
|
||||
downloadSource: modelDownloadSourceSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((operation, context) => {
|
||||
if (
|
||||
(operation.kind === 'download') !==
|
||||
(operation.downloadSource !== undefined)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['downloadSource'],
|
||||
message: '下载操作必须且仅能包含下载源'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const speechModelSnapshotSchema = z
|
||||
.object({
|
||||
rootDirectory: z.string().min(1).max(32_768),
|
||||
catalog: z.array(speechModelCatalogEntrySchema).max(64),
|
||||
selectedDownloadSource: modelDownloadSourceSchema,
|
||||
catalog: z.array(speechModelCatalogViewEntrySchema).max(64),
|
||||
installed: z.array(installedSpeechModelSchema).max(64),
|
||||
operations: z.array(speechModelOperationSchema).max(16),
|
||||
selectedModelId: speechModelIdSchema.nullable()
|
||||
@@ -144,6 +212,13 @@ export const speechModelActionInputSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const speechModelInstallInputSchema = z
|
||||
.object({
|
||||
modelId: speechModelIdSchema,
|
||||
expectedDownloadSource: modelDownloadSourceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const speechModelSelectionInputSchema = z
|
||||
.object({
|
||||
modelId: speechModelIdSchema.nullable()
|
||||
@@ -185,6 +260,9 @@ export type SpeechModelFileSpec = z.infer<
|
||||
export type SpeechModelCatalogEntry = z.infer<
|
||||
typeof speechModelCatalogEntrySchema
|
||||
>
|
||||
export type SpeechModelCatalogViewEntry = z.infer<
|
||||
typeof speechModelCatalogViewEntrySchema
|
||||
>
|
||||
export type InstalledSpeechModel = z.infer<
|
||||
typeof installedSpeechModelSchema
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user