feat: add explicit managed model download sources

Managed speech and OCR downloads previously used catalog-specific source URLs without a global selection. Platform Features now lets users choose ModelScope by default or Hugging Face, while Main validates and freezes that source for each download.

Verified coverage remains explicit: downloads never mix artifacts or silently switch sources, and installed models plus ZIP imports stay source-independent.

Release note: 可在“设置 → 平台功能 → 通用设置”中选择 ModelScope 或 Hugging Face 作为后续语音输入与 OCR 模型下载源;缺少完整已验证文件或下载失败时不会静默换源。
This commit is contained in:
mesalogo
2026-08-19 13:43:16 +08:00
parent c41d7e8b96
commit 43e1d162dc
44 changed files with 6083 additions and 839 deletions
@@ -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
+7 -2
View File
@@ -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: (
+101 -14
View File
@@ -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'
+249
View File
@@ -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()
+198 -30
View File
@@ -5,6 +5,9 @@ import {
speechModelSnapshotSchema
} from './speech-model-contracts'
const revision = 'a'.repeat(40)
const repositoryUrl =
'https://huggingface.co/example/test-speech-model'
const downloadableEntry = {
id: 'test-speech-model',
displayName: 'Test speech model',
@@ -15,7 +18,9 @@ const downloadableEntry = {
quality: 'balanced' as const,
speed: 'balanced' as const,
recommended: false,
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
repositoryUrls: {
'hugging-face': repositoryUrl
},
license: {
name: 'MIT License',
notice: 'Test license notice.',
@@ -26,36 +31,175 @@ const downloadableEntry = {
{
name: 'model.onnx',
role: 'model' as const,
download: {
url: 'https://huggingface.co/example/test/resolve/main/model.onnx',
size: 12,
sha256: 'a'.repeat(64)
size: 12,
sha256: 'a'.repeat(64),
targets: {
'hugging-face': {
url: `${repositoryUrl}/resolve/${revision}/model.onnx`,
repositoryUrl,
revision,
redirectHosts: ['cdn-lfs.hf.co']
}
}
}
]
}
const catalogView = {
id: downloadableEntry.id,
displayName: downloadableEntry.displayName,
description: downloadableEntry.description,
languages: downloadableEntry.languages,
family: downloadableEntry.family,
quantization: downloadableEntry.quantization,
quality: downloadableEntry.quality,
speed: downloadableEntry.speed,
recommended: downloadableEntry.recommended,
license: downloadableEntry.license,
manualOnly: false,
files: [
{
name: 'model.onnx',
role: 'model' as const,
size: 12,
sha256: 'a'.repeat(64)
}
],
downloadAvailability: [
{
source: 'modelscope' as const,
available: false,
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
},
{
source: 'hugging-face' as const,
available: true,
totalBytes: 12
}
]
}
describe('speech model contracts', () => {
it('requires verified download metadata for every automatic file', () => {
it('requires canonical identity and one complete verified source', () => {
expect(speechModelCatalogEntrySchema.parse(downloadableEntry)).toEqual(
downloadableEntry
)
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [{ name: 'model.onnx', role: 'model' }]
files: [
{
name: 'model.onnx',
role: 'model',
size: 12,
sha256: 'a'.repeat(64),
targets: {}
}
]
}).success
).toBe(false)
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [
{
...downloadableEntry.files[0],
targets: {
'hugging-face': {
...downloadableEntry.files[0]!.targets[
'hugging-face'
],
url:
'https://huggingface.co/example/another-model/' +
`resolve/${revision}/model.onnx`
}
}
}
]
}).success
).toBe(false)
})
it('rejects mutable revisions and source-host mismatches', () => {
const target =
downloadableEntry.files[0]!.targets['hugging-face']
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [
{
...downloadableEntry.files[0],
targets: {
'hugging-face': {
...target,
revision: 'main',
url: `${repositoryUrl}/resolve/main/model.onnx`
}
}
}
]
}).success
).toBe(false)
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [
{
...downloadableEntry.files[0],
targets: {
modelscope: {
...target
}
}
}
]
}).success
).toBe(false)
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
files: [
{
...downloadableEntry.files[0],
targets: {
'hugging-face': {
...target,
redirectHosts: ['modelscope.cn']
}
}
}
]
}).success
).toBe(false)
})
it('keeps repository links consistent with source targets', () => {
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
repositoryUrls: {
'hugging-face':
'https://huggingface.co/example/another-model'
}
}).success
).toBe(false)
})
it('requires a reason for manual-only models and rejects duplicate files', () => {
const manualFile = {
name: 'model.onnx',
role: 'model' as const,
size: 12,
sha256: 'a'.repeat(64),
targets: {}
}
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
manualOnly: true,
files: [
{ name: 'model.onnx', role: 'model' },
{ name: 'model.onnx', role: 'tokens' }
manualFile,
{ ...manualFile, role: 'tokens' }
]
}).success
).toBe(false)
@@ -63,13 +207,55 @@ describe('speech model contracts', () => {
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
manualOnly: true,
manualReason: '上游没有可核验的大小和摘要。',
files: [{ name: 'model.onnx', role: 'model' }]
manualReason: '上游没有可核验的下载目标。',
files: [manualFile]
}).success
).toBe(true)
})
it('rejects traversal, unknown fields, and malformed snapshots', () => {
it('keeps renderer snapshots URL-free and validates frozen sources', () => {
expect(
speechModelSnapshotSchema.safeParse({
rootDirectory: 'C:\\models\\speech',
selectedDownloadSource: 'hugging-face',
catalog: [catalogView],
installed: [],
operations: [
{
modelId: 'test-speech-model',
kind: 'download',
phase: 'transferring',
currentFile: 'model.onnx',
completedBytes: 1,
totalBytes: 12,
downloadSource: 'hugging-face'
}
],
selectedModelId: null
}).success
).toBe(true)
expect(
speechModelSnapshotSchema.safeParse({
rootDirectory: 'C:\\models\\speech',
selectedDownloadSource: 'hugging-face',
catalog: [catalogView],
installed: [],
operations: [
{
modelId: 'test-speech-model',
kind: 'download',
phase: 'transferring',
currentFile: 'model.onnx',
completedBytes: 1,
totalBytes: 12
}
],
selectedModelId: null
}).success
).toBe(false)
})
it('rejects traversal and unknown local-directory fields', () => {
expect(
speechModelCatalogEntrySchema.safeParse({
...downloadableEntry,
@@ -88,23 +274,5 @@ describe('speech model contracts', () => {
copyEverything: true
}).success
).toBe(false)
expect(
speechModelSnapshotSchema.safeParse({
rootDirectory: 'C:\\models\\speech',
catalog: [downloadableEntry],
installed: [],
operations: [
{
modelId: 'test-speech-model',
kind: 'download',
phase: 'transferring',
currentFile: 'model.onnx',
completedBytes: -1,
totalBytes: 12
}
],
selectedModelId: null
}).success
).toBe(false)
})
})
+94 -16
View File
@@ -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
>