From a0977645a3de6a2d42c386a57632da7990302b80 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Thu, 20 Aug 2026 23:57:38 +0800 Subject: [PATCH] fix: enable verified ModelScope speech downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting ModelScope could leave supported speech models unavailable because their fixed targets were incomplete and the official LFS redirect host was not allowed. Speech models now resolve complete per-source artifact fingerprints while preserving integrity checks, ZIP compatibility, and source isolation. Release note: 修复选择 ModelScope 后部分语音模型仍无法下载的问题;六个语音模型现均可从已验证的固定 ModelScope 来源下载。 --- .../local-text-embedding-model-design.md | 5 +- .../model-download-source-design.md | 73 +++++--- .../document-extraction-and-local-ocr.md | 6 +- src/main/document-ocr-model-manager.ts | 84 +++++---- src/main/model-download-targets.ts | 13 +- src/main/speech/speech-model-catalog.ts | 103 +++++++++- src/main/speech/speech-model-manager.test.ts | 177 +++++++++++++++++- src/main/speech/speech-model-manager.ts | 89 ++++----- .../src/DocumentParsingSettingsSection.tsx | 11 +- .../src/SpeechModelSettingsSection.test.tsx | 44 +++++ .../src/SpeechModelSettingsSection.tsx | 8 +- src/shared/model-download-contracts.ts | 138 +++++++++++++- src/shared/speech-model-contracts.test.ts | 45 +++++ 13 files changed, 663 insertions(+), 133 deletions(-) diff --git a/docs/architecture/local-text-embedding-model-design.md b/docs/architecture/local-text-embedding-model-design.md index 80ff421f..a5074ca1 100644 --- a/docs/architecture/local-text-embedding-model-design.md +++ b/docs/architecture/local-text-embedding-model-design.md @@ -859,7 +859,7 @@ Digest 变化。 ### 15.2 下载 - 目录只包含元数据,不包含模型权重。 -- 每个文件固定不可变 Revision、URL、字节数和 SHA-256。 +- 每个来源的文件固定不可变 Revision、URL、字节数和 SHA-256。 - 实际下载使用“平台功能 → 通用设置”中显式选择的 ModelScope 或 Hugging Face,完整 来源契约以[平台功能页签与模型下载源设计](./model-download-source-design.md)为准。 - 下载写入随机暂存目录和 `.partial` 文件。 @@ -868,7 +868,8 @@ Digest 变化。 - 全部校验成功后原子重命名为最终目录。 - 取消或失败清理本次暂存,不删除已安装版本。 - 所选来源不可用或缺少模型时显示失败,不尝试另一个来源。 -- 两个来源必须提供相同的预期文件名、大小和 SHA-256,不在一次模型包中混合来源。 +- 两个来源可以维护不同的固定大小和 SHA-256,但文件名和运行角色必须兼容;一次模型包 + 只能完整使用一个来源,不混合来源文件。 ### 15.3 ZIP 导入与导出 diff --git a/docs/architecture/model-download-source-design.md b/docs/architecture/model-download-source-design.md index 243e44a2..9b3897b9 100644 --- a/docs/architecture/model-download-source-design.md +++ b/docs/architecture/model-download-source-design.md @@ -41,8 +41,8 @@ - 云端模型调用。 - LLM Provider、Runtime、Skills、MCP 或扩展下载。 - GoodBuddy 应用自身的“检查更新源”。 -6. 每个模型文件继续固定 Revision、字节数和 SHA-256。两个来源只有提供完全相同的预期 - 文件时,才表示同一个模型工件。 +6. 每个来源继续固定 Revision、字节数和 SHA-256。两个来源可以为同一模型 ID 提供字节 + 不同的工件,但必须分别声明并校验完整来源包,不能只登记一个未校验链接。 7. 一个模型在所选来源缺少任何必需文件时,整个模型在该来源标记“暂不可下载”。不能把 同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。 8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的 @@ -72,7 +72,7 @@ 两者均已接入全局模型下载源,并具备: - Main 进程下载。 -- 来源无关的固定文件名、字节数和 SHA-256。 +- 固定文件名与角色,以及可按来源覆盖的字节数和 SHA-256。 - ModelScope 或 Hugging Face 的固定 Revision 与下载 Target。 - 随机暂存目录。 - 下载进度和取消。 @@ -146,8 +146,8 @@ | 模型下载源 | 用户选择的 ModelScope 或 Hugging Face | | 模型目录 | GoodBuddy 源码中经过验证的模型元数据集合 | | 模型包 | 一个模型运行所需的全部必需文件和安装清单 | -| 下载目标 | 某个来源中一个固定模型文件的 URL 和仓库信息 | -| 规范工件 | 由文件名、字节数和 SHA-256 定义的来源无关文件 | +| 下载目标 | 某个来源中一个固定模型文件的 URL、仓库、Revision 和可选来源专用指纹 | +| 规范工件 | 目录用于旧安装、本地目录和 ZIP 兼容的默认文件指纹 | | 来源覆盖 | 一个来源是否为模型包的全部必需文件提供下载目标 | | 冻结来源 | 下载操作启动时记录且在操作期间不变化的来源 | | 来源回退 | 一个来源失败后由应用自动请求另一个来源 | @@ -420,10 +420,11 @@ type StoredApplicationSettingsV7 = { ## 10. 模型目录契约 -### 10.1 来源无关的规范工件 +### 10.1 规范工件与来源专用指纹 -当前每个文件把 URL、大小和 SHA-256 放在同一个 `download` 对象中。新契约把工件身份与 -下载目标拆开: +每个文件保留一组默认大小和 SHA-256,用于兼容已有安装、本地目录和 ZIP。下载目标固定 +来源、仓库和 Revision;当某个来源发布的是同一模型 ID 的另一套字节时,Target 可以同时 +声明该来源自己的大小和 SHA-256: ```ts type ModelArtifactTarget = { @@ -431,6 +432,8 @@ type ModelArtifactTarget = { repositoryUrl: string revision: string redirectHosts?: string[] + size?: number + sha256?: string } type ModelArtifactFile = { @@ -450,14 +453,17 @@ type ManagedModelCatalogEntry = { } ``` -`size` 和 `sha256` 位于来源外层,表示两个来源必须提供同一规范工件。 +Target 省略 `size` 和 `sha256` 时继承文件默认指纹;覆盖时两者必须同时出现。下载解析器 +始终冻结一个来源,并使用该来源每个 Target 的有效指纹计算总大小和完成下载校验。 -如果两个站点提供的文件不是完全相同的字节: +目录因此可以表达: -- 不能把它们放入同一文件的两个 Target。 -- 不能为了“兼容”使用两个 SHA-256。 -- 应创建不同模型工件版本、Variant 或不同模型 ID。 -- 每个 Variant 分别经过运行时和质量验证。 +- 两个来源提供字节完全一致的镜像; +- 两个来源提供文件名和运行角色相同、但大小或 SHA-256 不同的版本; +- 只有一个来源提供完整模型包。 + +来源差异不能削弱完整性校验。只提供 URL 而没有默认或来源专用指纹、只覆盖大小或 +SHA-256 之一、使用可变 Revision,都会在目录解析时被拒绝。 Renderer 使用的目录快照不暴露下载 URL、重定向 Host 或文件存储地址,只返回来源可用性: @@ -493,7 +499,7 @@ type ManagedModelSnapshot = { 1. 每个必需文件都有该来源的 Target。 2. 每个 Target 使用固定 Revision,不使用 `main`、`latest` 或可变 Tag。 3. URL、仓库 URL 和重定向 Host 通过 Schema 和来源策略校验。 -4. 文件大小和 SHA-256 已独立验证。 +4. 每个文件都有默认指纹或该来源专用指纹,并在下载时按解析后的大小和 SHA-256 校验。 只要缺少一个文件,该模型在该来源整体不可下载。 @@ -523,6 +529,7 @@ dictionary.yml ← ModelScope - 模型 ID 唯一。 - 文件名和角色唯一。 - 每个来源 Target 的 URL 和 Revision 有效。 +- 来源专用大小和 SHA-256 必须成对出现。 - 至少一个来源完整覆盖可下载模型。 - 非手动模型不得在两个来源都缺失。 - `repositoryUrl` 与下载 Target 属于同一声明来源。 @@ -539,15 +546,18 @@ dictionary.yml ← ModelScope | PP-OCRv6 Tiny / Small / Medium | 可下载 | 可下载 | | SenseVoiceSmall INT8 | 可下载 | 可下载 | | Whisper Tiny | 可下载 | 可下载 | -| Paraformer 中英双语 / 中英日三语 | 暂不可下载 | 可下载 | -| Whisper Small / Medium | 暂不可下载 | 可下载 | +| Paraformer 中英双语 | 可下载 | 可下载 | +| Paraformer 中粤英三语 | 可下载 | 可下载 | +| Whisper Small / Medium | 可下载 | 可下载 | OCR 模型包的检测模型与识别模型来自同一下载源中的不同上游仓库。因此每个 Target 都必须 符合所声明来源的 Host 策略,而模型卡的主仓库入口必须对应到该来源至少一个实际文件 Target。语音模型当前每个模型包的 Target 与主仓库入口保持一致。 -未找到可独立验证且字节完全一致的 Target 时,目录保留部分覆盖,不通过替换模型、 -改写摘要或请求另一个来源来伪造双来源支持。 +当前六个语音模型均已登记 ModelScope 和 Hugging Face 固定 Target。Paraformer 中英双语 +在两个来源使用不同的已验证文件指纹;其余语音模型当前使用字节一致的镜像。来源专用 +版本不会触发自动回退,也不会允许一次安装混合两个来源。其 ModelScope 版本还必须通过 +当前捆绑 `sherpa-onnx` 的模型加载和中英样例转写探测,不能仅凭下载 URL 可访问就入库。 --- @@ -671,6 +681,7 @@ Hugging Face 暂不提供此模型的完整已验证文件。 - 切换来源后继续可用。 - 不重新验证网络地址。 - 继续按安装 Manifest 中的大小和 SHA-256 验证。 +- Manifest 必须完整匹配目录中的默认包或任一来源包,不能逐文件拼接不同来源版本。 - 不自动重新下载。 - 不改变当前模型选择。 @@ -693,6 +704,7 @@ type InstalledModelProvenance = { ZIP 是来源无关的离线迁移方式: - 导入始终按模型 ID、文件名、大小和 SHA-256 验证。 +- 整个 ZIP 必须匹配默认包或某一个完整来源包,不接受逐文件跨来源组合。 - 当前下载源不参与导入兼容判断。 - 从 ModelScope 下载的模型可以在选择 Hugging Face 的设备上导入。 - 导出可以保留原安装来源作为审计元数据,但不能限制另一设备导入。 @@ -780,6 +792,9 @@ ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源 - 不允许通配任意公网 Host。 - 最终内容仍按固定大小和 SHA-256 验证。 +当前 ModelScope 大文件只允许跳转到已核验的 +`cdn-lfs-cn-1.modelscope.cn`;小文件可以由 `modelscope.cn` 直接返回。 + 重定向到已声明 CDN 属于同一下载源内部传输,不构成下载源切换。UI 继续显示用户选择的 逻辑来源。 @@ -837,7 +852,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 ### 15.4 模型运行 -模型下载源只决定获取规范工件的位置,不进入: +模型下载源只决定本次获取哪一个已登记来源包,不进入: - 推理配置。 - 向量 Provider Fingerprint。 @@ -845,8 +860,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 - OCR 模型选择。 - 模型质量或速度标签。 -只要最终模型文件 SHA-256 相同,从 ModelScope 或 Hugging Face 安装后运行语义必须完全 -一致。 +安装后运行只依赖 Manifest 与磁盘文件匹配一个完整目录包,不依赖当前全局下载源。不同 +来源可以具有不同 SHA-256;目录中的模型族、语言、质量、速度和许可证说明必须准确覆盖 +所登记的来源版本。 --- @@ -899,7 +915,8 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 对语音、OCR 和向量目录分别验证: -- 两个来源解析到相同文件名、大小和 SHA-256。 +- 两个来源分别解析到自己的固定文件名、大小和 SHA-256。 +- Target 未覆盖指纹时继承默认值;只覆盖大小或 SHA-256 之一时拒绝。 - 缺少任一 Target 时模型在该来源不可下载。 - 不允许一次任务混合来源。 - 可变 Revision 被拒绝。 @@ -916,7 +933,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 - 设置中途变化不改变活动任务。 - 新任务使用最新已保存来源。 - Renderer 提交过期来源时零网络请求。 -- 两个来源的安装 Manifest 文件摘要相同。 +- 两个来源字节不同时,安装 Manifest 分别记录实际来源包摘要。 +- 旧默认指纹和任一完整来源指纹都能通过安装、ZIP 和运行时校验。 +- 逐文件命中不同来源的混合包被拒绝。 - 取消、关闭、错误和摘要不匹配不留下正式模型目录。 - 快照准备会清理名称同时匹配受管模型 ID 与安装 UUID 的陈旧 `.install-*` 目录,以及受管文件名对应的孤立 `.partial` 文件;已安装模型、 @@ -945,10 +964,10 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 ### 阶段 2:共享目录和来源解析(已完成) -- 定义来源无关的规范工件与分来源 Target。 +- 定义默认规范工件与可覆盖指纹的分来源 Target。 - 实现共享 `ModelDownloadTargetResolver`。 - 为语音和 OCR 目录补充 ModelScope 与 Hugging Face 固定 Target。 -- 静态验证两个来源的文件大小和 SHA-256。 +- 静态验证每个来源解析后的文件大小和 SHA-256。 - 更正来源感知文案和仓库入口。 ### 阶段 3:模型管理器(已完成) @@ -982,7 +1001,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源 ### 19.2 数据与下载 - 旧应用设置完整迁移,并得到 ModelScope 默认值。 -- 每个双来源模型的规范文件名、大小和 SHA-256 相同。 +- 每个双来源模型的文件名和角色一致;大小和 SHA-256 可以按来源固定。 - 单次下载任务只使用一个冻结来源。 - 活动下载显示实际冻结来源。 - 已安装 Manifest 和 ZIP 兼容性不依赖来源。 diff --git a/docs/prd/document-processing/document-extraction-and-local-ocr.md b/docs/prd/document-processing/document-extraction-and-local-ocr.md index 62ff5db3..73beaab0 100644 --- a/docs/prd/document-processing/document-extraction-and-local-ocr.md +++ b/docs/prd/document-processing/document-extraction-and-local-ocr.md @@ -188,7 +188,9 @@ type ParsedDocument = { Tiny、Small 和 Medium 模型使用 PaddlePaddle 发布的规范工件。GoodBuddy 为 ModelScope 和 Hugging Face 分别维护固定下载 Target,默认使用 ModelScope。Small 是默认推荐档位; Medium 面向更高识别质量,但具有更高内存占用和延迟。每个档位的检测模型、识别模型与 -字符字典配置分别使用固定提交,并在应用内记录共同的文件字节数和 SHA-256。 +字符字典配置分别使用固定提交,并在应用内记录各来源解析后的文件字节数和 SHA-256。 +当前 OCR 两个来源的对应文件字节一致;共享下载契约同时支持后续来源声明不同的固定 +工件指纹。 下载流程: @@ -221,7 +223,7 @@ ZIP 根目录包含模型文件和 `goodbuddy-model.json`。清单格式为 `goo - 文件名、角色、原始字节数和 SHA-256; - 导出时间。 -导出不能直接信任已有安装清单,必须重新读取并校验每个文件。导入不能只信任 ZIP 自声明内容,模型 ID、文件角色、字节数和哈希必须再次与当前应用内置目录完全匹配。导入通过后复用普通本地安装的受控临时目录和原子重命名路径。 +导出不能直接信任已有安装清单,必须重新读取并校验每个文件。导入不能只信任 ZIP 自声明内容,模型 ID、文件角色、字节数和哈希必须再次完整匹配当前应用内置目录的默认包或任一来源包;不接受逐文件跨来源组合。导入通过后复用普通本地安装的受控临时目录和原子重命名路径。 归档处理使用有界流式读写,不把大型模型或整个展开结果复制到内存。主进程限制压缩包大小、条目数、清单大小、单文件大小和总展开大小,并拒绝: diff --git a/src/main/document-ocr-model-manager.ts b/src/main/document-ocr-model-manager.ts index 1846ce99..1becb71c 100644 --- a/src/main/document-ocr-model-manager.ts +++ b/src/main/document-ocr-model-manager.ts @@ -32,7 +32,10 @@ import { } from '../shared/document-parsing-contracts' import { MODEL_DOWNLOAD_SOURCES, + getMaximumModelPackageBytes, getModelDownloadAvailability, + getModelPackageFingerprints, + modelPackageFingerprintMatches, resolveModelDownloadPackage, type ModelDownloadSource, type ResolvedModelArtifactFile @@ -105,6 +108,21 @@ function toCatalogView(entry: DocumentOcrModelCatalogEntry) { }) } +function documentOcrPackageMatches( + entry: DocumentOcrModelCatalogEntry, + files: readonly { + name: string + role: string + size: number + sha256: string + }[] +): boolean { + return getModelPackageFingerprints(entry.files).some( + (fingerprint) => + modelPackageFingerprintMatches(fingerprint, files) + ) +} + function safeChild(parent: string, name: string): string { return managedModelChild( parent, @@ -408,6 +426,9 @@ export class DocumentOcrModelManager { if (!installed) { throw new Error('只能导出已安装的 OCR 模型') } + if (!documentOcrPackageMatches(entry, installed.files)) { + throw new Error('OCR 模型文件与当前模型目录不匹配') + } const directory = this.modelDirectory(entry.id) const files = [] for (const expected of entry.files) { @@ -416,11 +437,7 @@ export class DocumentOcrModelManager { file.name === expected.name && file.role === expected.role ) - if ( - !recorded || - recorded.size !== expected.size || - recorded.sha256 !== expected.sha256 - ) { + if (!recorded) { throw new Error(`OCR 模型文件校验失败:${expected.name}`) } files.push({ @@ -447,10 +464,7 @@ export class DocumentOcrModelManager { archivePath: string ): Promise { const entry = this.requireCatalogEntry(modelId) - const expectedTotal = entry.files.reduce( - (total, file) => total + file.size, - 0 - ) + const expectedTotal = getMaximumModelPackageBytes(entry.files) const operation = this.beginOperation( entry.id, 'import', @@ -482,22 +496,13 @@ export class DocumentOcrModelManager { operation.progress.completedBytes = completedBytes } }) - for (const expected of entry.files) { - const archived = descriptor.files.find( - (file) => - file.name === expected.name && - file.role === expected.role - ) - if ( - !archived || - archived.size !== expected.size || - archived.sha256 !== expected.sha256 - ) { - throw new Error( - `OCR 模型 ZIP 与当前模型目录不匹配:${expected.name}` - ) - } + if (!documentOcrPackageMatches(entry, descriptor.files)) { + throw new Error('OCR 模型 ZIP 与当前模型目录不匹配') } + operation.progress.totalBytes = descriptor.files.reduce( + (total, file) => total + file.size, + 0 + ) operation.progress.phase = 'installing' operation.progress.currentFile = null const installed = installedDocumentOcrModelSchema.parse({ @@ -723,6 +728,7 @@ export class DocumentOcrModelManager { throw new Error('本地 OCR 模型目录包含不安全文件') } } + const actualFiles: InstalledDocumentOcrModel['files'] = [] for (const file of entry.files) { ensureModelOperationNotAborted(signal) const path = safeChild(sourceDirectory, file.name) @@ -730,13 +736,22 @@ export class DocumentOcrModelManager { if (!info.isFile() || info.isSymbolicLink()) { throw new Error(`OCR 模型文件必须是普通文件:${file.name}`) } - const actual = await hashModelFile(path, signal) if ( - actual.size !== file.size || - actual.sha256 !== file.sha256 + info.size <= 0 || + info.size > this.maxFileBytes ) { - throw new Error(`本地 OCR 模型文件校验失败:${file.name}`) + throw new RangeError(`OCR 模型文件大小无效:${file.name}`) } + actualFiles.push({ + name: file.name, + role: file.role, + ...(await hashModelFile(path, signal)) + }) + } + if (!documentOcrPackageMatches(entry, actualFiles)) { + throw new Error( + '本地 OCR 模型文件校验失败:与当前模型目录不匹配' + ) } } @@ -765,6 +780,9 @@ export class DocumentOcrModelManager { installedAt: new Date().toISOString(), files }) + if (!documentOcrPackageMatches(entry, manifest.files)) { + throw new Error('OCR 模型文件与当前模型目录不匹配') + } await writeFile( safeChild(stagingDirectory, MANIFEST_FILE_NAME), `${JSON.stringify(manifest, null, 2)}\n`, @@ -831,6 +849,9 @@ export class DocumentOcrModelManager { ): Promise { const directory = this.modelDirectory(entry.id) const manifest = await this.readInstalledManifest(entry) + if (!documentOcrPackageMatches(entry, manifest.files)) { + throw new Error('OCR 模型文件与当前模型目录不匹配') + } for (const file of entry.files) { const installed = manifest.files.find( (candidate) => @@ -842,8 +863,6 @@ export class DocumentOcrModelManager { ) if ( !installed || - actual.size !== file.size || - actual.sha256 !== file.sha256 || actual.size !== installed.size || actual.sha256 !== installed.sha256 ) { @@ -893,6 +912,9 @@ export class DocumentOcrModelManager { ): Promise { const directory = this.modelDirectory(entry.id) const manifest = await this.readInstalledManifest(entry) + if (!documentOcrPackageMatches(entry, manifest.files)) { + throw new Error('OCR 模型文件与当前模型目录不匹配') + } const loaded = new Map< DocumentOcrModelFile['role'], ArrayBuffer @@ -911,8 +933,6 @@ export class DocumentOcrModelManager { } if ( !installed || - actual.size !== file.size || - actual.sha256 !== file.sha256 || actual.size !== installed.size || actual.sha256 !== installed.sha256 ) { diff --git a/src/main/model-download-targets.ts b/src/main/model-download-targets.ts index be239245..82972e8c 100644 --- a/src/main/model-download-targets.ts +++ b/src/main/model-download-targets.ts @@ -1,29 +1,36 @@ import { MODEL_DOWNLOAD_REDIRECT_HOSTS, + type ModelArtifactFingerprint, type ModelArtifactTarget } from '../shared/model-download-contracts' export function modelScopeTarget( repository: string, revision: string, - file: string + file: string, + fingerprint?: ModelArtifactFingerprint ): ModelArtifactTarget { const repositoryUrl = `https://modelscope.cn/models/${repository}` return { + ...fingerprint, url: `${repositoryUrl}/resolve/${revision}/${file}`, repositoryUrl, revision, - redirectHosts: [] + redirectHosts: [ + ...MODEL_DOWNLOAD_REDIRECT_HOSTS.modelscope + ] } } export function huggingFaceTarget( repository: string, revision: string, - file: string + file: string, + fingerprint?: ModelArtifactFingerprint ): ModelArtifactTarget { const repositoryUrl = `https://huggingface.co/${repository}` return { + ...fingerprint, url: `${repositoryUrl}/resolve/${revision}/${file}`, repositoryUrl, revision, diff --git a/src/main/speech/speech-model-catalog.ts b/src/main/speech/speech-model-catalog.ts index 8340e685..105888f6 100644 --- a/src/main/speech/speech-model-catalog.ts +++ b/src/main/speech/speech-model-catalog.ts @@ -25,10 +25,31 @@ const whisperTinyHuggingFaceRepository = const whisperTinyHuggingFaceRevision = '65176e2deb88badc814a94058666cadccc29b61c' +const paraformerBilingualModelScopeRepository = + 'pengzhendong/sherpa-onnx-paraformer-zh' +const paraformerBilingualModelScopeRevision = + '66e549ea2ba4951d5d533b8ad3ffad09a29a4ba9' + +const paraformerTrilingualModelScopeRepository = + 'pengzhendong/sherpa-onnx-paraformer-trilingual-zh-cantonese-en' +const paraformerTrilingualModelScopeRevision = + '7cd698783acc17a4d0a7d3390e1588b534d949c7' + +const whisperSmallModelScopeRepository = + 'pengzhendong/sherpa-onnx-whisper-small' +const whisperSmallModelScopeRevision = + '893b50bd57565edb13eb1dfe84d33bfef77b0102' + +const whisperMediumModelScopeRepository = + 'pengzhendong/sherpa-onnx-whisper-medium' +const whisperMediumModelScopeRevision = + '4d75596a69806a269ff5911bb56fbae542f35463' + /** - * 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. + * Model weights are never bundled with GoodBuddy. Each file keeps a canonical + * fingerprint for local import compatibility, while a source target can pin a + * different verified size and SHA-256 when that source publishes another + * compatible artifact revision. */ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = speechModelCatalogEntrySchema.array().parse([ @@ -193,17 +214,17 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = speed: 'fast', recommended: true, repositoryUrls: { + modelscope: + `https://modelscope.cn/models/${paraformerBilingualModelScopeRepository}`, 'hugging-face': 'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en' }, license: { - name: 'MIT License', + name: 'Apache License 2.0 / MIT License', notice: - '转换仓库声明 MIT License;模型源自 FunASR Paraformer,使用前请同时阅读仓库说明。', + 'ModelScope 工件声明 Apache License 2.0;Hugging Face 转换仓库声明 MIT License。模型源自 FunASR Paraformer,使用前请阅读所选来源的仓库说明。', url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-bilingual-zh-en/blob/' + - '4b891f7b5c73d874e607797a4b0578fd4c35dd4b/README.md' + `https://modelscope.cn/models/${paraformerBilingualModelScopeRepository}` }, manualOnly: false, files: [ @@ -214,6 +235,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: '9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7', targets: { + modelscope: modelScopeTarget( + paraformerBilingualModelScopeRepository, + paraformerBilingualModelScopeRevision, + 'model.int8.onnx', + { + size: 227_330_205, + sha256: + '90bc03034ae1bef9575f8cc798cd1519c8be8aa9e8b458a033e32017ff4d584c' + } + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en', '4b891f7b5c73d874e607797a4b0578fd4c35dd4b', @@ -228,6 +259,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: '59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6', targets: { + modelscope: modelScopeTarget( + paraformerBilingualModelScopeRepository, + paraformerBilingualModelScopeRevision, + 'tokens.txt', + { + size: 75_354, + sha256: + '6c0e3b35cece259829e6cb5b8d90d13db88f61ea3a2953d11898e4b2bfd7a2e2' + } + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en', '4b891f7b5c73d874e607797a4b0578fd4c35dd4b', @@ -249,6 +290,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = speed: 'balanced', recommended: false, repositoryUrls: { + modelscope: + `https://modelscope.cn/models/${paraformerTrilingualModelScopeRepository}`, 'hugging-face': 'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en' }, @@ -270,6 +313,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: 'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144', targets: { + modelscope: modelScopeTarget( + paraformerTrilingualModelScopeRepository, + paraformerTrilingualModelScopeRevision, + 'model.int8.onnx' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en', '8d90151338178bb433354c9fb677bd3acb8023cd', @@ -284,6 +332,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: '8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492', targets: { + modelscope: modelScopeTarget( + paraformerTrilingualModelScopeRepository, + paraformerTrilingualModelScopeRevision, + 'tokens.txt' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en', '8d90151338178bb433354c9fb677bd3acb8023cd', @@ -305,6 +358,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = speed: 'balanced', recommended: false, repositoryUrls: { + modelscope: + `https://modelscope.cn/models/${whisperSmallModelScopeRepository}`, 'hugging-face': 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small' }, @@ -323,6 +378,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: '4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9', targets: { + modelscope: modelScopeTarget( + whisperSmallModelScopeRepository, + whisperSmallModelScopeRevision, + 'small-encoder.int8.onnx' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-whisper-small', '8f3c18b358db4d1f2fc1eae49d75cd20989e4309', @@ -337,6 +397,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: 'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee', targets: { + modelscope: modelScopeTarget( + whisperSmallModelScopeRepository, + whisperSmallModelScopeRevision, + 'small-decoder.int8.onnx' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-whisper-small', '8f3c18b358db4d1f2fc1eae49d75cd20989e4309', @@ -351,6 +416,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126', targets: { + modelscope: modelScopeTarget( + whisperSmallModelScopeRepository, + whisperSmallModelScopeRevision, + 'small-tokens.txt' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-whisper-small', '8f3c18b358db4d1f2fc1eae49d75cd20989e4309', @@ -372,6 +442,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = speed: 'slow', recommended: false, repositoryUrls: { + modelscope: + `https://modelscope.cn/models/${whisperMediumModelScopeRepository}`, 'hugging-face': 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium' }, @@ -390,6 +462,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: '1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e', targets: { + modelscope: modelScopeTarget( + whisperMediumModelScopeRepository, + whisperMediumModelScopeRevision, + 'medium-encoder.int8.onnx' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-whisper-medium', '8c31d28503847560985df21f90e14f0c736e075e', @@ -404,6 +481,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: '595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747', targets: { + modelscope: modelScopeTarget( + whisperMediumModelScopeRepository, + whisperMediumModelScopeRevision, + 'medium-decoder.int8.onnx' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-whisper-medium', '8c31d28503847560985df21f90e14f0c736e075e', @@ -418,6 +500,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = sha256: 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126', targets: { + modelscope: modelScopeTarget( + whisperMediumModelScopeRepository, + whisperMediumModelScopeRevision, + 'medium-tokens.txt' + ), 'hugging-face': huggingFaceTarget( 'csukuangfj/sherpa-onnx-whisper-medium', '8c31d28503847560985df21f90e14f0c736e075e', diff --git a/src/main/speech/speech-model-manager.test.ts b/src/main/speech/speech-model-manager.test.ts index 77f3e201..76247e23 100644 --- a/src/main/speech/speech-model-manager.test.ts +++ b/src/main/speech/speech-model-manager.test.ts @@ -204,6 +204,20 @@ describe('speech model catalog', () => { file.targets['hugging-face'] !== undefined ) ).toBe(true) + expect( + SPEECH_MODEL_CATALOG.filter((entry) => + entry.files.every( + (file) => file.targets.modelscope !== undefined + ) + ).map((entry) => entry.id) + ).toEqual([ + 'sensevoice-small-int8', + 'whisper-tiny-multilingual', + 'paraformer-bilingual-zh-en-int8', + 'paraformer-trilingual-zh-yue-en-int8', + 'whisper-small-multilingual-int8', + 'whisper-medium-multilingual-int8' + ]) expect(whisper?.files.map((file) => file.name)).toEqual([ 'tiny-encoder.int8.onnx', 'tiny-decoder.int8.onnx', @@ -212,7 +226,7 @@ describe('speech model catalog', () => { expect(paraformerBilingual).toMatchObject({ family: 'paraformer', languages: ['中文', '英语'], - license: { name: 'MIT License' }, + license: { name: 'Apache License 2.0 / MIT License' }, recommended: true }) expect(paraformerTrilingual).toMatchObject({ @@ -240,13 +254,32 @@ describe('speech model catalog', () => { /^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u ) } + if (file.targets.modelscope) { + expect(file.targets.modelscope.redirectHosts).toEqual([ + 'cdn-lfs-cn-1.modelscope.cn' + ]) + } } } expect( - paraformerBilingual?.files.some( - (file) => file.targets.modelscope !== undefined - ) - ).toBe(false) + paraformerBilingual?.files.map((file) => ({ + canonicalSize: file.size, + modelscopeSize: file.targets.modelscope?.size, + sameSha256: + file.sha256 === file.targets.modelscope?.sha256 + })) + ).toEqual([ + { + canonicalSize: 223_385_835, + modelscopeSize: 227_330_205, + sameSha256: false + }, + { + canonicalSize: 75_756, + modelscopeSize: 75_354, + sameSha256: false + } + ]) }) }) @@ -473,6 +506,140 @@ describe('SpeechModelManager downloads', () => { }) }) + it('installs and round-trips a source-specific artifact fingerprint', async () => { + const userData = await temporaryDirectory() + const importUserData = await temporaryDirectory() + const mixedUserData = await temporaryDirectory() + const huggingFaceModel = new TextEncoder().encode('hf model') + const huggingFaceTokens = new TextEncoder().encode('hf tokens') + const modelScopeModel = new TextEncoder().encode( + 'modelscope model variant' + ) + const modelScopeTokens = new TextEncoder().encode( + 'modelscope tokens variant' + ) + const catalog = downloadableCatalog( + huggingFaceModel, + huggingFaceTokens + ).map((entry) => ({ + ...entry, + files: entry.files.map((file) => { + const bytes = + file.role === 'model' + ? modelScopeModel + : modelScopeTokens + return { + ...file, + targets: { + ...file.targets, + modelscope: file.targets.modelscope + ? { + ...file.targets.modelscope, + size: bytes.byteLength, + sha256: sha256(bytes) + } + : undefined + } + } + }) + })) + const transport = vi.fn(async (input) => { + const url = String(input) + const bytes = url.endsWith('model.onnx') + ? modelScopeModel + : modelScopeTokens + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) } + }) + }) + const manager = new SpeechModelManager({ + userDataDirectory: userData, + fetch: transport, + catalog + }) + + await expect(manager.snapshot()).resolves.toMatchObject({ + catalog: [ + { + downloadAvailability: [ + { + source: 'modelscope', + available: true, + totalBytes: + modelScopeModel.byteLength + + modelScopeTokens.byteLength + }, + { + source: 'hugging-face', + available: true, + totalBytes: + huggingFaceModel.byteLength + + huggingFaceTokens.byteLength + } + ] + } + ] + }) + const installed = await manager.install( + 'download-test-model', + 'modelscope' + ) + expect(installed.files).toMatchObject([ + { + name: 'model.onnx', + size: modelScopeModel.byteLength, + sha256: sha256(modelScopeModel) + }, + { + name: 'tokens.txt', + size: modelScopeTokens.byteLength, + sha256: sha256(modelScopeTokens) + } + ]) + await manager.select('download-test-model') + await expect(manager.getSelectedRuntimeModel()).resolves.toMatchObject({ + id: 'download-test-model', + files: installed.files + }) + + const archive = join(userData, 'modelscope-variant.zip') + await manager.exportArchive('download-test-model', archive) + const importing = new SpeechModelManager({ + userDataDirectory: importUserData, + fetch: vi.fn(), + catalog + }) + await expect( + importing.importArchive('download-test-model', archive) + ).resolves.toMatchObject({ + files: installed.files + }) + + const mixedDirectory = join(mixedUserData, 'mixed-package') + await mkdir(mixedDirectory) + await Promise.all([ + writeFile( + join(mixedDirectory, 'model.onnx'), + modelScopeModel + ), + writeFile( + join(mixedDirectory, 'tokens.txt'), + huggingFaceTokens + ) + ]) + const mixed = new SpeechModelManager({ + userDataDirectory: mixedUserData, + fetch: vi.fn(), + catalog + }) + await expect( + mixed.registerLocalDirectory( + 'download-test-model', + mixedDirectory + ) + ).rejects.toThrow('与当前模型目录不匹配') + }) + it('cleans only manager-owned stale staging and partial artifacts', async () => { const userData = await temporaryDirectory() const modelBytes = new TextEncoder().encode('verified model bytes') diff --git a/src/main/speech/speech-model-manager.ts b/src/main/speech/speech-model-manager.ts index dacdd5f1..65009451 100644 --- a/src/main/speech/speech-model-manager.ts +++ b/src/main/speech/speech-model-manager.ts @@ -28,7 +28,10 @@ import { } from '../../shared/speech-model-contracts' import { MODEL_DOWNLOAD_SOURCES, + getMaximumModelPackageBytes, getModelDownloadAvailability, + getModelPackageFingerprints, + modelPackageFingerprintMatches, resolveModelDownloadPackage, type ModelDownloadSource, type ResolvedModelArtifactFile @@ -126,6 +129,21 @@ function toCatalogView(entry: SpeechModelCatalogEntry) { }) } +function speechModelPackageMatches( + entry: SpeechModelCatalogEntry, + files: readonly { + name: string + role: string + size: number + sha256: string + }[] +): boolean { + return getModelPackageFingerprints(entry.files).some( + (fingerprint) => + modelPackageFingerprintMatches(fingerprint, files) + ) +} + function validateMaximumBytes(value: number | undefined): number { const maximum = value ?? DEFAULT_MAX_FILE_BYTES if ( @@ -457,6 +475,9 @@ export class SpeechModelManager { if (!installed) { throw new Error('只能导出已安装的语音模型') } + if (!speechModelPackageMatches(entry, installed.files)) { + throw new Error('语音模型文件与当前模型目录不匹配') + } const directory = this.modelDirectory(entry.id) const files = [] for (const expected of entry.files) { @@ -467,9 +488,7 @@ export class SpeechModelManager { if ( !recorded || recorded.size <= 0 || - recorded.size > this.maxFileBytes || - recorded.size !== expected.size || - recorded.sha256 !== expected.sha256 + recorded.size > this.maxFileBytes ) { throw new Error(`语音模型文件不可导出:${expected.name}`) } @@ -497,10 +516,7 @@ export class SpeechModelManager { archivePath: string ): Promise { const entry = this.requireCatalogEntry(modelId) - const expectedTotal = entry.files.reduce( - (total, file) => total + file.size, - 0 - ) + const expectedTotal = getMaximumModelPackageBytes(entry.files) const maximumTotalBytes = Math.min( MAXIMUM_ARCHIVE_BYTES, expectedTotal + ARCHIVE_OVERHEAD_BYTES @@ -536,23 +552,18 @@ export class SpeechModelManager { operation.progress.completedBytes = completedBytes } }) - for (const expected of entry.files) { - const archived = descriptor.files.find( - (file) => - file.name === expected.name && - file.role === expected.role - ) - if ( - !archived || - archived.size > this.maxFileBytes || - archived.size !== expected.size || - archived.sha256 !== expected.sha256 - ) { - throw new Error( - `语音模型 ZIP 与当前模型目录不匹配:${expected.name}` - ) - } + if ( + descriptor.files.some( + (file) => file.size > this.maxFileBytes + ) || + !speechModelPackageMatches(entry, descriptor.files) + ) { + throw new Error('语音模型 ZIP 与当前模型目录不匹配') } + operation.progress.totalBytes = descriptor.files.reduce( + (total, file) => total + file.size, + 0 + ) operation.progress.phase = 'installing' operation.progress.currentFile = null const installed = installedSpeechModelSchema.parse({ @@ -631,18 +642,7 @@ export class SpeechModelManager { } if ( installed.files.length !== catalogEntry.files.length || - catalogEntry.files.some((expected) => { - const recorded = installed.files.find( - (file) => - file.name === expected.name && - file.role === expected.role - ) - return ( - !recorded || - recorded.size !== expected.size || - recorded.sha256 !== expected.sha256 - ) - }) + !speechModelPackageMatches(catalogEntry, installed.files) ) { return undefined } @@ -901,6 +901,7 @@ export class SpeechModelManager { await this.rejectUnsafeLocalEntries(sourceDirectory, signal, { visited: 0 }) + const actualFiles: InstalledSpeechModel['files'] = [] for (const expectedFile of entry.files) { ensureModelOperationNotAborted(signal) const sourceFile = safeChild(sourceDirectory, expectedFile.name) @@ -917,13 +918,14 @@ export class SpeechModelManager { ) { throw new RangeError(`模型文件大小无效:${expectedFile.name}`) } - if ( - sourceFileInfo.size !== expectedFile.size || - (await hashModelFile(sourceFile, signal)).sha256 !== - expectedFile.sha256 - ) { - throw new Error(`本地模型文件校验失败:${expectedFile.name}`) - } + actualFiles.push({ + name: expectedFile.name, + role: expectedFile.role, + ...(await hashModelFile(sourceFile, signal)) + }) + } + if (!speechModelPackageMatches(entry, actualFiles)) { + throw new Error('本地模型文件与当前模型目录不匹配') } } @@ -1022,6 +1024,9 @@ export class SpeechModelManager { installedAt: new Date().toISOString(), files }) + if (!speechModelPackageMatches(entry, manifest.files)) { + throw new Error('语音模型文件与当前模型目录不匹配') + } await writeFile( safeChild(stagingDirectory, MANIFEST_FILE_NAME), `${JSON.stringify(manifest, null, 2)}\n`, diff --git a/src/renderer/src/DocumentParsingSettingsSection.tsx b/src/renderer/src/DocumentParsingSettingsSection.tsx index 393521f9..8a24ae6b 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.tsx @@ -501,6 +501,13 @@ export function DocumentParsingSettingsSection({ ) const modelSourceAvailable = modelDownloadAvailability?.available === true + const modelSize = installedModel + ? installedModel.files.reduce( + (total, file) => total + file.size, + 0 + ) + : modelDownloadAvailability?.totalBytes ?? + (model ? catalogSize(model) : undefined) const pendingModelSelection = draft.localOcrModelId !== snapshot.settings.localOcrModelId const selectedModelReady = installedModel !== undefined @@ -845,7 +852,9 @@ export function DocumentParsingSettingsSection({ })} - {formatBytes(catalogSize(model))} + {formatBytes( + modelSize ?? catalogSize(model) + )} {model.license.name} diff --git a/src/renderer/src/SpeechModelSettingsSection.test.tsx b/src/renderer/src/SpeechModelSettingsSection.test.tsx index 27ba40a9..f81d4761 100644 --- a/src/renderer/src/SpeechModelSettingsSection.test.tsx +++ b/src/renderer/src/SpeechModelSettingsSection.test.tsx @@ -117,6 +117,50 @@ describe('SpeechModelSettingsSection', () => { expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8') }) + it('shows the selected download source artifact size', async () => { + await changeUiLocale('en-US') + const sourceSpecificSnapshot: SpeechModelSnapshot = { + ...snapshot, + catalog: [ + { + ...entry, + downloadAvailability: [ + { + source: 'modelscope', + available: true, + totalBytes: 2 * 1024 * 1024 + }, + { + source: 'hugging-face', + available: true, + totalBytes: 1_100 + } + ] + } + ] + } + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { + speechModels: { + getSnapshot: vi.fn(async () => sourceSpecificSnapshot), + 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() + + expect(await screen.findByText('2.0 MB')).toBeInTheDocument() + }) + it('lists downloadable models and starts a verified download', async () => { const installedSnapshot: SpeechModelSnapshot = { ...snapshot, diff --git a/src/renderer/src/SpeechModelSettingsSection.tsx b/src/renderer/src/SpeechModelSettingsSection.tsx index db0308f8..9b48a82f 100644 --- a/src/renderer/src/SpeechModelSettingsSection.tsx +++ b/src/renderer/src/SpeechModelSettingsSection.tsx @@ -309,11 +309,17 @@ export function SpeechModelSettingsSection({ const percent = operation ? progressPercent(operation) : undefined - const size = model ? catalogSize(model) : undefined const downloadAvailability = model?.downloadAvailability.find( (availability) => availability.source === snapshot.selectedDownloadSource ) + const size = installed + ? installed.files.reduce( + (total, file) => total + file.size, + 0 + ) + : downloadAvailability?.totalBytes ?? + (model ? catalogSize(model) : undefined) const sourceAvailable = downloadAvailability?.available === true const selected = model?.id === effectiveSelectedModelId const inUse = model?.id === effectivePersistedModelId diff --git a/src/shared/model-download-contracts.ts b/src/shared/model-download-contracts.ts index e1b876dc..6959d06a 100644 --- a/src/shared/model-download-contracts.ts +++ b/src/shared/model-download-contracts.ts @@ -16,7 +16,7 @@ export type ModelDownloadSource = z.infer< export const MODEL_DOWNLOAD_SOURCES = modelDownloadSourceSchema.options export const MODEL_DOWNLOAD_REDIRECT_HOSTS = { - modelscope: [], + modelscope: ['cdn-lfs-cn-1.modelscope.cn'], 'hugging-face': [ 'cdn-lfs.hf.co', 'cdn-lfs-us-1.hf.co', @@ -37,8 +37,17 @@ function isSourceHost( : hostname === 'huggingface.co' } +export const modelArtifactFingerprintSchema = z + .object({ + size: z.number().int().positive().safe(), + sha256: z.string().regex(sha256Pattern) + }) + .strict() + export const modelArtifactTargetSchema = z .object({ + size: modelArtifactFingerprintSchema.shape.size.optional(), + sha256: modelArtifactFingerprintSchema.shape.sha256.optional(), url: z.url().max(2_048), repositoryUrl: z.url().max(2_048), revision: z.string().regex(immutableRevisionPattern), @@ -49,6 +58,13 @@ export const modelArtifactTargetSchema = z }) .strict() .superRefine((target, context) => { + if ((target.size === undefined) !== (target.sha256 === undefined)) { + context.addIssue({ + code: 'custom', + path: ['size'], + message: '来源专用模型工件必须同时声明大小和 SHA-256' + }) + } for (const [key, value] of [ ['url', target.url], ['repositoryUrl', target.repositoryUrl] @@ -157,6 +173,9 @@ export const modelDownloadAvailabilitySchema = z export type ModelArtifactTarget = z.infer< typeof modelArtifactTargetSchema > +export type ModelArtifactFingerprint = z.infer< + typeof modelArtifactFingerprintSchema +> export type ModelArtifactTargets = z.infer< typeof modelArtifactTargetsSchema > @@ -186,6 +205,35 @@ export type ResolvedModelPackage = { files: ResolvedModelArtifactFile[] } +export type ModelPackageFingerprintFile< + Role extends string = string +> = { + name: string + role: Role + size: number + sha256: string +} + +export type ModelPackageFingerprint< + Role extends string = string +> = { + totalBytes: number + files: ModelPackageFingerprintFile[] +} + +function totalModelBytes( + files: readonly { size: number }[] +): number { + const totalBytes = files.reduce( + (total, file) => total + file.size, + 0 + ) + if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) { + throw new RangeError('模型总大小超出安全范围') + } + return totalBytes +} + export function getModelDownloadAvailability( files: readonly ResolvableModelArtifactFile[], source: ModelDownloadSource @@ -199,14 +247,14 @@ export function getModelDownloadAvailability( 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 + totalBytes: totalModelBytes( + files.map((file) => ({ + size: file.targets[source]!.size ?? file.size + })) + ) }) } @@ -232,18 +280,88 @@ export function resolveModelDownloadPackage( return { name: file.name, role: file.role, - size: file.size, - sha256: file.sha256, + size: target.size ?? file.size, + sha256: target.sha256 ?? file.sha256, target } }) } } +export function getModelPackageFingerprints( + files: readonly ResolvableModelArtifactFile[] +): ModelPackageFingerprint[] { + const candidates: ModelPackageFingerprint[] = [ + { + totalBytes: totalModelBytes(files), + files: files.map((file) => ({ + name: file.name, + role: file.role, + size: file.size, + sha256: file.sha256 + })) + } + ] + for (const source of MODEL_DOWNLOAD_SOURCES) { + if (!files.every((file) => file.targets[source])) { + continue + } + const resolved = resolveModelDownloadPackage(files, source) + candidates.push({ + totalBytes: resolved.totalBytes, + files: resolved.files.map((file) => ({ + name: file.name, + role: file.role, + size: file.size, + sha256: file.sha256 + })) + }) + } + const seen = new Set() + return candidates.filter((candidate) => { + const key = JSON.stringify(candidate.files) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +export function modelPackageFingerprintMatches< + Role extends string +>( + expected: ModelPackageFingerprint, + actual: readonly ModelPackageFingerprintFile[] +): boolean { + return ( + actual.length === expected.files.length && + expected.files.every((file) => + actual.some( + (candidate) => + candidate.name === file.name && + candidate.role === file.role && + candidate.size === file.size && + candidate.sha256 === file.sha256 + ) + ) + ) +} + +export function getMaximumModelPackageBytes( + files: readonly ResolvableModelArtifactFile[] +): number { + return Math.max( + ...getModelPackageFingerprints(files).map( + (fingerprint) => fingerprint.totalBytes + ) + ) +} + export const modelArtifactIdentitySchema = z .object({ - size: z.number().int().positive().safe(), - sha256: z.string().regex(sha256Pattern), + size: modelArtifactFingerprintSchema.shape.size, + sha256: modelArtifactFingerprintSchema.shape.sha256, targets: modelArtifactTargetsSchema }) .strict() diff --git a/src/shared/speech-model-contracts.test.ts b/src/shared/speech-model-contracts.test.ts index fd161e98..91d73432 100644 --- a/src/shared/speech-model-contracts.test.ts +++ b/src/shared/speech-model-contracts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { modelArtifactTargetsSchema } from './model-download-contracts' import { speechModelCatalogEntrySchema, speechModelLocalDirectoryInputSchema, @@ -173,6 +174,50 @@ describe('speech model contracts', () => { ).toBe(false) }) + it('allows only the declared ModelScope CDN redirect host', () => { + const modelscopeRepositoryUrl = + 'https://modelscope.cn/models/example/test-speech-model' + const target = { + url: + `${modelscopeRepositoryUrl}/resolve/${revision}/` + + 'model.onnx', + repositoryUrl: modelscopeRepositoryUrl, + revision, + redirectHosts: ['cdn-lfs-cn-1.modelscope.cn'] + } + + expect( + modelArtifactTargetsSchema.safeParse({ + modelscope: target + }).success + ).toBe(true) + expect( + modelArtifactTargetsSchema.safeParse({ + modelscope: { + ...target, + size: 13, + sha256: 'b'.repeat(64) + } + }).success + ).toBe(true) + expect( + modelArtifactTargetsSchema.safeParse({ + modelscope: { + ...target, + size: 13 + } + }).success + ).toBe(false) + expect( + modelArtifactTargetsSchema.safeParse({ + modelscope: { + ...target, + redirectHosts: ['untrusted.example'] + } + }).success + ).toBe(false) + }) + it('keeps repository links consistent with source targets', () => { expect( speechModelCatalogEntrySchema.safeParse({