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:
@@ -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