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:
+2
-1
@@ -74,7 +74,8 @@
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
|
||||
- [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||
|
||||
@@ -567,6 +567,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
|
||||
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
|
||||
@@ -29,6 +29,9 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先
|
||||
## 跨功能文档
|
||||
|
||||
- [自动化平台架构](./architecture/automation-platform-architecture.md)
|
||||
- [平台功能页签与模型下载源设计](./architecture/model-download-source-design.md)
|
||||
- [本地文本向量模型与连接设计](./architecture/local-text-embedding-model-design.md)
|
||||
- [全双工实时语音交互设计](./architecture/full-duplex-voice-design.md)
|
||||
- [DeepSeek Harness Runtime 设计](./architecture/deepseek-harness-runtime-design.md)
|
||||
- [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md)
|
||||
- [长期助手路线图](./roadmap/long-term-assistant-roadmap.md)
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
# GoodBuddy 全双工实时语音交互设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档类型 | 跨功能技术与产品架构 |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 相关基线 | [跨平台助手产品设计](../design/cross-platform-assistant-product-design.md)、[长期助手路线图](../roadmap/long-term-assistant-roadmap.md)、[统一界面设计系统](../../UI-DESIGN.md) |
|
||||
|
||||
本文定义 GoodBuddy 中类似自然通话的全双工实时语音能力,包括本地与云端语音引擎、
|
||||
音频平面、会话状态、打断语义、工具审批、数据留存、失败恢复、跨平台交付和验收指标。
|
||||
|
||||
本文所称“支持本地与云端”是指用户可以显式配置并选择不同语音引擎,不代表系统可以在
|
||||
它们之间自动切换。**GoodBuddy 不设计静默降级。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 摘要与核心决策
|
||||
|
||||
1. 实时语音是独立的 `VoiceSession`,不把现有一次性语音听写改名后直接复用。
|
||||
2. 系统支持三种显式引擎:
|
||||
- 本地模块化全双工:本地流式 ASR、所选 Agent Runtime、本地流式 TTS。
|
||||
- 本地原生全双工:一个本地端到端语音模型同时听、想和说。
|
||||
- 云端原生全双工:通过供应商 Realtime/Live API 进行双向流式音频交互。
|
||||
3. 用户开始会话时冻结引擎、Provider、模型、版本、地域、数据位置、声音、能力和
|
||||
Turn Detection 配置。会话过程中不得静默替换。
|
||||
4. 同一目标内允许有界重试、网络抖动恢复和语义等价的内部执行优化;任何会改变
|
||||
Provider、模型、数据位置、成本、隐私、能力、质量或可感知延迟的替代路径都必须显式。
|
||||
5. 所选引擎不可用时,会话明确进入 `blocked` 或 `failed`,保留可恢复上下文,并提供
|
||||
“重试当前引擎”或“结束后选择其他引擎”。不自动切换本地/云端,不退回听写、纯文本或
|
||||
非全双工模式。
|
||||
6. Renderer 负责麦克风采集、回声消除、低延迟播放和即时打断;Main 负责凭据、会话
|
||||
控制、Provider Adapter、工具权限、持久化和资源回收。
|
||||
7. 音频帧不进入普通 `AgentEvent` 和聊天消息持久化通道。默认只保存最终文本、会话状态和
|
||||
有界诊断,不保存原始录音。
|
||||
8. 语音不能成为新的授权通道。Ask 继续只读,Execute 的工具调用继续经过现有审批控件。
|
||||
|
||||
---
|
||||
|
||||
## 2. 背景与当前基础
|
||||
|
||||
GoodBuddy 当前已经具备:
|
||||
|
||||
- Renderer 中的麦克风入口、录音状态和取消操作。
|
||||
- `getUserMedia` 的单声道采集、回声消除和噪声抑制。
|
||||
- 将完整录音重采样为 16 kHz PCM 的能力。
|
||||
- 基于 `sherpa-onnx` 的本地离线识别、模型下载、ZIP 迁移、选择和删除。
|
||||
- Main 中受信任发送者校验、Zod IPC 输入校验、超时、取消和应用关闭回收。
|
||||
- `AgentRuntime.run()` 的流式文本、工具事件、审批、取消和会话持久化。
|
||||
- Renderer 中的流式聊天时间线、全局通知和可访问的输入控件。
|
||||
|
||||
当前链路仍是:
|
||||
|
||||
```text
|
||||
点击麦克风
|
||||
→ 最多录音 20 秒
|
||||
→ 停止并一次性发送完整 PCM
|
||||
→ 本地离线转写
|
||||
→ 把文本插入输入框
|
||||
→ 用户再次确认发送
|
||||
```
|
||||
|
||||
该链路适合听写,但不具备:
|
||||
|
||||
- 连续流式识别和临时转写。
|
||||
- 同时采集与播放。
|
||||
- 自动轮次检测。
|
||||
- 助手语音输出。
|
||||
- 用户抢话和响应截断。
|
||||
- 音频队列、背压和时钟同步。
|
||||
- 实时语音 Provider 抽象。
|
||||
- 语音会话快照和诊断。
|
||||
|
||||
因此实时语音必须新增会话层,而不是在现有 `SpeechTranscriptionService` 后面简单追加 TTS。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标
|
||||
|
||||
### 3.1 用户目标
|
||||
|
||||
- 用户可以像通话一样持续说话,不需要每轮点击开始和停止。
|
||||
- 助手可以边生成边说,并显示与实际播放进度一致的文本。
|
||||
- 用户开口时可以自然打断,助手在很短时间内停止出声并开始听取新内容。
|
||||
- 用户始终知道当前使用本地还是云端、具体引擎是什么、音频或转写文本会去哪里。
|
||||
- 本地或云端引擎失败时,用户能看到准确状态并决定下一步,不被系统暗中换模型。
|
||||
- 语音对话继续拥有文本聊天中的项目、知识库、角色、Ask/Execute、工具审批和历史能力。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
- 在六个平台/架构目标上提供统一的上层会话契约。
|
||||
- 先以现有 `sherpa-onnx` 和 Agent Runtime 构建可跨平台交付的本地模块化引擎。
|
||||
- 允许云端 Provider 使用 WebRTC 或 WebSocket,但不把供应商协议泄漏到通用 UI。
|
||||
- 允许高性能设备安装本地原生全双工模型,但按真实能力检测决定是否可选。
|
||||
- 保持 Main-only 凭据、上下文隔离、沙箱、取消、超时、有界输出和关机回收。
|
||||
- 为延迟、打断、回声、音频中断、Provider 错误和成本提供可诊断指标。
|
||||
|
||||
---
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
首期不包含:
|
||||
|
||||
- 唤醒词、后台常驻监听或应用退出后的麦克风采集。
|
||||
- 根据网络、负载、价格或“智能判断”自动选择语音引擎。
|
||||
- 在一个会话内自动从云端切到本地,或从本地切到云端。
|
||||
- 从原生全双工自动退到 ASR → LLM → TTS,或反向切换。
|
||||
- 在实时语音失败后自动改成一次性听写、纯文本发送或系统 TTS。
|
||||
- 默认保存、上传或训练用户原始音频。
|
||||
- 声音克隆、未成年人声音模仿、电话呼入或多人会议。
|
||||
- 使用口头“同意”替代工具审批按钮或键盘确认。
|
||||
- 绕过当前 Agent Runtime 和权限边界的 Provider 直连工具、MCP 或 Connector。
|
||||
- 保证所有本地原生语音模型都能在 CPU 或全部六个发布目标上运行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 术语与全双工范围
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| `VoiceEngineProfile` | 用户保存的语音引擎配置,包含类型、Provider、模型、地域、声音和能力 |
|
||||
| `VoiceSession` | 一次从用户显式开始到结束的连续实时语音会话 |
|
||||
| `VoiceTurn` | 用户输入和助手响应形成的一次可持久化对话轮次 |
|
||||
| 系统级全双工 | 麦克风在助手播放期间继续采集,用户可以随时打断 |
|
||||
| 原生模型全双工 | 同一个模型联合处理持续输入、轮次判断和持续语音输出 |
|
||||
| 模块化全双工 | ASR、Agent Runtime 和 TTS 分离,但系统保持同时听说与可打断 |
|
||||
| 临时文本 | 尚未确认的 ASR 或尚未实际播放的助手文本,不写入长期历史 |
|
||||
| 已提交文本 | 用户轮次已确认,或助手对应音频已实际播放的文本 |
|
||||
| Barge-in | 用户在助手说话时开口,触发立即静音、取消和上下文截断 |
|
||||
| 引擎快照 | 会话开始时冻结的完整、无凭据配置及能力声明 |
|
||||
|
||||
“模块化”不等同于“回退”。当用户明确选择模块化本地引擎时,它就是该会话的唯一正式
|
||||
执行路径。原生模型和模块化引擎之间没有隐式优先级。
|
||||
|
||||
---
|
||||
|
||||
## 6. 不静默降级产品契约
|
||||
|
||||
### 6.1 必须显式的变化
|
||||
|
||||
以下变化不得在活动会话中静默发生:
|
||||
|
||||
- 本地与云端之间切换。
|
||||
- Provider、Endpoint、地域或账号切换。
|
||||
- 模型 ID、模型版本、量化档位或语音角色切换。
|
||||
- 原生全双工与模块化全双工之间切换。
|
||||
- ASR、LLM 或 TTS 组件切换。
|
||||
- 从音频输入改成仅文本输入,或从语音输出改成仅文本输出。
|
||||
- 禁用原本声明可用的工具、知识库、角色或 Execute 能力后继续运行。
|
||||
- 把原始音频改为上传,或改变云端数据地域和保留策略。
|
||||
- 采用明显更慢、更低质量或成本不同的路径。
|
||||
|
||||
### 6.2 可自动进行的恢复
|
||||
|
||||
以下操作可以自动执行,但必须保持同一引擎快照:
|
||||
|
||||
- 同一连接内的丢包恢复、抖动缓冲和音频重排。
|
||||
- 同一 Provider、模型、地域和配置的有限重连。
|
||||
- 同一本地模型进程的有限重启。
|
||||
- 不改变语义、隐私、成本和已声明性能级别的算子或执行 Provider 优化。
|
||||
|
||||
恢复在用户可感知前完成时可不打断界面;持续超过 500 ms、导致音频停顿或创建新远端
|
||||
会话时,必须显示“正在重新连接当前引擎”。所有恢复都进入有界诊断记录。
|
||||
|
||||
### 6.3 失败后的用户决策
|
||||
|
||||
恢复预算耗尽后:
|
||||
|
||||
1. 停止采集上传和音频播放。
|
||||
2. 将临时文本标记为未提交,不伪装成完整轮次。
|
||||
3. 保存已提交文本和脱敏错误。
|
||||
4. 显示当前失败的引擎、影响和建议。
|
||||
5. 提供“重试当前引擎”和“结束语音会话”。
|
||||
6. 用户结束后可以显式选择其他引擎并开始新会话。
|
||||
|
||||
首期不提供自动 Failover 列表。未来即使允许用户预先配置替代引擎,也必须在切换前获得
|
||||
明确确认,并在会话中持续显示新的活动引擎。
|
||||
|
||||
### 6.4 产品级适用范围
|
||||
|
||||
本契约不仅适用于语音。GoodBuddy 中 Provider、模型、Runtime、数据处理位置、工作模式、
|
||||
权限范围和质量档位等影响隐私、成本或能力的用户选择,都不得被静默替换。
|
||||
|
||||
用户明确选择名为“自动”的策略时,系统可以在该策略事先声明的范围内选择,但实际结果和
|
||||
任何能力退化必须可见、可诊断,不能把空结果或不完整结果表示为正常成功。
|
||||
|
||||
---
|
||||
|
||||
## 7. 用户体验
|
||||
|
||||
### 7.1 入口
|
||||
|
||||
现有麦克风入口继续表示“语音输入/听写”,转写进入可编辑输入框,不自动发送。
|
||||
|
||||
实时语音使用独立的“开始语音对话”入口,避免用户误以为点击一次听写会开启持续监听。
|
||||
入口只在以下条件满足时可用:
|
||||
|
||||
- 已选择并验证一个全双工语音引擎。
|
||||
- 当前平台满足该引擎能力要求。
|
||||
- 麦克风权限可申请。
|
||||
- 当前 Conversation 没有冲突的活动请求。
|
||||
- 当前工作模式和引擎能力兼容。
|
||||
|
||||
### 7.2 会话界面
|
||||
|
||||
活动会话显示一个持续可见的语音控制区:
|
||||
|
||||
- 当前状态:准备中、正在听、用户说话、正在思考、助手说话、正在打断、等待审批、
|
||||
正在重连、失败。
|
||||
- 本地/云端徽标、引擎名称和数据去向。
|
||||
- 实时用户转写和与播放同步的助手文本。
|
||||
- 麦克风静音、结束会话和必要的设备入口。
|
||||
- 输入音量与助手播放状态,但不得只用颜色表达。
|
||||
- 云端会话的使用量或成本提示入口。
|
||||
|
||||
“结束语音会话”是活动状态下的唯一主操作。波形和头像动效遵守
|
||||
`prefers-reduced-motion`,关闭动效后仍使用文字和图标表达状态。
|
||||
|
||||
### 7.3 打断
|
||||
|
||||
助手说话期间检测到用户有效语音:
|
||||
|
||||
1. 在 Renderer 立即对当前音频执行 20–40 ms 淡出。
|
||||
2. 清空尚未播放的音频队列。
|
||||
3. 向 Main 发送包含播放位置的 `interrupt`。
|
||||
4. Main 取消当前 Agent/TTS 响应或向 Provider 发送截断事件。
|
||||
5. 尚未播放的助手文本保持临时状态并从会话上下文中移除。
|
||||
6. 输入状态切到用户说话,继续采集,不重新建立会话。
|
||||
|
||||
键盘点击“停止说话”与语音 Barge-in 使用相同取消和提交语义。
|
||||
|
||||
### 7.4 工具和审批
|
||||
|
||||
- Ask 模式继续在 Runtime 边界保持只读。
|
||||
- Execute 模式的工具调用进入现有 Approval Broker。
|
||||
- 等待审批时暂停新的助手音频,可播放一次确定性的短提示,例如“需要你确认一个操作”。
|
||||
- 工具参数、风险、范围和确认操作使用现有可访问审批控件。
|
||||
- 麦克风中的“同意”“确认”或相似内容只作为普通用户文本,不构成授权。
|
||||
- 用户拒绝或取消后,结果作为结构化工具事件返回当前引擎,不私自换模型继续。
|
||||
|
||||
### 7.5 设置结构
|
||||
|
||||
长期设置结构使用一级“语音”分类,并以 `PageTabs` 组织:
|
||||
|
||||
1. **实时对话**:语音引擎列表、默认引擎、能力、数据位置、地域、声音和真实连接测试。
|
||||
2. **语音输入**:现有本地 ASR 模型、一次性听写和麦克风设置。
|
||||
3. **语音输出**:本地 TTS 模型、声音、语速和试听。
|
||||
|
||||
当前“模型连接”中的“语音输入”可在迁移阶段保留,之后移动现有模型管理组件时必须保存
|
||||
已安装模型和选择,不创建第二份设置。
|
||||
|
||||
模型类型选择器当前已经包含四项,不增加第五个分段项来承载实时语音,以免违反
|
||||
`SegmentedControl` 的 2–4 项约束。
|
||||
|
||||
语音引擎卡片必须持续显示:
|
||||
|
||||
- 本地或云端。
|
||||
- Provider、模型和版本。
|
||||
- 支持的语言。
|
||||
- 系统级或原生模型全双工。
|
||||
- 是否支持工具、图像和当前 Ask/Execute 模式。
|
||||
- 所需硬件或云端地域。
|
||||
- 音频和文本的数据去向。
|
||||
- 安装、已验证、不可用或需要凭据状态。
|
||||
|
||||
实际生成能力只能通过一次真实、有界、由用户触发的会话测试确认。配置保存成功或只完成
|
||||
握手不能证明麦克风输入、语音输出和打断均可工作。
|
||||
|
||||
---
|
||||
|
||||
## 8. 总体架构
|
||||
|
||||
```text
|
||||
┌──────────────────────── Renderer ────────────────────────┐
|
||||
│ VoiceSession UI │
|
||||
│ getUserMedia → AudioWorklet Capture → Fast VAD │
|
||||
│ AudioWorklet Playback ← Jitter/Playback Queue │
|
||||
└────────────── control IPC ─────── media MessagePort ───────┘
|
||||
│
|
||||
┌────────────────────────── Main ────────────────────────────┐
|
||||
│ VoiceSessionController │
|
||||
│ ├─ Session snapshot and state │
|
||||
│ ├─ Turn coordinator and interruption │
|
||||
│ ├─ Tool/approval bridge │
|
||||
│ ├─ Transcript/message persistence │
|
||||
│ ├─ Credential and provider policy │
|
||||
│ └─ VoiceEngineAdapter │
|
||||
│ ├─ LocalModularAdapter │
|
||||
│ ├─ LocalNativeDuplexAdapter │
|
||||
│ └─ CloudRealtimeAdapter │
|
||||
└───────────────┬──────────────────────┬─────────────────────┘
|
||||
│ │
|
||||
Local managed sidecar Cloud Realtime API
|
||||
or bounded worker WebRTC / WebSocket
|
||||
```
|
||||
|
||||
### 8.1 Renderer 音频平面
|
||||
|
||||
Renderer 负责需要接近音频设备的低延迟操作:
|
||||
|
||||
- 在用户操作后调用 `getUserMedia`。
|
||||
- 请求单声道、回声消除、噪声抑制和受支持时的自动增益。
|
||||
- 使用 `AudioWorklet`,不继续扩展 `ScriptProcessorNode`。
|
||||
- 将音频切成 10–20 ms 有序帧,并按引擎格式重采样。
|
||||
- 执行快速本地 VAD,用于 Barge-in,不独立提交最终轮次。
|
||||
- 维护有界播放和抖动缓冲,记录实际播放采样位置。
|
||||
- 在打断、设备变化、休眠或窗口销毁时立即静音和释放资源。
|
||||
|
||||
Renderer 不持有长期 API Key、不创建本地模型目录、不决定工具权限,也不持久化原始音频。
|
||||
|
||||
### 8.2 Preload 与 IPC
|
||||
|
||||
控制面使用显式、类型化的 preload 方法:
|
||||
|
||||
- `voice.getSnapshot()`
|
||||
- `voice.startSession(input)`
|
||||
- `voice.stopSession(sessionId)`
|
||||
- `voice.setMuted(input)`
|
||||
- `voice.interrupt(input)`
|
||||
- `voice.respondApproval(...)` 继续复用现有审批接口
|
||||
- `voice.onEvent(listener)`
|
||||
|
||||
音频帧不使用逐帧 `ipcRenderer.invoke`、JSON 或 Base64。Main 通过
|
||||
`MessageChannelMain` 向可信主 Frame 传递专用 `MessagePort`,使用可转移
|
||||
`ArrayBuffer` 和严格的帧头。控制事件和媒体帧分别限速、限长和验证。
|
||||
|
||||
### 8.3 Main 控制面
|
||||
|
||||
`VoiceSessionController` 负责:
|
||||
|
||||
- 每个窗口最多一个活动语音会话。
|
||||
- 解析并冻结 `VoiceSessionSnapshot`。
|
||||
- 建立所选 Adapter,不执行自动 Adapter 选择。
|
||||
- 维护输入、输出和生命周期状态。
|
||||
- 将 Barge-in 传播到 Provider、Agent Runtime、TTS 和播放队列。
|
||||
- 桥接工具调用、审批、问题和取消。
|
||||
- 只提交已经确认或实际播放的文本。
|
||||
- 处理超时、重连预算、应用退出、系统休眠和窗口销毁。
|
||||
- 对错误和诊断执行脱敏与边界限制。
|
||||
|
||||
### 8.4 本地进程边界
|
||||
|
||||
轻量 ONNX 能力可以运行在受控 Worker。需要 Python、CUDA、Metal/MLX 或独立依赖树的
|
||||
原生模型运行在 GoodBuddy 管理的 Sidecar:
|
||||
|
||||
- 只绑定 loopback,不监听外部网卡。
|
||||
- 使用随机端口和每次启动的短期认证值。
|
||||
- 环境变量使用最小 allowlist。
|
||||
- 不继承云端 Provider 密钥。
|
||||
- 模型路径由 Main 从受管目录解析,不接受任意相对路径。
|
||||
- 启动、健康检查、并发、输出、内存、超时和进程树有界。
|
||||
- 应用退出时终止完整进程树。
|
||||
|
||||
Sidecar 不因本地模型启动失败而自行连接云端。
|
||||
|
||||
### 8.5 云端连接边界
|
||||
|
||||
供应商支持 WebRTC 时优先使用其媒体传输、编解码和抖动能力:
|
||||
|
||||
- Main 使用长期凭据创建受限、短时的会话描述或临时凭据。
|
||||
- Renderer 只接收当前会话需要的短期材料。
|
||||
- 工具和业务事件优先由 Main sideband 连接处理。
|
||||
- Provider 不支持 sideband 时,由 Main 拥有 WebSocket,并通过媒体 `MessagePort`
|
||||
与 Renderer 交换音频。
|
||||
|
||||
长期凭据永不进入 Renderer、日志、诊断或会话快照。云端 Profile 必须固定可信 Endpoint、
|
||||
地域和数据说明,不跟随重定向切换到未声明的主机。
|
||||
|
||||
---
|
||||
|
||||
## 9. 共享契约
|
||||
|
||||
建议新增 `src/shared/voice-contracts.ts`,核心结构如下:
|
||||
|
||||
```ts
|
||||
type VoiceEngineKind =
|
||||
| 'local-modular'
|
||||
| 'local-native-duplex'
|
||||
| 'cloud-native-duplex'
|
||||
|
||||
type VoiceComponentRef = {
|
||||
providerId: string
|
||||
modelId: string
|
||||
modelVersion?: string
|
||||
endpoint?: string
|
||||
region?: string
|
||||
accountRef?: string
|
||||
credentialRef?: string
|
||||
}
|
||||
|
||||
type VoiceEngineProfile = {
|
||||
id: string
|
||||
name: string
|
||||
kind: VoiceEngineKind
|
||||
locality: 'local' | 'cloud'
|
||||
voiceId: string
|
||||
components: {
|
||||
asr?: VoiceComponentRef
|
||||
tts?: VoiceComponentRef
|
||||
nativeDuplex?: VoiceComponentRef
|
||||
}
|
||||
dataPath: {
|
||||
audioDestination:
|
||||
| { kind: 'device' }
|
||||
| {
|
||||
kind: 'provider'
|
||||
providerId: string
|
||||
endpoint: string
|
||||
region?: string
|
||||
}
|
||||
transcriptDestination:
|
||||
| { kind: 'device' }
|
||||
| {
|
||||
kind: 'provider'
|
||||
providerId: string
|
||||
endpoint: string
|
||||
region?: string
|
||||
}
|
||||
retentionPolicyId?: string
|
||||
}
|
||||
capabilities: {
|
||||
nativeDuplex: boolean
|
||||
supportsTools: boolean
|
||||
supportsAsk: boolean
|
||||
supportsExecute: boolean
|
||||
inputLanguages: string[]
|
||||
outputLanguages: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type VoiceRuntimeSnapshot = {
|
||||
selection: Exclude<AgentRuntimeSelection, { provider: 'auto' }>
|
||||
profileRevision?: string
|
||||
configurationDigest: string
|
||||
workspacePath: string
|
||||
}
|
||||
|
||||
type VoiceSessionSnapshot = {
|
||||
sessionId: string
|
||||
conversationId: string
|
||||
profile: VoiceEngineProfile
|
||||
profileRevision: string
|
||||
engineConfigurationDigest: string
|
||||
runtime?: VoiceRuntimeSnapshot
|
||||
workMode: 'ask' | 'execute'
|
||||
inputFormat: VoiceAudioFormat
|
||||
outputFormat: VoiceAudioFormat
|
||||
turnDetection: VoiceTurnDetectionConfig
|
||||
startedAt: string
|
||||
}
|
||||
```
|
||||
|
||||
`credentialRef` 和 `accountRef` 是不含凭据正文的稳定引用。Endpoint 写入 Profile 或快照前
|
||||
必须规范化并删除用户名、密码、查询参数和 Fragment;供应商部署路径仍应保留,以便检测
|
||||
Endpoint 是否发生变化。Profile 持久化时只引用 Main 加密设置,快照不包含长期或临时
|
||||
Token。
|
||||
|
||||
模块化引擎分别记录 ASR 和 TTS 组件,原生引擎记录 `nativeDuplex` 组件;不能用一个
|
||||
`modelId` 代表多组件链路。文本 Agent 使用独立 `VoiceRuntimeSnapshot`,记录已解析的明确
|
||||
Runtime、模型 Profile 修订、配置摘要和工作区。`dataPath` 分别说明原始音频和转写文本
|
||||
留在设备还是发送到哪个供应商。
|
||||
|
||||
### 9.1 Runtime Lease
|
||||
|
||||
语音会话不能在每轮请求时重新读取可变的全局 Runtime 设置。启动时必须:
|
||||
|
||||
1. 将 `auto` 解析为明确的 Runtime 和模型 Profile,并在会话界面显示实际结果。
|
||||
2. 根据已解析配置创建或取得一个不可变的 `VoiceRuntimeLease`。
|
||||
3. Lease 在整个 Voice Session 内引用同一个 Runtime 实例和配置摘要。
|
||||
4. 全局设置变化只为新请求和新 Voice Session 创建 Runtime,不替换活动 Lease。
|
||||
5. 用户删除或修改活动 Profile 时,界面说明“下次语音会话生效”;当前 Lease 继续运行。
|
||||
6. 固定实例无法继续时,当前语音会话明确失败,不能取得新的全局 Runtime 继续。
|
||||
|
||||
现有 `AgentRuntimeController` 的可变 `current` Slot 会在 `replace()` 后中断活动请求,因此
|
||||
不能直接作为长期 Voice Session Lease。实现前必须增加引用计数式 Pin/Lease,或由
|
||||
`SelectedRuntimeManager` 为会话持有独立 Runtime Slot;会话结束后再
|
||||
`releaseConversation()` 并释放 Lease。
|
||||
|
||||
### 9.2 事件
|
||||
|
||||
控制事件至少包括:
|
||||
|
||||
- `session-preparing`
|
||||
- `session-ready`
|
||||
- `session-reconnecting`
|
||||
- `input-speech-started`
|
||||
- `input-transcript-delta`
|
||||
- `input-transcript-committed`
|
||||
- `response-started`
|
||||
- `response-transcript-delta`
|
||||
- `response-audio-started`
|
||||
- `response-interrupted`
|
||||
- `response-completed`
|
||||
- `approval-required`
|
||||
- `tool-state`
|
||||
- `usage`
|
||||
- `error`
|
||||
- `session-ended`
|
||||
|
||||
音频帧使用独立二进制协议,包含:
|
||||
|
||||
- `sessionId`
|
||||
- `generationId`
|
||||
- `sequence`
|
||||
- `timestampSamples`
|
||||
- `sampleRate`
|
||||
- `channels`
|
||||
- `encoding`
|
||||
- `payload`
|
||||
|
||||
帧乱序、重复、跨会话或超过大小上限时直接拒绝,不尝试解释为其他格式。
|
||||
|
||||
---
|
||||
|
||||
## 10. 状态模型
|
||||
|
||||
全双工不能只用一个“正在听/正在说”枚举描述。会话使用三个正交状态:
|
||||
|
||||
```text
|
||||
Lifecycle:
|
||||
idle → preparing → active ↔ reconnecting → ended
|
||||
└──────────────→ failed
|
||||
|
||||
Input:
|
||||
muted ↔ listening ↔ speech
|
||||
|
||||
Output:
|
||||
idle → generating → playing → interrupting → idle
|
||||
```
|
||||
|
||||
用户可见状态由三个状态组合得出。合法示例:
|
||||
|
||||
- `input=listening + output=playing`:助手说话,同时继续监听。
|
||||
- `input=speech + output=interrupting`:用户抢话,助手正在停止。
|
||||
- `lifecycle=reconnecting + input=muted + output=idle`:当前引擎重连,停止上传。
|
||||
|
||||
`awaiting-approval` 是运行阻塞原因,不关闭会话;此时输入可以继续听取取消或补充文本,
|
||||
但不能把口头内容解释成授权。
|
||||
|
||||
---
|
||||
|
||||
## 11. Turn、文本与播放提交
|
||||
|
||||
### 11.1 用户输入
|
||||
|
||||
- 流式 ASR Delta 只用于界面。
|
||||
- Endpoint Detector 确认轮次后产生 committed transcript。
|
||||
- 空白、纯噪音和低置信度片段不创建用户消息。
|
||||
- 用户可在提交前通过键盘修正;修正结果而非原始猜测进入 Agent Runtime。
|
||||
|
||||
### 11.2 助手输出
|
||||
|
||||
模块化 TTS 可能落后于文本生成,因此助手文本分为:
|
||||
|
||||
- `generated`:模型已生成,尚未安排播放。
|
||||
- `queued`:已生成音频,尚未播放。
|
||||
- `played`:对应音频已从播放时钟确认输出。
|
||||
|
||||
助手消息需要区分“用户可见历史”和“下一轮模型上下文”:
|
||||
|
||||
- 所有已展示的有界文本和结构化内容都写入可见消息历史。
|
||||
- 可朗读文本记录 `generated`、`queued`、`played` 边界;中断后的消息标记为
|
||||
`interrupted`,并保留用户已经看见的内容及已播放边界。
|
||||
- 下一轮模型上下文只包含 `played` 可朗读文本,以及已经展示的 `visual-only` 内容。
|
||||
- 尚未播放的可朗读尾部即使曾临时显示,也不回送模型,并在历史中显示“未播完”状态。
|
||||
|
||||
代码块、表格、URL、引用和工具结果等不适合逐字朗读的内容使用 `visual-only` Block。它们
|
||||
一旦完整展示即可进入可见历史和下一轮上下文,不受语音播放边界裁切。这样既不会丢失用户
|
||||
已经看到的详细成果,也不会让模型误以为用户听到了被打断的语音尾部。
|
||||
|
||||
云端 Provider 支持会话截断时,Main 使用实际播放位置截断远端 Conversation Item;
|
||||
不支持时由 GoodBuddy 在下一轮上下文中只组装 `played` 和已展示的 `visual-only` 部分。
|
||||
现有消息契约与上下文组装器需要增加对应 Block 状态,不能用删掉完整助手消息来模拟截断。
|
||||
|
||||
### 11.3 文本转语音规划
|
||||
|
||||
模块化引擎从流式文本中产生可取消的短语块:
|
||||
|
||||
- 优先在中文标点、英文句界和自然从句边界提交。
|
||||
- 首个短语不等待完整回答,以降低首音频延迟。
|
||||
- URL、Markdown 标记、代码块、表格、引用编号和工具 JSON 不逐字符朗读。
|
||||
- 不能可靠口述的内容在界面展示,并使用确定性短提示说明“详细内容已显示在对话中”。
|
||||
- 不调用第二个未选择的模型生成“语音摘要”。
|
||||
|
||||
---
|
||||
|
||||
## 12. 引擎设计
|
||||
|
||||
### 12.1 本地模块化全双工
|
||||
|
||||
首个跨平台本地基线复用现有 `sherpa-onnx`:
|
||||
|
||||
```text
|
||||
AudioWorklet
|
||||
→ Silero/TEN VAD
|
||||
→ sherpa-onnx OnlineRecognizer
|
||||
→ selected AgentRuntime
|
||||
→ deterministic speech text planner
|
||||
→ sherpa-onnx TTS callback
|
||||
→ AudioWorklet playback
|
||||
```
|
||||
|
||||
现有 `sherpa-onnx` Node Addon 已提供在线识别、VAD、本地 TTS 和 TTS 音频回调。当前已安装
|
||||
的 SenseVoice、Paraformer 和 Whisper 目录主要用于离线识别;实时模式需要独立的在线
|
||||
模型目录和能力声明,不能把离线模型误标成流式模型。
|
||||
|
||||
“本地模块化”只保证音频采集、ASR 和 TTS 在本机。中间 Agent Runtime 是否本地取决于
|
||||
用户明确选择的模型连接:
|
||||
|
||||
- 连接到 loopback 本地模型时,完整链路可离线。
|
||||
- 连接到云端文本模型时,原始音频留在本地,但最终转写文本和 Agent 上下文会发送到
|
||||
该模型。界面必须明确显示这一数据路径。
|
||||
|
||||
不得因当前文本 Runtime 不可用而替换为另一模型连接。
|
||||
|
||||
### 12.2 本地原生全双工
|
||||
|
||||
本地原生 Adapter 面向 MiniCPM-o、Moshi/PersonaPlex、BayLing-Duplex 等能够持续接收并
|
||||
生成音频的模型。具体模型接入前必须逐个验证:
|
||||
|
||||
- 中文和目标语言质量。
|
||||
- 真正的持续输入、Barge-in 和 Backchannel,而不只是流式输出。
|
||||
- 首音频延迟和长期运行内存。
|
||||
- Windows、macOS、Linux 及 x64/arm64 Runtime 可用性。
|
||||
- NVIDIA CUDA、Apple Silicon 或 CPU 的真实硬件要求。
|
||||
- 工具调用、系统指令、上下文长度和取消支持。
|
||||
- 模型、声音、训练数据与商业分发许可。
|
||||
- 权重下载、ZIP 迁移、校验和、磁盘占用和卸载。
|
||||
|
||||
本地原生模型不作为六平台默认能力。只有能力检测和一次真实会话测试通过后才允许选择。
|
||||
缺少结构化工具能力的模型可以声明为 Ask-only;Execute 入口必须阻塞并说明原因,不能暗中
|
||||
调用另一个文本模型补齐工具。
|
||||
|
||||
### 12.3 云端原生全双工
|
||||
|
||||
云端 Adapter 可以面向 OpenAI Realtime、Gemini Live、Qwen Realtime、Azure Voice Live
|
||||
等正式配置。每个 Adapter 必须显式声明:
|
||||
|
||||
- WebRTC 或 WebSocket 传输。
|
||||
- 输入输出音频格式。
|
||||
- VAD、Semantic Turn Detection 和手动提交能力。
|
||||
- 响应取消、音频截断和实际播放对齐能力。
|
||||
- 输入与输出转写能力。
|
||||
- 工具调用和 sideband 控制能力。
|
||||
- 会话时长、上下文、速率限制和费用。
|
||||
- 可用地域、数据处理与保留说明。
|
||||
|
||||
Provider 配置不使用泛化“OpenAI compatible”推断 Realtime 能力。普通 Chat Completions
|
||||
Endpoint 不能因为 URL 相似就被标记为实时语音。
|
||||
|
||||
---
|
||||
|
||||
## 13. 音频处理
|
||||
|
||||
### 13.1 采集
|
||||
|
||||
- 浏览器设备通常以 44.1 或 48 kHz 采集,不能假定请求值就是实际值。
|
||||
- 使用 `MediaStreamTrack.getSettings()` 记录实际声道、采样率和回声消除状态。
|
||||
- AudioWorklet 以原始设备时钟采集,再按引擎要求转换为 16/24/48 kHz。
|
||||
- 默认单声道 Float32 内部格式,边界处转换为 PCM16、Opus 或 Provider 指定格式。
|
||||
- 每帧 10–20 ms,带序号和采样时间,不使用墙钟猜测播放位置。
|
||||
|
||||
### 13.2 回声与抢话
|
||||
|
||||
回声处理使用两层信号:
|
||||
|
||||
1. Chromium AEC/NS/AGC 处理后的麦克风流。
|
||||
2. GoodBuddy 已知的播放活动、播放能量和 VAD 结果。
|
||||
|
||||
只有满足最短语音持续时间、能量和回声相关性条件时才触发 Barge-in。阈值必须可测试,
|
||||
不能仅依赖一个 Provider 的 `speech_started` 事件。Provider 事件作为权威轮次信号之一,
|
||||
本地快速 VAD 负责先静音。
|
||||
|
||||
### 13.3 播放与背压
|
||||
|
||||
- 每个响应使用独立 `generationId`,旧响应帧不得进入新队列。
|
||||
- 播放队列按采样时钟排序,禁止无限积压。
|
||||
- 达到高水位时对上游施加背压;无法背压的 Provider 丢弃会话并报告协议错误,不能持续
|
||||
增长内存。
|
||||
- 音频缺口使用短静音或 Provider 编解码恢复,不重复上一段语音。
|
||||
- 切换输出设备、设备丢失或系统休眠时暂停提交时钟,避免把未播放文本标记为已听到。
|
||||
|
||||
---
|
||||
|
||||
## 14. 数据与持久化
|
||||
|
||||
### 14.1 默认保存
|
||||
|
||||
- Voice Session ID、Conversation ID 和时间。
|
||||
- 无凭据的引擎快照及其摘要哈希。
|
||||
- 最终用户文本、已展示的助手消息、`visual-only` Block、实际播放边界和中断状态。
|
||||
- 中断、失败、取消和完成状态。
|
||||
- 有界延迟、音频中断和用量指标。
|
||||
- 工具与审批事件继续进入现有任务和活动记录。
|
||||
|
||||
### 14.2 默认不保存
|
||||
|
||||
- 原始麦克风音频。
|
||||
- Provider 返回但尚未播放的音频。
|
||||
- 临时 ASR Delta。
|
||||
- 长期或临时 API Key、Cookie、会话 Token。
|
||||
- Provider 原始错误正文和可能包含用户内容的网络帧。
|
||||
- 回声参考信号、设备唯一标识和完整声学特征。
|
||||
|
||||
未来若提供录音留存,必须是独立、默认关闭的功能,说明保存位置、期限、大小、导出和删除,
|
||||
并与“改进模型”授权分离。
|
||||
|
||||
### 14.3 崩溃恢复
|
||||
|
||||
应用启动时将未结束的 Voice Session 标记为 `interrupted`。恢复文本 Conversation,
|
||||
但不自动重新打开麦克风、不自动连接 Provider,也不重播未完成音频。
|
||||
|
||||
---
|
||||
|
||||
## 15. 错误、重连与资源回收
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 麦克风权限拒绝 | 阻塞启动,保留引擎选择,提供系统权限说明 |
|
||||
| 输入/输出设备消失 | 立即静音或暂停,要求用户处理设备,不改用未选择设备 |
|
||||
| 本地模型缺失或损坏 | 阻塞启动,进入模型管理,不连接云端 |
|
||||
| 本地 Runtime 启动失败 | 在有界预算内重启同一 Runtime,之后明确失败 |
|
||||
| 云端认证或地域错误 | 明确失败,保留配置,不尝试其他 Provider/地域 |
|
||||
| 短暂断网 | 同一引擎有界重连,超过 500 ms 显示状态 |
|
||||
| Provider 限流或余额不足 | 结束生成并显示原因,不切本地模型 |
|
||||
| Agent Runtime 失败 | 终止当前轮次,允许重试同一 Runtime,不换连接 |
|
||||
| TTS 失败 | 当前轮次失败,不静默改成系统 TTS 或仅文本成功 |
|
||||
| 工具等待审批 | 暂停响应,保留会话;拒绝后把结果返回当前引擎 |
|
||||
| 应用退出/窗口销毁 | 取消请求、停止 Track、关闭 Port/PeerConnection、终止 Sidecar |
|
||||
| 系统休眠/锁屏 | 停止采集和上传;恢复后要求用户显式继续 |
|
||||
|
||||
每个会话必须有最大时长、最大连续无声时间、最大媒体队列、最大临时文本、最大重连次数和
|
||||
最大诊断大小。取消优先于重连和重试。
|
||||
|
||||
---
|
||||
|
||||
## 16. 安全与隐私
|
||||
|
||||
1. 只允许可信主窗口主 Frame 创建和控制 Voice Session。
|
||||
2. 麦克风权限只放行音频,不因实时语音放开视频。
|
||||
3. 任何音频采集都需要用户操作;活动期间持续显示应用内状态和系统麦克风指示。
|
||||
4. 云端会话在开始前显示 Provider、地域、发送内容和可能费用。
|
||||
5. API Key 只在 Main 的加密设置或受控环境变量中使用。
|
||||
6. 临时 Provider 凭据具有最短可行期限、最小能力和单会话作用域。
|
||||
7. Provider 工具调用必须回到 Main 的白名单、Schema、Ask/Execute 和审批边界。
|
||||
8. 本地 Sidecar 只监听 loopback,使用短期认证,不开放外部端口。
|
||||
9. 模型权重按受信任目录、固定来源、大小和 SHA-256 校验,导入 ZIP 防止路径穿越和压缩炸弹。
|
||||
10. 日志只记录状态、耗时、错误分类和匿名引擎 ID,不记录语音正文和音频。
|
||||
11. 窗口隐藏时若会话仍活动,托盘必须持续显示麦克风状态和停止入口;首期可以选择隐藏即
|
||||
暂停,但不能隐藏后无提示继续采集。
|
||||
12. Voice Session 不扩大项目、知识库、文件、浏览器或桌面控制范围。
|
||||
|
||||
---
|
||||
|
||||
## 17. 性能与质量指标
|
||||
|
||||
### 17.1 交互指标
|
||||
|
||||
| 指标 | 目标 |
|
||||
| --- | --- |
|
||||
| 用户开口到本地 VAD 检出 | P95 ≤ 100 ms |
|
||||
| Barge-in 检出到扬声器静音 | P95 ≤ 150 ms |
|
||||
| 播放队列常态深度 | 100–400 ms |
|
||||
| 用户轮次结束到临时文本稳定 | P50 ≤ 300 ms |
|
||||
| 用户轮次结束到首段助手音频 | 云端/原生引擎 P50 ≤ 800 ms;模块化引擎 P50 ≤ 1,200 ms |
|
||||
| 已提交文本与实际播放偏差 | ≤ 100 ms 或一个最小短语块 |
|
||||
| 连续 30 分钟会话 | 无未界定内存增长、重复播放或资源泄漏 |
|
||||
|
||||
本地指标必须注明测试硬件,不能把高端 GPU 结果宣传为 CPU 基线。未达到所选引擎声明的
|
||||
实时系数时,能力检测应标记为不满足实时要求,而不是静默切到更小模型。
|
||||
|
||||
### 17.2 质量指标
|
||||
|
||||
- 中文普通话、英文和中英混合词的 ASR 错误率。
|
||||
- 长停顿、语气词、短回答和自我修正的轮次准确率。
|
||||
- 扬声器回声、键盘声、音乐和旁人说话下的误打断率。
|
||||
- 真正用户抢话的漏检率和停止延迟。
|
||||
- TTS 首段延迟、断句、数字、日期、英文缩写和代码术语可懂度。
|
||||
- 中断后下一轮上下文不包含未播放内容。
|
||||
- Provider、模型、数据位置和能力从不发生未声明变化。
|
||||
|
||||
---
|
||||
|
||||
## 18. 测试策略
|
||||
|
||||
### 18.1 自动化
|
||||
|
||||
- Voice Contract Schema、大小边界和迁移测试。
|
||||
- 三组正交状态及非法状态组合测试。
|
||||
- 有序、乱序、重复、迟到和跨 Session 音频帧测试。
|
||||
- Barge-in 对播放、Provider、Agent、TTS 和持久化的取消传播测试。
|
||||
- 临时文本、已提交文本和播放位置对齐测试。
|
||||
- 同一引擎重连预算与超时测试。
|
||||
- “禁止静默降级”矩阵测试:任何 Adapter、Provider、模型、地域或 Runtime 变化都必须失败。
|
||||
- Ask 只读和 Execute 审批测试。
|
||||
- 窗口销毁、应用退出、休眠和设备丢失的资源释放测试。
|
||||
- 不持久化音频、临时 Token 和 Provider 原始正文的数据库测试。
|
||||
|
||||
### 18.2 模拟与声学测试
|
||||
|
||||
建立确定性 Fake Voice Engine,能够注入:
|
||||
|
||||
- 固定节奏的输入、文本和音频。
|
||||
- 网络抖动、丢包、重复和断开。
|
||||
- 超前文本、迟到音频和错误播放位置。
|
||||
- 用户抢话、回声、短噪音和长停顿。
|
||||
- 工具调用、审批、拒绝和取消。
|
||||
|
||||
真实声学测试使用预录双声道夹具,一路作为助手扬声器参考,一路作为用户麦克风输入。
|
||||
不能只通过静态单段 WAV 验证全双工。
|
||||
|
||||
### 18.3 手动与外部调用
|
||||
|
||||
- 六个发布目标分别验证麦克风权限、采集、播放、设备拔插和应用退出。
|
||||
- 本地模型在声明的最低硬件上完成 30 分钟稳定性和实时系数测试。
|
||||
- 云端 Provider 测试会产生外部调用和费用,只在明确授权的 gated 测试中运行。
|
||||
- 每个云端 Adapter 至少验证一次真实音频输入、真实音频输出、打断和工具审批。
|
||||
- 真实测试失败时不使用配置握手成功替代生成验证。
|
||||
|
||||
---
|
||||
|
||||
## 19. 跨平台交付
|
||||
|
||||
### 19.1 基线
|
||||
|
||||
- 本地模块化引擎作为 Windows、macOS、Linux x64/arm64 的统一功能基线。
|
||||
- 在线 ASR、VAD 和轻量 TTS 权重不内置,继续使用按需下载和 ZIP 离线迁移。
|
||||
- GoodBuddy 托管模型的下载遵守
|
||||
[平台功能页签与模型下载源设计](./model-download-source-design.md),使用用户显式选择的
|
||||
ModelScope 或 Hugging Face,失败时不切换来源。
|
||||
- 云端 Adapter 在六个平台复用同一契约,并分别验证 Electron WebRTC/WebSocket 行为。
|
||||
- 本地原生引擎按 Adapter 声明平台与硬件,不伪装成全平台能力。
|
||||
|
||||
### 19.2 硬件能力等级
|
||||
|
||||
| 等级 | 目标 |
|
||||
| --- | --- |
|
||||
| CPU 基线 | 本地模块化 ASR/TTS;文本 Runtime 可以本地或云端 |
|
||||
| Apple Silicon | 可增加 MLX/Metal 本地原生 Adapter,必须单独验证 |
|
||||
| NVIDIA GPU | 可增加 CUDA 本地原生 Adapter,按显存和驱动验证 |
|
||||
| 不满足要求 | 引擎卡片显示不可用与原因,不自动选择其他引擎 |
|
||||
|
||||
安装包继续保持轻量。大模型权重、CUDA Runtime 和独立 Python 环境不得无条件加入全部
|
||||
发布包。
|
||||
|
||||
---
|
||||
|
||||
## 20. 分阶段实施
|
||||
|
||||
### 阶段 0:契约与模拟器
|
||||
|
||||
- 新增 Voice Contracts、状态机和 Fake Voice Engine。
|
||||
- 建立禁止静默降级测试矩阵。
|
||||
- 建立会话快照、事件和诊断结构。
|
||||
|
||||
### 阶段 1:Renderer 音频平面
|
||||
|
||||
- AudioWorklet 采集与播放。
|
||||
- 媒体 `MessagePort`、背压和播放时钟。
|
||||
- 快速 VAD、回声关联、Barge-in 和设备生命周期。
|
||||
- 实时语音控制区和可访问状态。
|
||||
|
||||
### 阶段 2:本地模块化基线
|
||||
|
||||
- 在线 ASR 和 VAD 模型管理。
|
||||
- Agent Runtime 流式文本桥。
|
||||
- 本地 TTS 模型管理、短语规划、音频回调和取消。
|
||||
- 最终文本持久化与工具审批。
|
||||
- 六个平台/架构验证。
|
||||
|
||||
### 阶段 3:首个云端原生 Adapter
|
||||
|
||||
- Main-only 凭据和引擎 Profile。
|
||||
- WebRTC 或 WebSocket 会话。
|
||||
- 转写、音频、截断、用量和 Provider 错误。
|
||||
- sideband 工具与审批。
|
||||
- 真实有费用的 gated 验证。
|
||||
|
||||
### 阶段 4:本地原生全双工 Adapter
|
||||
|
||||
- 选择一个中文质量、许可和硬件要求已验证的模型。
|
||||
- 建立受管 Sidecar、能力检测和真实会话测试。
|
||||
- 验证原生 Barge-in、文本提交、工具能力和长期稳定性。
|
||||
|
||||
### 阶段 5:扩展与质量
|
||||
|
||||
- 增加经过验证的云端和本地 Adapter。
|
||||
- 输出设备选择和企业语音策略。
|
||||
- 声学基准、延迟仪表盘和成本诊断。
|
||||
- 评估是否允许用户预配置仍需确认的显式替代策略。
|
||||
|
||||
---
|
||||
|
||||
## 21. 首个垂直切片
|
||||
|
||||
首个可合并实现应使用 Fake Voice Engine,不立即绑定某个云端 Provider:
|
||||
|
||||
1. 用户显式开始会话。
|
||||
2. AudioWorklet 持续采集和播放模拟流。
|
||||
3. Fake Engine 产生临时转写、助手文本和音频。
|
||||
4. 用户开口触发 150 ms 内静音和响应取消。
|
||||
5. 持久化已提交用户文本、已展示助手内容、`visual-only` Block 和实际播放边界;下一轮
|
||||
模型上下文只使用已播放文本与完整显示的 `visual-only` Block。
|
||||
6. 模拟工具审批时暂停语音,拒绝口头授权。
|
||||
7. 注入 Adapter 失败后明确结束,不切换任何引擎。
|
||||
8. 关闭窗口后所有 Track、Port、计时器和模拟任务归零。
|
||||
|
||||
该切片先验证最难改变的会话、音频、提交和安全契约,再分别接入本地和云端实现。
|
||||
|
||||
---
|
||||
|
||||
## 22. 验收标准
|
||||
|
||||
- 用户可以明确选择本地模块化、本地原生或云端原生引擎,界面持续显示当前选择。
|
||||
- 会话快照冻结 Provider、模型、地域、声音、数据位置和能力。
|
||||
- 任何引擎、Provider、模型、地域、Runtime 或模式变化都不能在测试中静默发生。
|
||||
- 助手播放期间继续采集麦克风,用户可在 P95 150 ms 内打断。
|
||||
- 中断后未播放音频与文本不进入下一轮上下文。
|
||||
- Ask 和 Execute 在语音中与文本中使用同一权限和审批边界。
|
||||
- 语音口令不能批准工具。
|
||||
- 云端长期凭据不进入 Renderer,本地 Sidecar 不监听外部地址。
|
||||
- 默认数据库、日志和 Artifact 中没有原始音频。
|
||||
- 本地引擎失败不连接云端,云端引擎失败不启动本地模型。
|
||||
- 重连只针对同一引擎快照,并在可感知时显示状态。
|
||||
- 六个平台目标完成各自声明能力的真实采集、播放、取消和资源回收验证。
|
||||
- `npm test`、`npm run typecheck`、`npm run lint` 和生产构建全部通过。
|
||||
|
||||
---
|
||||
|
||||
## 23. 参考
|
||||
|
||||
- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx):本地在线/离线 ASR、VAD 与 TTS。
|
||||
- [OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime):云端实时音频会话与 WebRTC/WebSocket。
|
||||
- [Gemini Live API](https://ai.google.dev/gemini-api/docs/live-api):云端双向实时音频与多模态会话。
|
||||
- [Qwen Realtime](https://help.aliyun.com/zh/model-studio/realtime):云端实时音视频输入与音频/文本输出。
|
||||
- [MiniCPM-o](https://github.com/OpenBMB/MiniCPM-V):本地端到端多模态与全双工候选。
|
||||
- [Moshi](https://github.com/kyutai-labs/moshi):本地原生全双工语音模型框架。
|
||||
- [PersonaPlex](https://github.com/NVIDIA/personaplex):本地可控角色与声音的全双工候选。
|
||||
- [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet):Renderer 低延迟音频处理基础。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ GoodBuddy 需要用同一条可信文档解析链路服务以下场景:
|
||||
- 后续的合同审阅、表格分析、演示文稿理解和文档转换。
|
||||
|
||||
文档解析不是对话模型的附属功能。它是主进程管理的独立基础能力,设置入口为“设置中心 / 文档解析”。
|
||||
本地 OCR 的模型下载源以
|
||||
[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
@@ -72,11 +74,12 @@ PDF 不是所有文档唯一的中间格式。解析应同时保留:
|
||||
OCR 模型区沿用语音模型管理模式:
|
||||
|
||||
- 应用不内置模型权重;
|
||||
- 用户按需从 ModelScope 下载,下载完成后离线使用;
|
||||
- 用户按需从全局选择的 ModelScope 或 Hugging Face 下载,默认 ModelScope,下载完成后
|
||||
离线使用;
|
||||
- 显示来源、语言、运行时、模型体积、安装与校验状态;
|
||||
- 联网设备可导出已安装模型 ZIP,离线或内网设备可直接导入;
|
||||
- 支持下载进度、取消、删除、ZIP 导入导出、打开模型仓库和受管目录;
|
||||
- “打开 ModelScope”直接显示在 OCR 模型卡片右上角,不使用手动导入折叠区;
|
||||
- “打开模型仓库”直接显示在 OCR 模型卡片右上角,并使用当前下载源对应仓库;
|
||||
- 模型操作即时生效,解析策略仍通过分类页头的“保存设置”提交。
|
||||
|
||||
### 4.1 第一阶段字段
|
||||
@@ -182,11 +185,14 @@ type ParsedDocument = {
|
||||
|
||||
### 7.2 下载与安装
|
||||
|
||||
Tiny、Small 和 Medium 模型均由 PaddlePaddle 官方 ModelScope 仓库提供。Small 是默认推荐档位;Medium 面向更高识别质量,但具有更高内存占用和延迟。每个档位的检测模型、识别模型与字符字典配置分别使用固定提交,并在应用内记录文件字节数和 SHA-256。
|
||||
Tiny、Small 和 Medium 模型使用 PaddlePaddle 发布的规范工件。GoodBuddy 为 ModelScope
|
||||
和 Hugging Face 分别维护固定下载 Target,默认使用 ModelScope。Small 是默认推荐档位;
|
||||
Medium 面向更高识别质量,但具有更高内存占用和延迟。每个档位的检测模型、识别模型与
|
||||
字符字典配置分别使用固定提交,并在应用内记录共同的文件字节数和 SHA-256。
|
||||
|
||||
下载流程:
|
||||
|
||||
1. 主进程从固定 ModelScope `resolve/<revision>/...` 地址读取文件;
|
||||
1. 主进程读取已保存的全局模型下载源,并解析该来源的固定 Target;
|
||||
2. 禁用凭据与缓存,限制重定向次数和单文件大小;
|
||||
3. 写入受管目录下的随机临时安装目录;
|
||||
4. 边下载边计算 SHA-256,并核对完整字节数;
|
||||
@@ -194,7 +200,9 @@ Tiny、Small 和 Medium 模型均由 PaddlePaddle 官方 ModelScope 仓库提供
|
||||
6. 原子重命名为正式模型目录;
|
||||
7. 失败、取消或退出时删除临时文件。
|
||||
|
||||
模型只在下载或用户显式打开仓库时访问网络。OCR 推理从受管目录读取已校验文件,不发起网络请求。
|
||||
单次任务只使用启动时冻结的一个来源。所选来源不可用或缺少任一必需文件时明确失败,
|
||||
不请求另一个来源。模型只在下载或用户显式打开仓库时访问网络。OCR 推理从受管目录读取
|
||||
已校验文件,不发起网络请求。
|
||||
|
||||
### 7.3 离线 ZIP 迁移
|
||||
|
||||
@@ -298,7 +306,8 @@ DOC、XLS、PPT 通过 `DocumentConversionProvider` 转换:
|
||||
- 新增文档解析设置分类和持久化契约;
|
||||
- 建立 `DocumentParsingService`,供聊天和知识库共用;
|
||||
- 将无文本 PDF 识别为可触发 OCR 的明确状态;
|
||||
- 接入 PP-OCRv6 Tiny、Small、Medium 的 ModelScope 下载、校验、ZIP 离线迁移、删除与 WASM Worker;
|
||||
- 接入 PP-OCRv6 Tiny、Small、Medium 的双来源下载、校验、ZIP 离线迁移、删除与
|
||||
WASM Worker;
|
||||
- 实现真实文件测试和六平台验证入口。
|
||||
|
||||
### 阶段二
|
||||
@@ -322,7 +331,8 @@ DOC、XLS、PPT 通过 `DocumentConversionProvider` 转换:
|
||||
- 模型文件损坏时拒绝加载并显示可恢复错误;
|
||||
- 未安装模型时扫描文档提示用户前往“文档解析”下载,文本型文档仍可原生解析;
|
||||
- 下载中可显示文件与总进度并允许取消,失败或取消后不留下已安装状态;
|
||||
- ModelScope 下载与 ZIP 导入均经过同一大小和 SHA-256 校验;
|
||||
- ModelScope、Hugging Face 下载与 ZIP 导入均经过同一大小和 SHA-256 校验;
|
||||
- 所选下载源失败或缺少模型时不会请求另一个来源;
|
||||
- 语音和 OCR 模型可在联网设备导出 ZIP,并在离线设备导入后完成真实推理;
|
||||
- 路径穿越、未知条目、错误模型 ID、篡改文件和超限 ZIP 均被拒绝;
|
||||
- 超页数、超时、取消和关闭不会留下运行任务;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
| 日期 | 2026-08-11 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
|
||||
| 相关设计 | [本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
@@ -38,7 +39,8 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
3. 保留“模型按需检索”,并新增“每次先检索”模式。后者必须由 Main 进程
|
||||
预检索,不能只依赖提示词要求模型调用工具。
|
||||
4. 知识库新建后不默认启用全部已有知识库;对话中的范围继续由用户显式选择。
|
||||
5. 向量服务不可用时保留全文检索,但必须返回明确降级状态。
|
||||
5. 向量服务不可用时保留全文与中文检索,但必须返回明确降级状态。这是可见的检索通道
|
||||
降级,不得自动切换应用托管模型、Ollama、云端 Provider 或其他向量模型。
|
||||
6. 中文召回使用应用内可控的 CJK n-gram 索引,不新增远程服务依赖。
|
||||
7. 混合检索保留 RRF 候选融合,并增加本地确定性重排、可选的
|
||||
Cohere/Jina 兼容学习型重排、最低相关度和上下文预算。学习型重排失败时
|
||||
@@ -46,7 +48,9 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
8. 向量搜索取消 5,000 分块静默失效,使用有界内存的分页扫描。在没有稳定
|
||||
跨平台向量扩展前,接受本地 CPU 线性扫描,并持续显示性能诊断。
|
||||
9. 向量索引兼容性同时校验 Provider、Model、维度和 Provider Fingerprint。
|
||||
同名模型切换端点后,旧向量不能继续参与召回。
|
||||
同名模型切换端点后,旧向量不能继续参与召回。Fingerprint 的完整模型、编码与
|
||||
数据路径定义以[本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md)
|
||||
为准。
|
||||
10. 失败或取消的重建不能停用上一版已就绪索引。新索引只有完整校验成功后才
|
||||
原子替换当前服务版本。
|
||||
11. 分块设置属于知识库,修改后不会伪装为立即生效。用户需要显式重建索引。
|
||||
@@ -156,6 +160,11 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
|
||||
工作台,不新增第五个一级页签。
|
||||
|
||||
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。应用托管本地模型、
|
||||
用户自行安装的 Ollama/自托管服务和云端兼容服务的界面、数据路径及切换语义以
|
||||
[本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md)
|
||||
为准,知识库页面只显示当前模型、索引兼容性、覆盖率和重建操作。
|
||||
|
||||
对话输入区的知识范围弹层包含:
|
||||
|
||||
1. 已启用知识库多选。
|
||||
@@ -495,7 +504,7 @@ type KnowledgeRetrievalResponse = {
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 向量查询失败 | 继续全文和图谱检索,显示降级原因 |
|
||||
| 向量查询失败 | 继续已配置的全文、中文和图谱通道,显示降级原因,不切换向量 Provider 或模型 |
|
||||
| 部分文档无向量 | 使用可用文档,显示完成数和失败数 |
|
||||
| CJK 索引迁移失败 | 回滚迁移,不损坏旧 FTS |
|
||||
| 重排失败 | 回退 RRF 排序并显示诊断 |
|
||||
@@ -558,6 +567,7 @@ GoodBuddy 不上传私人检索查询或文档内容。本地诊断至少记录
|
||||
- “每次先检索”在 Runtime 启动前产生检索诊断和引用,即使模型未调用工具。
|
||||
- 未配置向量模型时,中文改写问题仍能通过 CJK 索引召回相关分块。
|
||||
- 向量查询失败时回答可继续,界面明确显示已降级。
|
||||
- 应用托管模型、Ollama 和云端向量连接之间不会自动切换;实际数据路径持续可见。
|
||||
- 10,000 个分块的向量测试能够返回正确 Top K,不出现固定上限空结果。
|
||||
- 同名模型切换端点后,不会读取 Fingerprint 不匹配的旧向量。
|
||||
- 重建失败时,上一版已就绪向量仍能继续召回。
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -70,14 +71,16 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 6,
|
||||
version: 7,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -102,6 +105,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -124,6 +128,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -146,6 +151,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -168,6 +174,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -194,9 +201,10 @@ describe('ApplicationSettingsStore', () => {
|
||||
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
||||
).resolves.toBe('0.8.18')
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 6,
|
||||
version: 7,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'narrative',
|
||||
@@ -222,6 +230,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'structured'
|
||||
@@ -231,6 +240,39 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lazily migrates version 6 to the default ModelScope source', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const versionSix = {
|
||||
version: 6,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'structured',
|
||||
lastSeenReleaseNotesVersion: '0.8.18'
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'structured'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
|
||||
versionSix
|
||||
)
|
||||
|
||||
await store.update({ modelDownloadSource: 'hugging-face' })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...versionSix,
|
||||
version: 7,
|
||||
modelDownloadSource: 'hugging-face'
|
||||
})
|
||||
})
|
||||
|
||||
it('strictly rejects incomplete full settings', () => {
|
||||
for (const input of [
|
||||
{},
|
||||
@@ -254,6 +296,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
for (const input of [
|
||||
{},
|
||||
{ checkUpdatesOnStartup: 'true' },
|
||||
{ modelDownloadSource: 'automatic' },
|
||||
{ anotherSetting: true },
|
||||
null
|
||||
]) {
|
||||
@@ -274,6 +317,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -387,14 +431,16 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 6,
|
||||
version: 7,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -419,6 +465,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -20,7 +20,7 @@ export {
|
||||
} from '../shared/application-settings-contracts'
|
||||
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
||||
|
||||
const CURRENT_SETTINGS_VERSION = 6
|
||||
const CURRENT_SETTINGS_VERSION = 7
|
||||
|
||||
const legacyStoredApplicationSettingsSchema = z
|
||||
.object({
|
||||
@@ -47,20 +47,28 @@ const versionThreeStoredApplicationSettingsSchema = z
|
||||
.strict()
|
||||
|
||||
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||
.omit({ updateSource: true })
|
||||
.omit({ updateSource: true, modelDownloadSource: true })
|
||||
.extend({
|
||||
version: z.literal(4)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||
.omit({ updateSource: true })
|
||||
.omit({ updateSource: true, modelDownloadSource: true })
|
||||
.extend({
|
||||
version: z.literal(5),
|
||||
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||
.omit({ modelDownloadSource: true })
|
||||
.extend({
|
||||
version: z.literal(6),
|
||||
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||
.extend({
|
||||
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||
@@ -75,6 +83,7 @@ type StoredApplicationSettings = z.infer<
|
||||
export const defaultApplicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -131,13 +140,24 @@ export class ApplicationSettingsStore {
|
||||
)
|
||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const versionSixResult =
|
||||
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionSixResult.success) {
|
||||
this.settings = {
|
||||
...versionSixResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
modelDownloadSource: 'modelscope'
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
const versionFiveResult =
|
||||
versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionFiveResult.success) {
|
||||
this.settings = {
|
||||
...versionFiveResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github'
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope'
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
@@ -148,6 +168,7 @@ export class ApplicationSettingsStore {
|
||||
...versionFourResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
lastSeenReleaseNotesVersion: null
|
||||
}
|
||||
return this.settings
|
||||
@@ -159,6 +180,7 @@ export class ApplicationSettingsStore {
|
||||
...versionThreeResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
lastSeenReleaseNotesVersion: null
|
||||
}
|
||||
@@ -171,6 +193,7 @@ export class ApplicationSettingsStore {
|
||||
...versionTwoResult.data,
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
lastSeenReleaseNotesVersion: null
|
||||
@@ -185,6 +208,7 @@ export class ApplicationSettingsStore {
|
||||
checkUpdatesOnStartup:
|
||||
legacyResult.data.checkUpdatesOnStartup,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -225,6 +249,7 @@ export class ApplicationSettingsStore {
|
||||
return {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
updateSource: stored.updateSource,
|
||||
modelDownloadSource: stored.modelDownloadSource,
|
||||
magicNotesEnabled: stored.magicNotesEnabled,
|
||||
magicNoteCommentMode: stored.magicNoteCommentMode,
|
||||
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
||||
@@ -257,6 +282,7 @@ export class ApplicationSettingsStore {
|
||||
return {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
updateSource: next.updateSource,
|
||||
modelDownloadSource: next.modelDownloadSource,
|
||||
magicNotesEnabled: next.magicNotesEnabled,
|
||||
magicNoteCommentMode: next.magicNoteCommentMode,
|
||||
magicNoteCommentFormat: next.magicNoteCommentFormat
|
||||
|
||||
@@ -2,19 +2,66 @@ import {
|
||||
documentOcrModelCatalogEntrySchema,
|
||||
type DocumentOcrModelCatalogEntry
|
||||
} from '../shared/document-parsing-contracts'
|
||||
import {
|
||||
huggingFaceTarget,
|
||||
modelScopeTarget
|
||||
} from './model-download-targets'
|
||||
|
||||
const detectionRevision =
|
||||
'7d7f5d128d9309ebf6de4f21f404dd583afdbae3'
|
||||
const recognitionRevision =
|
||||
'afba04b618200c5f4824531c6e42c957c6439d9a'
|
||||
const smallDetectionRevision =
|
||||
'956a0b620a4017cc04056c692be1703b0025d028'
|
||||
const smallRecognitionRevision =
|
||||
'296d43bc0ebced0fd9c605174aa5962e49810ab6'
|
||||
const mediumDetectionRevision =
|
||||
'c317b40325be40bfaaff58c8dcece2a075294f8a'
|
||||
const mediumRecognitionRevision =
|
||||
'db5d610d492a14e3c34dc1fd4e9339bd369f79e6'
|
||||
const repositories = {
|
||||
tinyDetection: 'PaddlePaddle/PP-OCRv6_tiny_det_onnx',
|
||||
tinyRecognition: 'PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
|
||||
smallDetection: 'PaddlePaddle/PP-OCRv6_small_det_onnx',
|
||||
smallRecognition: 'PaddlePaddle/PP-OCRv6_small_rec_onnx',
|
||||
mediumDetection: 'PaddlePaddle/PP-OCRv6_medium_det_onnx',
|
||||
mediumRecognition: 'PaddlePaddle/PP-OCRv6_medium_rec_onnx'
|
||||
} as const
|
||||
|
||||
const modelScopeRevisions = {
|
||||
tinyDetection: '7d7f5d128d9309ebf6de4f21f404dd583afdbae3',
|
||||
tinyRecognition: 'afba04b618200c5f4824531c6e42c957c6439d9a',
|
||||
smallDetection: '956a0b620a4017cc04056c692be1703b0025d028',
|
||||
smallRecognition: '296d43bc0ebced0fd9c605174aa5962e49810ab6',
|
||||
mediumDetection: 'c317b40325be40bfaaff58c8dcece2a075294f8a',
|
||||
mediumRecognition: 'db5d610d492a14e3c34dc1fd4e9339bd369f79e6'
|
||||
} as const
|
||||
|
||||
const huggingFaceRevisions = {
|
||||
tinyDetection: '2ba1506c0380b8f0b03dd142459aac66d4421f6c',
|
||||
tinyRecognition: '2612ab37152ae0a677521bae4e1e3d4fb4cf7c30',
|
||||
smallDetection: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62',
|
||||
smallRecognition: 'b8f84f0b80c529de40b4fbb3544b84fa7233a513',
|
||||
mediumDetection: '61323801669c338b7891481ec7bac61ce31b576a',
|
||||
mediumRecognition: '50c7eacafc52fa7bcf4194e8cd08e46f8558504b'
|
||||
} as const
|
||||
|
||||
type RepositoryKey = keyof typeof repositories
|
||||
|
||||
function targets(
|
||||
repositoryKey: RepositoryKey,
|
||||
file: string
|
||||
) {
|
||||
const repository = repositories[repositoryKey]
|
||||
return {
|
||||
modelscope: modelScopeTarget(
|
||||
repository,
|
||||
modelScopeRevisions[repositoryKey],
|
||||
file
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
repository,
|
||||
huggingFaceRevisions[repositoryKey],
|
||||
file
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryUrls(repositoryKey: RepositoryKey) {
|
||||
const repository = repositories[repositoryKey]
|
||||
return {
|
||||
modelscope: `https://modelscope.cn/models/${repository}`,
|
||||
'hugging-face': `https://huggingface.co/${repository}`
|
||||
}
|
||||
}
|
||||
|
||||
export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] =
|
||||
documentOcrModelCatalogEntrySchema.array().parse([
|
||||
@@ -28,54 +75,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
|
||||
quality: 'basic',
|
||||
speed: 'fast',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_tiny_rec_onnx',
|
||||
repositoryUrls: repositoryUrls('tinyRecognition'),
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice:
|
||||
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
|
||||
'检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
|
||||
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: 'detection.onnx',
|
||||
role: 'detection',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_tiny_det_onnx/resolve/' +
|
||||
`${detectionRevision}/inference.onnx`,
|
||||
size: 1_780_590,
|
||||
sha256:
|
||||
'193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8'
|
||||
}
|
||||
size: 1_780_590,
|
||||
sha256:
|
||||
'193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8',
|
||||
targets: targets('tinyDetection', 'inference.onnx')
|
||||
},
|
||||
{
|
||||
name: 'recognition.onnx',
|
||||
role: 'recognition',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_tiny_rec_onnx/resolve/' +
|
||||
`${recognitionRevision}/inference.onnx`,
|
||||
size: 4_462_639,
|
||||
sha256:
|
||||
'9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6'
|
||||
}
|
||||
size: 4_462_639,
|
||||
sha256:
|
||||
'9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6',
|
||||
targets: targets('tinyRecognition', 'inference.onnx')
|
||||
},
|
||||
{
|
||||
name: 'dictionary.yml',
|
||||
role: 'dictionary',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_tiny_rec_onnx/resolve/' +
|
||||
`${recognitionRevision}/inference.yml`,
|
||||
size: 55_571,
|
||||
sha256:
|
||||
'66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1'
|
||||
}
|
||||
size: 55_571,
|
||||
sha256:
|
||||
'66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1',
|
||||
targets: targets('tinyRecognition', 'inference.yml')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -89,54 +119,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
|
||||
quality: 'balanced',
|
||||
speed: 'balanced',
|
||||
recommended: true,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_small_rec_onnx',
|
||||
repositoryUrls: repositoryUrls('smallRecognition'),
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice:
|
||||
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
|
||||
'检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
|
||||
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: 'detection.onnx',
|
||||
role: 'detection',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_small_det_onnx/resolve/' +
|
||||
`${smallDetectionRevision}/inference.onnx`,
|
||||
size: 9_880_512,
|
||||
sha256:
|
||||
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e'
|
||||
}
|
||||
size: 9_880_512,
|
||||
sha256:
|
||||
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e',
|
||||
targets: targets('smallDetection', 'inference.onnx')
|
||||
},
|
||||
{
|
||||
name: 'recognition.onnx',
|
||||
role: 'recognition',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_small_rec_onnx/resolve/' +
|
||||
`${smallRecognitionRevision}/inference.onnx`,
|
||||
size: 21_159_378,
|
||||
sha256:
|
||||
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
|
||||
}
|
||||
size: 21_159_378,
|
||||
sha256:
|
||||
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634',
|
||||
targets: targets('smallRecognition', 'inference.onnx')
|
||||
},
|
||||
{
|
||||
name: 'dictionary.yml',
|
||||
role: 'dictionary',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_small_rec_onnx/resolve/' +
|
||||
`${smallRecognitionRevision}/inference.yml`,
|
||||
size: 150_579,
|
||||
sha256:
|
||||
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
|
||||
}
|
||||
size: 150_579,
|
||||
sha256:
|
||||
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1',
|
||||
targets: targets('smallRecognition', 'inference.yml')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -150,54 +163,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
|
||||
quality: 'high',
|
||||
speed: 'slow',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_medium_rec_onnx',
|
||||
repositoryUrls: repositoryUrls('mediumRecognition'),
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice:
|
||||
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
|
||||
'检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
|
||||
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: 'detection.onnx',
|
||||
role: 'detection',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_medium_det_onnx/resolve/' +
|
||||
`${mediumDetectionRevision}/inference.onnx`,
|
||||
size: 62_032_837,
|
||||
sha256:
|
||||
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
|
||||
}
|
||||
size: 62_032_837,
|
||||
sha256:
|
||||
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1',
|
||||
targets: targets('mediumDetection', 'inference.onnx')
|
||||
},
|
||||
{
|
||||
name: 'recognition.onnx',
|
||||
role: 'recognition',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_medium_rec_onnx/resolve/' +
|
||||
`${mediumRecognitionRevision}/inference.onnx`,
|
||||
size: 76_554_979,
|
||||
sha256:
|
||||
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
|
||||
}
|
||||
size: 76_554_979,
|
||||
sha256:
|
||||
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba',
|
||||
targets: targets('mediumRecognition', 'inference.onnx')
|
||||
},
|
||||
{
|
||||
name: 'dictionary.yml',
|
||||
role: 'dictionary',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/PaddlePaddle/' +
|
||||
'PP-OCRv6_medium_rec_onnx/resolve/' +
|
||||
`${mediumRecognitionRevision}/inference.yml`,
|
||||
size: 150_580,
|
||||
sha256:
|
||||
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
|
||||
}
|
||||
size: 150_580,
|
||||
sha256:
|
||||
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129',
|
||||
targets: targets('mediumRecognition', 'inference.yml')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -70,8 +70,12 @@ function catalog(
|
||||
quality: 'balanced',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
'https://modelscope.cn/models/example/test-model',
|
||||
'hugging-face':
|
||||
'https://huggingface.co/example/test-model'
|
||||
},
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice: 'Test license notice.',
|
||||
@@ -80,10 +84,27 @@ function catalog(
|
||||
files: files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
download: {
|
||||
url: `https://modelscope.cn/models/example/resolve/revision/${file.name}`,
|
||||
size: file.bytes.byteLength,
|
||||
sha256: sha256(file.bytes)
|
||||
size: file.bytes.byteLength,
|
||||
sha256: sha256(file.bytes),
|
||||
targets: {
|
||||
modelscope: {
|
||||
url:
|
||||
'https://modelscope.cn/models/example/test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/${file.name}`,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/example/test-model',
|
||||
revision: 'a'.repeat(40),
|
||||
redirectHosts: []
|
||||
},
|
||||
'hugging-face': {
|
||||
url:
|
||||
'https://huggingface.co/example/test-model/' +
|
||||
`resolve/${'b'.repeat(40)}/${file.name}`,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/example/test-model',
|
||||
revision: 'b'.repeat(40),
|
||||
redirectHosts: []
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -124,10 +145,12 @@ async function createManager(
|
||||
throw new Error('Test OCR catalog is empty')
|
||||
}
|
||||
const files = new Map(
|
||||
entry.files.map((file) => [
|
||||
file.download.url,
|
||||
modelBytes[file.role]
|
||||
])
|
||||
entry.files.flatMap((file) =>
|
||||
Object.values(file.targets).map((target) => [
|
||||
target.url,
|
||||
modelBytes[file.role]
|
||||
] as const)
|
||||
)
|
||||
)
|
||||
const transport = vi.fn(async (input: string | URL | Request) => {
|
||||
const url =
|
||||
@@ -174,7 +197,7 @@ describe('DocumentOcrModelManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses immutable SHA-256 verified ModelScope catalog files', () => {
|
||||
it('uses immutable byte-identical ModelScope and Hugging Face files', () => {
|
||||
expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3)
|
||||
expect(
|
||||
new Set(DOCUMENT_OCR_MODEL_CATALOG.map((entry) => entry.id)).size
|
||||
@@ -186,20 +209,28 @@ describe('DocumentOcrModelManager', () => {
|
||||
).toEqual(['pp-ocrv6-small'])
|
||||
|
||||
for (const entry of DOCUMENT_OCR_MODEL_CATALOG) {
|
||||
expect(entry.repositoryUrls.modelscope).toMatch(
|
||||
/^https:\/\/modelscope\.cn\/models\/PaddlePaddle\//u
|
||||
)
|
||||
expect(entry.repositoryUrls['hugging-face']).toMatch(
|
||||
/^https:\/\/huggingface\.co\/PaddlePaddle\//u
|
||||
)
|
||||
for (const file of entry.files) {
|
||||
expect(file.download.url).toMatch(
|
||||
expect(file.targets.modelscope?.url).toMatch(
|
||||
/^https:\/\/modelscope\.cn\/models\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
|
||||
)
|
||||
expect(file.download.sha256).toMatch(/^[a-f0-9]{64}$/u)
|
||||
expect(file.download.size).toBeGreaterThan(0)
|
||||
expect(file.targets['hugging-face']?.url).toMatch(
|
||||
/^https:\/\/huggingface\.co\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
|
||||
)
|
||||
expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u)
|
||||
expect(file.size).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
DOCUMENT_OCR_MODEL_CATALOG.find(
|
||||
(entry) => entry.id === 'pp-ocrv6-small'
|
||||
)
|
||||
).toMatchObject({
|
||||
const small = DOCUMENT_OCR_MODEL_CATALOG.find(
|
||||
(entry) => entry.id === 'pp-ocrv6-small'
|
||||
)
|
||||
expect(small).toMatchObject({
|
||||
languages: ['50 种语言'],
|
||||
quality: 'balanced',
|
||||
speed: 'balanced',
|
||||
@@ -207,30 +238,29 @@ describe('DocumentOcrModelManager', () => {
|
||||
files: [
|
||||
{
|
||||
role: 'detection',
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/956a0b620a4017cc04056c692be1703b0025d028/inference.onnx',
|
||||
size: 9_880_512,
|
||||
sha256:
|
||||
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e'
|
||||
size: 9_880_512,
|
||||
sha256:
|
||||
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e',
|
||||
targets: {
|
||||
modelscope: {
|
||||
revision: '956a0b620a4017cc04056c692be1703b0025d028'
|
||||
},
|
||||
'hugging-face': {
|
||||
revision: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: 'recognition',
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.onnx',
|
||||
size: 21_159_378,
|
||||
sha256:
|
||||
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
|
||||
}
|
||||
size: 21_159_378,
|
||||
sha256:
|
||||
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
|
||||
},
|
||||
{
|
||||
role: 'dictionary',
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.yml',
|
||||
size: 150_579,
|
||||
sha256:
|
||||
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
|
||||
}
|
||||
size: 150_579,
|
||||
sha256:
|
||||
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -246,30 +276,21 @@ describe('DocumentOcrModelManager', () => {
|
||||
files: [
|
||||
{
|
||||
role: 'detection',
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_det_onnx/resolve/c317b40325be40bfaaff58c8dcece2a075294f8a/inference.onnx',
|
||||
size: 62_032_837,
|
||||
sha256:
|
||||
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
|
||||
}
|
||||
size: 62_032_837,
|
||||
sha256:
|
||||
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
|
||||
},
|
||||
{
|
||||
role: 'recognition',
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.onnx',
|
||||
size: 76_554_979,
|
||||
sha256:
|
||||
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
|
||||
}
|
||||
size: 76_554_979,
|
||||
sha256:
|
||||
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
|
||||
},
|
||||
{
|
||||
role: 'dictionary',
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.yml',
|
||||
size: 150_580,
|
||||
sha256:
|
||||
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
|
||||
}
|
||||
size: 150_580,
|
||||
sha256:
|
||||
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -296,6 +317,58 @@ describe('DocumentOcrModelManager', () => {
|
||||
expect(new TextDecoder().decode(assets.dictionary)).toContain(
|
||||
"!\n\"\n'\n"
|
||||
)
|
||||
const snapshot = await manager.getSnapshot()
|
||||
expect(snapshot.selectedDownloadSource).toBe('modelscope')
|
||||
expect(snapshot.catalog[0]?.files[0]).not.toHaveProperty('targets')
|
||||
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/')
|
||||
})
|
||||
|
||||
it('downloads the same canonical package from Hugging Face', async () => {
|
||||
const { manager } = await createManager()
|
||||
|
||||
await expect(
|
||||
manager.install('pp-ocrv6-tiny', 'hugging-face')
|
||||
).resolves.toMatchObject({
|
||||
id: 'pp-ocrv6-tiny',
|
||||
source: 'download'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not request another source when selected coverage is missing', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-document-ocr-model-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const detection = Buffer.from('detection')
|
||||
const recognition = Buffer.from('recognition')
|
||||
const dictionary = dictionaryYaml()
|
||||
const sourceCatalog = catalog(
|
||||
detection,
|
||||
recognition,
|
||||
dictionary
|
||||
).map((entry) => ({
|
||||
...entry,
|
||||
repositoryUrls: {
|
||||
modelscope: entry.repositoryUrls.modelscope
|
||||
},
|
||||
files: entry.files.map((file) => ({
|
||||
...file,
|
||||
targets: {
|
||||
modelscope: file.targets.modelscope
|
||||
}
|
||||
}))
|
||||
}))
|
||||
const transport = vi.fn<typeof fetch>()
|
||||
const manager = new DocumentOcrModelManager({
|
||||
userDataDirectory: directory,
|
||||
fetch: transport,
|
||||
catalog: sourceCatalog
|
||||
})
|
||||
|
||||
await expect(
|
||||
manager.install('pp-ocrv6-tiny', 'hugging-face')
|
||||
).rejects.toThrow('当前下载源')
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an imported model whose hash does not match', async () => {
|
||||
|
||||
@@ -15,26 +15,35 @@ import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
documentOcrAssetsSchema,
|
||||
documentOcrModelCatalogEntrySchema,
|
||||
documentOcrModelCatalogViewEntrySchema,
|
||||
documentOcrModelSnapshotSchema,
|
||||
documentParsingModelStatusSchema,
|
||||
installedDocumentOcrModelSchema,
|
||||
localOcrModelIdSchema,
|
||||
type DocumentOcrAssets,
|
||||
type DocumentOcrModelCatalogEntry,
|
||||
type DocumentOcrModelCatalogViewEntry,
|
||||
type DocumentOcrModelFile,
|
||||
type DocumentOcrModelOperation,
|
||||
type DocumentOcrModelSnapshot,
|
||||
type InstalledDocumentOcrModel
|
||||
} from '../shared/document-parsing-contracts'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
getModelDownloadAvailability,
|
||||
resolveModelDownloadPackage,
|
||||
type ModelDownloadSource,
|
||||
type ResolvedModelArtifactFile
|
||||
} from '../shared/model-download-contracts'
|
||||
import { DOCUMENT_OCR_MODEL_CATALOG } from './document-ocr-model-catalog'
|
||||
import {
|
||||
exportModelArchive,
|
||||
extractModelArchive
|
||||
} from './model-archive'
|
||||
import { fetchModelDownloadResponse } from './model-download-transport'
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024
|
||||
const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
const MAX_REDIRECTS = 3
|
||||
const PARTIAL_SUFFIX = '.partial'
|
||||
const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
|
||||
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
|
||||
@@ -50,6 +59,9 @@ export type DocumentOcrModelManagerOptions = {
|
||||
userDataDirectory: string
|
||||
fetch: typeof fetch
|
||||
catalog?: readonly DocumentOcrModelCatalogEntry[]
|
||||
getDownloadSource?: () =>
|
||||
| ModelDownloadSource
|
||||
| Promise<ModelDownloadSource>
|
||||
maxFileBytes?: number
|
||||
}
|
||||
|
||||
@@ -69,6 +81,23 @@ function cloneCatalogEntry(
|
||||
return documentOcrModelCatalogEntrySchema.parse(entry)
|
||||
}
|
||||
|
||||
function toCatalogView(entry: DocumentOcrModelCatalogEntry) {
|
||||
const { repositoryUrls, files, ...metadata } = entry
|
||||
void repositoryUrls
|
||||
return documentOcrModelCatalogViewEntrySchema.parse({
|
||||
...metadata,
|
||||
files: files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
})),
|
||||
downloadAvailability: MODEL_DOWNLOAD_SOURCES.map((source) =>
|
||||
getModelDownloadAvailability(files, source)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function safeChild(parent: string, name: string): string {
|
||||
const child = resolve(parent, name)
|
||||
if (dirname(child) !== resolve(parent)) {
|
||||
@@ -77,14 +106,6 @@ function safeChild(parent: string, name: string): string {
|
||||
return child
|
||||
}
|
||||
|
||||
function validateDownloadUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('OCR 模型下载地址必须使用 HTTP 或 HTTPS')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return Uint8Array.from(buffer).buffer
|
||||
}
|
||||
@@ -157,6 +178,10 @@ export class DocumentOcrModelManager {
|
||||
|
||||
private readonly transport: typeof fetch
|
||||
private readonly catalog: DocumentOcrModelCatalogEntry[]
|
||||
private readonly catalogViews: DocumentOcrModelCatalogViewEntry[]
|
||||
private readonly getDownloadSource: () =>
|
||||
| ModelDownloadSource
|
||||
| Promise<ModelDownloadSource>
|
||||
private readonly maxFileBytes: number
|
||||
private readonly operations = new Map<string, ActiveOperation>()
|
||||
private readonly verifiedModels = new Map<string, Promise<void>>()
|
||||
@@ -171,6 +196,8 @@ export class DocumentOcrModelManager {
|
||||
'document-ocr'
|
||||
)
|
||||
this.transport = options.fetch
|
||||
this.getDownloadSource =
|
||||
options.getDownloadSource ?? (() => 'modelscope')
|
||||
this.catalog = (options.catalog ?? DOCUMENT_OCR_MODEL_CATALOG).map(
|
||||
cloneCatalogEntry
|
||||
)
|
||||
@@ -180,6 +207,7 @@ export class DocumentOcrModelManager {
|
||||
) {
|
||||
throw new Error('OCR 模型目录包含重复 ID')
|
||||
}
|
||||
this.catalogViews = this.catalog.map(toCatalogView)
|
||||
this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
|
||||
if (
|
||||
!Number.isSafeInteger(this.maxFileBytes) ||
|
||||
@@ -192,10 +220,15 @@ export class DocumentOcrModelManager {
|
||||
|
||||
async getSnapshot(): Promise<DocumentOcrModelSnapshot> {
|
||||
await this.ensureRoot()
|
||||
const [selectedDownloadSource, installed] = await Promise.all([
|
||||
this.getDownloadSource(),
|
||||
this.readInstalled()
|
||||
])
|
||||
return documentOcrModelSnapshotSchema.parse({
|
||||
rootDirectory: this.rootDirectory,
|
||||
catalog: this.catalog.map(cloneCatalogEntry),
|
||||
installed: await this.readInstalled(),
|
||||
selectedDownloadSource,
|
||||
catalog: this.catalogViews,
|
||||
installed,
|
||||
operations: [...this.operations.values()].map((operation) => ({
|
||||
...operation.progress
|
||||
}))
|
||||
@@ -234,7 +267,7 @@ export class DocumentOcrModelManager {
|
||||
available: false,
|
||||
verified: false,
|
||||
runtime: entry.runtime,
|
||||
detail: '模型尚未安装或校验失败,请从 ModelScope 下载'
|
||||
detail: '模型尚未安装或校验失败,请从当前模型下载源获取'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -243,19 +276,37 @@ export class DocumentOcrModelManager {
|
||||
return this.loadVerifiedAssets(this.requireCatalogEntry(modelId))
|
||||
}
|
||||
|
||||
getRepositoryUrl(
|
||||
modelId: string,
|
||||
source: ModelDownloadSource
|
||||
): string {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
resolveModelDownloadPackage(entry.files, source)
|
||||
const repositoryUrl = entry.repositoryUrls[source]
|
||||
if (!repositoryUrl) {
|
||||
throw new Error('当前下载源暂不提供此 OCR 模型的仓库')
|
||||
}
|
||||
return repositoryUrl
|
||||
}
|
||||
|
||||
async install(
|
||||
modelId: string,
|
||||
downloadSource?: ModelDownloadSource,
|
||||
externalSignal?: AbortSignal
|
||||
): Promise<InstalledDocumentOcrModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const totalBytes = entry.files.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
0
|
||||
const selectedDownloadSource =
|
||||
downloadSource ?? (await this.getDownloadSource())
|
||||
const resolvedPackage = resolveModelDownloadPackage(
|
||||
entry.files,
|
||||
selectedDownloadSource
|
||||
)
|
||||
const operation = this.beginOperation(
|
||||
entry.id,
|
||||
'download',
|
||||
resolvedPackage.totalBytes,
|
||||
resolvedPackage.source
|
||||
)
|
||||
if (!Number.isSafeInteger(totalBytes)) {
|
||||
throw new RangeError('OCR 模型总大小超出安全范围')
|
||||
}
|
||||
const operation = this.beginOperation(entry.id, 'download', totalBytes)
|
||||
const detachAbort = this.attachExternalSignal(
|
||||
externalSignal,
|
||||
operation.controller
|
||||
@@ -265,7 +316,7 @@ export class DocumentOcrModelManager {
|
||||
await this.ensureRoot()
|
||||
await this.assertNotInstalled(entry.id)
|
||||
stagingDirectory = await this.createStagingDirectory(entry.id)
|
||||
for (const file of entry.files) {
|
||||
for (const file of resolvedPackage.files) {
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
operation.progress.phase = 'transferring'
|
||||
operation.progress.currentFile = file.name
|
||||
@@ -376,8 +427,8 @@ export class DocumentOcrModelManager {
|
||||
)
|
||||
if (
|
||||
!recorded ||
|
||||
recorded.size !== expected.download.size ||
|
||||
recorded.sha256 !== expected.download.sha256
|
||||
recorded.size !== expected.size ||
|
||||
recorded.sha256 !== expected.sha256
|
||||
) {
|
||||
throw new Error(`OCR 模型文件校验失败:${expected.name}`)
|
||||
}
|
||||
@@ -406,7 +457,7 @@ export class DocumentOcrModelManager {
|
||||
): Promise<InstalledDocumentOcrModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const expectedTotal = entry.files.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
const operation = this.beginOperation(
|
||||
@@ -448,8 +499,8 @@ export class DocumentOcrModelManager {
|
||||
)
|
||||
if (
|
||||
!archived ||
|
||||
archived.size !== expected.download.size ||
|
||||
archived.sha256 !== expected.download.sha256
|
||||
archived.size !== expected.size ||
|
||||
archived.sha256 !== expected.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`OCR 模型 ZIP 与当前模型目录不匹配:${expected.name}`
|
||||
@@ -536,7 +587,8 @@ export class DocumentOcrModelManager {
|
||||
private beginOperation(
|
||||
modelId: string,
|
||||
kind: DocumentOcrModelOperation['kind'],
|
||||
totalBytes: number | null
|
||||
totalBytes: number | null,
|
||||
downloadSource?: ModelDownloadSource
|
||||
): ActiveOperation {
|
||||
if (this.operations.has(modelId)) {
|
||||
throw new Error('该 OCR 模型已有进行中的操作')
|
||||
@@ -549,7 +601,8 @@ export class DocumentOcrModelManager {
|
||||
phase: 'preparing',
|
||||
currentFile: null,
|
||||
completedBytes: 0,
|
||||
totalBytes
|
||||
totalBytes,
|
||||
...(downloadSource ? { downloadSource } : {})
|
||||
}
|
||||
}
|
||||
this.operations.set(modelId, operation)
|
||||
@@ -597,50 +650,22 @@ export class DocumentOcrModelManager {
|
||||
return directory
|
||||
}
|
||||
|
||||
private async fetchFollowingRedirects(
|
||||
initialUrl: string,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
let url = validateDownloadUrl(initialUrl)
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
ensureNotAborted(signal)
|
||||
const response = await this.transport(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
signal
|
||||
})
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error('OCR 模型下载重定向次数过多')
|
||||
}
|
||||
const location = response.headers.get('location')
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
if (!location) {
|
||||
throw new Error('OCR 模型下载重定向缺少地址')
|
||||
}
|
||||
url = validateDownloadUrl(new URL(location, url).toString())
|
||||
continue
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(
|
||||
file: DocumentOcrModelFile,
|
||||
file: ResolvedModelArtifactFile<DocumentOcrModelFile['role']>,
|
||||
destination: string,
|
||||
operation: ActiveOperation,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (file.download.size > this.maxFileBytes) {
|
||||
if (file.size > this.maxFileBytes) {
|
||||
throw new RangeError(`OCR 模型文件过大:${file.name}`)
|
||||
}
|
||||
const response = await this.fetchFollowingRedirects(
|
||||
file.download.url,
|
||||
signal
|
||||
)
|
||||
const response = await fetchModelDownloadResponse({
|
||||
transport: this.transport,
|
||||
initialUrl: file.target.url,
|
||||
redirectHosts: file.target.redirectHosts,
|
||||
signal,
|
||||
modelLabel: 'OCR 模型'
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error(`OCR 模型下载失败:HTTP ${response.status}`)
|
||||
@@ -651,7 +676,7 @@ export class DocumentOcrModelManager {
|
||||
const declaredLength = response.headers.get('content-length')
|
||||
if (
|
||||
declaredLength !== null &&
|
||||
Number(declaredLength) !== file.download.size
|
||||
Number(declaredLength) !== file.size
|
||||
) {
|
||||
await response.body.cancel().catch(() => undefined)
|
||||
throw new Error(`OCR 模型文件大小不匹配:${file.name}`)
|
||||
@@ -671,7 +696,7 @@ export class DocumentOcrModelManager {
|
||||
}
|
||||
written += result.value.byteLength
|
||||
if (
|
||||
written > file.download.size ||
|
||||
written > file.size ||
|
||||
written > this.maxFileBytes
|
||||
) {
|
||||
await reader.cancel()
|
||||
@@ -688,8 +713,8 @@ export class DocumentOcrModelManager {
|
||||
await handle.close()
|
||||
}
|
||||
if (
|
||||
written !== file.download.size ||
|
||||
hash.digest('hex') !== file.download.sha256
|
||||
written !== file.size ||
|
||||
hash.digest('hex') !== file.sha256
|
||||
) {
|
||||
throw new Error(`OCR 模型文件校验失败:${file.name}`)
|
||||
}
|
||||
@@ -724,8 +749,8 @@ export class DocumentOcrModelManager {
|
||||
}
|
||||
const actual = await hashFile(path, signal)
|
||||
if (
|
||||
actual.size !== file.download.size ||
|
||||
actual.sha256 !== file.download.sha256
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256
|
||||
) {
|
||||
throw new Error(`本地 OCR 模型文件校验失败:${file.name}`)
|
||||
}
|
||||
@@ -832,8 +857,8 @@ export class DocumentOcrModelManager {
|
||||
const actual = await hashFile(safeChild(directory, file.name))
|
||||
if (
|
||||
!installed ||
|
||||
actual.size !== file.download.size ||
|
||||
actual.sha256 !== file.download.sha256 ||
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256 ||
|
||||
actual.size !== installed.size ||
|
||||
actual.sha256 !== installed.sha256
|
||||
) {
|
||||
@@ -879,8 +904,8 @@ export class DocumentOcrModelManager {
|
||||
}
|
||||
if (
|
||||
!installed ||
|
||||
actual.size !== file.download.size ||
|
||||
actual.sha256 !== file.download.sha256 ||
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256 ||
|
||||
actual.size !== installed.size ||
|
||||
actual.sha256 !== installed.sha256
|
||||
) {
|
||||
|
||||
+6
-2
@@ -440,7 +440,9 @@ if (hasSingleInstanceLock) {
|
||||
)
|
||||
documentOcrModelManager = new DocumentOcrModelManager({
|
||||
userDataDirectory: app.getPath('userData'),
|
||||
fetch: globalThis.fetch
|
||||
fetch: globalThis.fetch,
|
||||
getDownloadSource: async () =>
|
||||
(await applicationSettingsStore.get()).modelDownloadSource
|
||||
})
|
||||
documentOcrBroker = new DocumentOcrBroker(mainWindow)
|
||||
const documentParsingService = new DocumentParsingService(
|
||||
@@ -456,7 +458,9 @@ if (hasSingleInstanceLock) {
|
||||
})
|
||||
const speechModelManager = new SpeechModelManager({
|
||||
userDataDirectory: app.getPath('userData'),
|
||||
fetch: globalThis.fetch
|
||||
fetch: globalThis.fetch,
|
||||
getDownloadSource: async () =>
|
||||
(await applicationSettingsStore.get()).modelDownloadSource
|
||||
})
|
||||
const speechTranscriptionService = new SpeechTranscriptionService(
|
||||
speechModelManager
|
||||
|
||||
@@ -430,6 +430,179 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerIpcHandlers model download source routing', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses the persisted source and rejects stale renderer requests', async () => {
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
const event = {
|
||||
sender: webContents,
|
||||
senderFrame: webContents.mainFrame
|
||||
}
|
||||
const speechSnapshot = {
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [],
|
||||
installed: [],
|
||||
operations: [],
|
||||
selectedModelId: null
|
||||
}
|
||||
const ocrSnapshot = {
|
||||
settings: {},
|
||||
status: {},
|
||||
ocrModels: {
|
||||
rootDirectory: 'C:\\models\\ocr',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [],
|
||||
installed: [],
|
||||
operations: []
|
||||
}
|
||||
}
|
||||
const speechModelManager = {
|
||||
install: vi.fn(async () => undefined),
|
||||
getSnapshot: vi.fn(async () => speechSnapshot),
|
||||
getRepositoryUrl: vi.fn(
|
||||
() => 'https://huggingface.co/example/speech'
|
||||
)
|
||||
}
|
||||
const documentOcrModelManager = {
|
||||
install: vi.fn(async () => undefined),
|
||||
getRepositoryUrl: vi.fn(
|
||||
() => 'https://huggingface.co/example/ocr'
|
||||
)
|
||||
}
|
||||
const documentParsingService = {
|
||||
snapshot: vi.fn(async () => ocrSnapshot)
|
||||
}
|
||||
const applicationSettingsStore = {
|
||||
get: vi.fn(async () => ({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
}))
|
||||
}
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{ claimDueSchedules: vi.fn(() => []) } as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
applicationSettingsStore as never,
|
||||
undefined,
|
||||
speechModelManager as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
documentParsingService as never,
|
||||
documentOcrModelManager as never
|
||||
)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
|
||||
event,
|
||||
{
|
||||
modelId: 'speech-model',
|
||||
expectedDownloadSource: 'hugging-face'
|
||||
}
|
||||
)
|
||||
).resolves.toEqual(speechSnapshot)
|
||||
expect(speechModelManager.install).toHaveBeenCalledWith(
|
||||
'speech-model',
|
||||
'hugging-face'
|
||||
)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
|
||||
event,
|
||||
{
|
||||
modelId: 'speech-model',
|
||||
expectedDownloadSource: 'modelscope'
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('模型下载源已变化')
|
||||
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
|
||||
|
||||
applicationSettingsStore.get.mockRejectedValueOnce(
|
||||
new Error('settings unavailable')
|
||||
)
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
|
||||
event,
|
||||
{
|
||||
modelId: 'speech-model',
|
||||
expectedDownloadSource: 'hugging-face'
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('settings unavailable')
|
||||
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.documentOcrModelsInstall
|
||||
)?.(event, {
|
||||
modelId: 'ocr-model',
|
||||
expectedDownloadSource: 'hugging-face'
|
||||
})
|
||||
).resolves.toEqual(ocrSnapshot)
|
||||
expect(documentOcrModelManager.install).toHaveBeenCalledWith(
|
||||
'ocr-model',
|
||||
'hugging-face'
|
||||
)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.speechModelsOpenRepository
|
||||
)?.(event, { modelId: 'speech-model' })
|
||||
).resolves.toBeUndefined()
|
||||
expect(speechModelManager.getRepositoryUrl).toHaveBeenCalledWith(
|
||||
'speech-model',
|
||||
'hugging-face'
|
||||
)
|
||||
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
||||
'https://huggingface.co/example/speech'
|
||||
)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.documentOcrModelsOpenRepository
|
||||
)?.(event, { modelId: 'ocr-model' })
|
||||
).resolves.toBeUndefined()
|
||||
expect(
|
||||
documentOcrModelManager.getRepositoryUrl
|
||||
).toHaveBeenCalledWith('ocr-model', 'hugging-face')
|
||||
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
||||
'https://huggingface.co/example/ocr'
|
||||
)
|
||||
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getName: vi.fn(() => 'GoodBuddy'),
|
||||
|
||||
+43
-24
@@ -109,6 +109,7 @@ import { applicationSettingsUpdateSchema } from '../shared/application-settings-
|
||||
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
|
||||
import {
|
||||
speechModelActionInputSchema,
|
||||
speechModelInstallInputSchema,
|
||||
speechModelSelectionInputSchema
|
||||
} from '../shared/speech-model-contracts'
|
||||
import {
|
||||
@@ -119,6 +120,7 @@ import {
|
||||
} from '../shared/embedding-contracts'
|
||||
import {
|
||||
documentOcrModelActionInputSchema,
|
||||
documentOcrModelInstallInputSchema,
|
||||
documentOcrFailureSchema,
|
||||
documentOcrResultSchema,
|
||||
documentParsingSettingsUpdateSchema,
|
||||
@@ -3513,16 +3515,25 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.documentOcrModelsInstall,
|
||||
(event, input: unknown) => {
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!documentOcrModelManager || !documentParsingService) {
|
||||
if (
|
||||
!documentOcrModelManager ||
|
||||
!documentParsingService ||
|
||||
!applicationSettingsStore
|
||||
) {
|
||||
throw new Error('本地 OCR 模型服务不可用')
|
||||
}
|
||||
const { modelId } =
|
||||
documentOcrModelActionInputSchema.parse(input)
|
||||
const { modelId, expectedDownloadSource } =
|
||||
documentOcrModelInstallInputSchema.parse(input)
|
||||
const { modelDownloadSource: selectedDownloadSource } =
|
||||
await applicationSettingsStore.get()
|
||||
if (selectedDownloadSource !== expectedDownloadSource) {
|
||||
throw new Error('模型下载源已变化,请刷新后重试')
|
||||
}
|
||||
return trackExecution(
|
||||
documentOcrModelManager
|
||||
.install(modelId)
|
||||
.install(modelId, selectedDownloadSource)
|
||||
.then(() => documentParsingService.snapshot())
|
||||
)
|
||||
}
|
||||
@@ -3611,19 +3622,19 @@ export function registerIpcHandlers(
|
||||
ipcChannels.documentOcrModelsOpenRepository,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!documentOcrModelManager) {
|
||||
if (!documentOcrModelManager || !applicationSettingsStore) {
|
||||
throw new Error('本地 OCR 模型服务不可用')
|
||||
}
|
||||
const { modelId } =
|
||||
documentOcrModelActionInputSchema.parse(input)
|
||||
const snapshot = await documentOcrModelManager.getSnapshot()
|
||||
const entry = snapshot.catalog.find(
|
||||
(candidate) => candidate.id === modelId
|
||||
const { modelDownloadSource: selectedDownloadSource } =
|
||||
await applicationSettingsStore.get()
|
||||
await shell.openExternal(
|
||||
documentOcrModelManager.getRepositoryUrl(
|
||||
modelId,
|
||||
selectedDownloadSource
|
||||
)
|
||||
)
|
||||
if (!entry) {
|
||||
throw new Error('未知的 OCR 模型')
|
||||
}
|
||||
await shell.openExternal(entry.repositoryUrl)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3759,15 +3770,21 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.speechModelsInstall,
|
||||
(event, input: unknown) => {
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!speechModelManager) {
|
||||
if (!speechModelManager || !applicationSettingsStore) {
|
||||
throw new Error('语音模型服务不可用')
|
||||
}
|
||||
const { modelId } = speechModelActionInputSchema.parse(input)
|
||||
const { modelId, expectedDownloadSource } =
|
||||
speechModelInstallInputSchema.parse(input)
|
||||
const { modelDownloadSource: selectedDownloadSource } =
|
||||
await applicationSettingsStore.get()
|
||||
if (selectedDownloadSource !== expectedDownloadSource) {
|
||||
throw new Error('模型下载源已变化,请刷新后重试')
|
||||
}
|
||||
return trackExecution(
|
||||
speechModelManager
|
||||
.install(modelId)
|
||||
.install(modelId, selectedDownloadSource)
|
||||
.then(() => speechModelManager.getSnapshot())
|
||||
)
|
||||
}
|
||||
@@ -3862,16 +3879,18 @@ export function registerIpcHandlers(
|
||||
ipcChannels.speechModelsOpenRepository,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!speechModelManager) {
|
||||
if (!speechModelManager || !applicationSettingsStore) {
|
||||
throw new Error('语音模型服务不可用')
|
||||
}
|
||||
const { modelId } = speechModelActionInputSchema.parse(input)
|
||||
const snapshot = await speechModelManager.getSnapshot()
|
||||
const entry = snapshot.catalog.find((item) => item.id === modelId)
|
||||
if (!entry) {
|
||||
throw new Error('未知的语音模型')
|
||||
}
|
||||
await shell.openExternal(entry.repositoryUrl)
|
||||
const { modelDownloadSource: selectedDownloadSource } =
|
||||
await applicationSettingsStore.get()
|
||||
await shell.openExternal(
|
||||
speechModelManager.getRepositoryUrl(
|
||||
modelId,
|
||||
selectedDownloadSource
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
MODEL_DOWNLOAD_REDIRECT_HOSTS,
|
||||
type ModelArtifactTarget
|
||||
} from '../shared/model-download-contracts'
|
||||
|
||||
export function modelScopeTarget(
|
||||
repository: string,
|
||||
revision: string,
|
||||
file: string
|
||||
): ModelArtifactTarget {
|
||||
const repositoryUrl = `https://modelscope.cn/models/${repository}`
|
||||
return {
|
||||
url: `${repositoryUrl}/resolve/${revision}/${file}`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
redirectHosts: []
|
||||
}
|
||||
}
|
||||
|
||||
export function huggingFaceTarget(
|
||||
repository: string,
|
||||
revision: string,
|
||||
file: string
|
||||
): ModelArtifactTarget {
|
||||
const repositoryUrl = `https://huggingface.co/${repository}`
|
||||
return {
|
||||
url: `${repositoryUrl}/resolve/${revision}/${file}`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
redirectHosts: [
|
||||
...MODEL_DOWNLOAD_REDIRECT_HOSTS['hugging-face']
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
const MAX_REDIRECTS = 3
|
||||
const redirectStatuses = new Set([301, 302, 303, 307, 308])
|
||||
|
||||
function validateDownloadUrl(value: string, modelLabel: string): URL {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
(url.port !== '' && url.port !== '443') ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error(
|
||||
`${modelLabel}下载地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL`
|
||||
)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export async function fetchModelDownloadResponse(options: {
|
||||
transport: typeof fetch
|
||||
initialUrl: string
|
||||
redirectHosts: readonly string[]
|
||||
signal: AbortSignal
|
||||
modelLabel: string
|
||||
}): Promise<Response> {
|
||||
let url = validateDownloadUrl(options.initialUrl, options.modelLabel)
|
||||
const initialHost = url.hostname
|
||||
const allowedRedirectHosts = new Set(options.redirectHosts)
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
if (options.signal.aborted) {
|
||||
throw new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
const response = await options.transport(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
signal: options.signal
|
||||
})
|
||||
if (!redirectStatuses.has(response.status)) {
|
||||
return response
|
||||
}
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error(
|
||||
`${options.modelLabel}下载重定向次数过多`
|
||||
)
|
||||
}
|
||||
const location = response.headers.get('location')
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
if (!location) {
|
||||
throw new Error(
|
||||
`${options.modelLabel}下载重定向缺少地址`
|
||||
)
|
||||
}
|
||||
const nextUrl = validateDownloadUrl(
|
||||
new URL(location, url).toString(),
|
||||
options.modelLabel
|
||||
)
|
||||
if (
|
||||
nextUrl.hostname !== initialHost &&
|
||||
!allowedRedirectHosts.has(nextUrl.hostname)
|
||||
) {
|
||||
throw new Error(
|
||||
`${options.modelLabel}下载重定向到未声明的主机`
|
||||
)
|
||||
}
|
||||
url = nextUrl
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,33 @@ import {
|
||||
speechModelCatalogEntrySchema,
|
||||
type SpeechModelCatalogEntry
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import {
|
||||
huggingFaceTarget,
|
||||
modelScopeTarget
|
||||
} from '../model-download-targets'
|
||||
|
||||
const senseVoiceModelScopeRepository =
|
||||
'pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue'
|
||||
const senseVoiceModelScopeRevision =
|
||||
'73eca47697f980daa3d16112404174b6b950b514'
|
||||
const senseVoiceHuggingFaceRepository =
|
||||
'csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17'
|
||||
const senseVoiceHuggingFaceRevision =
|
||||
'2365baeacb507f821a0c8120fcee3d484dba7a07'
|
||||
|
||||
const whisperTinyModelScopeRepository =
|
||||
'pengzhendong/sherpa-onnx-whisper-tiny'
|
||||
const whisperTinyModelScopeRevision =
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f'
|
||||
const whisperTinyHuggingFaceRepository =
|
||||
'csukuangfj/sherpa-onnx-whisper-tiny'
|
||||
const whisperTinyHuggingFaceRevision =
|
||||
'65176e2deb88badc814a94058666cadccc29b61c'
|
||||
|
||||
/**
|
||||
* This catalog intentionally contains metadata only. Model weights are never
|
||||
* bundled with GoodBuddy. Entries remain manual-only until every downloadable
|
||||
* file has a pinned revision, byte size, and independently verified SHA-256.
|
||||
* Model weights are never bundled with GoodBuddy. Canonical file identity is
|
||||
* source-independent; a source target is included only after its bytes match
|
||||
* the declared size and SHA-256.
|
||||
*/
|
||||
export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speechModelCatalogEntrySchema.array().parse([
|
||||
@@ -21,13 +43,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue',
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
`https://modelscope.cn/models/${senseVoiceModelScopeRepository}`,
|
||||
'hugging-face':
|
||||
`https://huggingface.co/${senseVoiceHuggingFaceRepository}`
|
||||
},
|
||||
license: {
|
||||
name: '模型仓库自定义许可(Model License)',
|
||||
notice:
|
||||
'SenseVoiceSmall 权重采用模型仓库声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。',
|
||||
'SenseVoiceSmall 权重采用上游声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。',
|
||||
url: 'https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE'
|
||||
},
|
||||
manualOnly: false,
|
||||
@@ -35,29 +60,39 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' +
|
||||
'resolve/73eca47697f980daa3d16112404174b6b950b514/' +
|
||||
'model.int8.onnx',
|
||||
size: 239_233_841,
|
||||
sha256:
|
||||
'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51'
|
||||
size: 239_233_841,
|
||||
sha256:
|
||||
'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
senseVoiceModelScopeRepository,
|
||||
senseVoiceModelScopeRevision,
|
||||
'model.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
senseVoiceHuggingFaceRepository,
|
||||
senseVoiceHuggingFaceRevision,
|
||||
'model.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' +
|
||||
'resolve/73eca47697f980daa3d16112404174b6b950b514/' +
|
||||
'tokens.txt',
|
||||
size: 315_894,
|
||||
sha256:
|
||||
'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc'
|
||||
size: 315_894,
|
||||
sha256:
|
||||
'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
senseVoiceModelScopeRepository,
|
||||
senseVoiceModelScopeRevision,
|
||||
'tokens.txt'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
senseVoiceHuggingFaceRepository,
|
||||
senseVoiceHuggingFaceRevision,
|
||||
'tokens.txt'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -73,9 +108,12 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
quality: 'basic',
|
||||
speed: 'fast',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny',
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
`https://modelscope.cn/models/${whisperTinyModelScopeRepository}`,
|
||||
'hugging-face':
|
||||
`https://huggingface.co/${whisperTinyHuggingFaceRepository}`
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice:
|
||||
@@ -87,43 +125,58 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
{
|
||||
name: 'tiny-encoder.int8.onnx',
|
||||
role: 'encoder',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f/' +
|
||||
'tiny-encoder.int8.onnx',
|
||||
size: 12_937_772,
|
||||
sha256:
|
||||
'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434'
|
||||
size: 12_937_772,
|
||||
sha256:
|
||||
'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperTinyModelScopeRepository,
|
||||
whisperTinyModelScopeRevision,
|
||||
'tiny-encoder.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
whisperTinyHuggingFaceRepository,
|
||||
whisperTinyHuggingFaceRevision,
|
||||
'tiny-encoder.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tiny-decoder.int8.onnx',
|
||||
role: 'decoder',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f/' +
|
||||
'tiny-decoder.int8.onnx',
|
||||
size: 89_855_401,
|
||||
sha256:
|
||||
'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925'
|
||||
size: 89_855_401,
|
||||
sha256:
|
||||
'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperTinyModelScopeRepository,
|
||||
whisperTinyModelScopeRevision,
|
||||
'tiny-decoder.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
whisperTinyHuggingFaceRepository,
|
||||
whisperTinyHuggingFaceRevision,
|
||||
'tiny-decoder.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tiny-tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/pengzhendong/' +
|
||||
'sherpa-onnx-whisper-tiny/resolve/' +
|
||||
'33a655645234f82ce833cf27b689d9c2212e693f/' +
|
||||
'tiny-tokens.txt',
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperTinyModelScopeRepository,
|
||||
whisperTinyModelScopeRevision,
|
||||
'tiny-tokens.txt'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
whisperTinyHuggingFaceRepository,
|
||||
whisperTinyHuggingFaceRevision,
|
||||
'tiny-tokens.txt'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -139,9 +192,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-bilingual-zh-en',
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice:
|
||||
@@ -156,29 +210,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' +
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' +
|
||||
'model.int8.onnx',
|
||||
size: 223_385_835,
|
||||
sha256:
|
||||
'9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7'
|
||||
size: 223_385_835,
|
||||
sha256:
|
||||
'9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
|
||||
'model.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' +
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' +
|
||||
'tokens.txt',
|
||||
size: 75_756,
|
||||
sha256:
|
||||
'59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6'
|
||||
size: 75_756,
|
||||
sha256:
|
||||
'59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
|
||||
'tokens.txt'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -194,9 +248,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
quality: 'high',
|
||||
speed: 'balanced',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en'
|
||||
},
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice:
|
||||
@@ -211,29 +266,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' +
|
||||
'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' +
|
||||
'model.int8.onnx',
|
||||
size: 244_684_152,
|
||||
sha256:
|
||||
'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144'
|
||||
size: 244_684_152,
|
||||
sha256:
|
||||
'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
|
||||
'8d90151338178bb433354c9fb677bd3acb8023cd',
|
||||
'model.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' +
|
||||
'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' +
|
||||
'tokens.txt',
|
||||
size: 118_931,
|
||||
sha256:
|
||||
'8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492'
|
||||
size: 118_931,
|
||||
sha256:
|
||||
'8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
|
||||
'8d90151338178bb433354c9fb677bd3acb8023cd',
|
||||
'tokens.txt'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -249,8 +304,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
quality: 'balanced',
|
||||
speed: 'balanced',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small',
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice:
|
||||
@@ -262,43 +319,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
{
|
||||
name: 'small-encoder.int8.onnx',
|
||||
role: 'encoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-small/resolve/' +
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
|
||||
'small-encoder.int8.onnx',
|
||||
size: 112_442_483,
|
||||
sha256:
|
||||
'4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9'
|
||||
size: 112_442_483,
|
||||
sha256:
|
||||
'4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-small',
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
|
||||
'small-encoder.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'small-decoder.int8.onnx',
|
||||
role: 'decoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-small/resolve/' +
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
|
||||
'small-decoder.int8.onnx',
|
||||
size: 262_226_114,
|
||||
sha256:
|
||||
'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee'
|
||||
size: 262_226_114,
|
||||
sha256:
|
||||
'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-small',
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
|
||||
'small-decoder.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'small-tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-small/resolve/' +
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
|
||||
'small-tokens.txt',
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-small',
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
|
||||
'small-tokens.txt'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -314,8 +371,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
quality: 'high',
|
||||
speed: 'slow',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium',
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice:
|
||||
@@ -327,43 +386,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
{
|
||||
name: 'medium-encoder.int8.onnx',
|
||||
role: 'encoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-medium/resolve/' +
|
||||
'8c31d28503847560985df21f90e14f0c736e075e/' +
|
||||
'medium-encoder.int8.onnx',
|
||||
size: 374_196_283,
|
||||
sha256:
|
||||
'1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e'
|
||||
size: 374_196_283,
|
||||
sha256:
|
||||
'1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-medium',
|
||||
'8c31d28503847560985df21f90e14f0c736e075e',
|
||||
'medium-encoder.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'medium-decoder.int8.onnx',
|
||||
role: 'decoder',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-medium/resolve/' +
|
||||
'8c31d28503847560985df21f90e14f0c736e075e/' +
|
||||
'medium-decoder.int8.onnx',
|
||||
size: 571_059_257,
|
||||
sha256:
|
||||
'595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747'
|
||||
size: 571_059_257,
|
||||
sha256:
|
||||
'595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-medium',
|
||||
'8c31d28503847560985df21f90e14f0c736e075e',
|
||||
'medium-decoder.int8.onnx'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'medium-tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-whisper-medium/resolve/' +
|
||||
'8c31d28503847560985df21f90e14f0c736e075e/' +
|
||||
'medium-tokens.txt',
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
|
||||
size: 816_730,
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
|
||||
targets: {
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-medium',
|
||||
'8c31d28503847560985df21f90e14f0c736e075e',
|
||||
'medium-tokens.txt'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -35,12 +35,36 @@ function sha256(value: Uint8Array): string {
|
||||
}
|
||||
|
||||
function manualCatalog(): SpeechModelCatalogEntry[] {
|
||||
return SPEECH_MODEL_CATALOG.map((entry) => ({
|
||||
const entry = SPEECH_MODEL_CATALOG.find(
|
||||
(candidate) => candidate.id === 'sensevoice-small-int8'
|
||||
)
|
||||
if (!entry) {
|
||||
throw new Error('SenseVoice test catalog entry is missing')
|
||||
}
|
||||
const modelBytes = new TextEncoder().encode('model')
|
||||
const tokenBytes = new TextEncoder().encode('tokens')
|
||||
return [{
|
||||
...entry,
|
||||
manualOnly: true,
|
||||
manualReason: entry.manualReason ?? '测试使用本地目录导入。',
|
||||
files: entry.files.map(({ name, role }) => ({ name, role }))
|
||||
}))
|
||||
manualReason: '测试使用本地目录导入。',
|
||||
repositoryUrls: {},
|
||||
files: [
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model',
|
||||
size: modelBytes.byteLength,
|
||||
sha256: sha256(modelBytes),
|
||||
targets: {}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
size: tokenBytes.byteLength,
|
||||
sha256: sha256(tokenBytes),
|
||||
targets: {}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
||||
function downloadableCatalog(
|
||||
@@ -58,8 +82,12 @@ function downloadableCatalog(
|
||||
quality: 'balanced',
|
||||
speed: 'balanced',
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/example/download-test-model',
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
'https://modelscope.cn/models/example/download-test-model',
|
||||
'hugging-face':
|
||||
'https://huggingface.co/example/download-test-model'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Test-only model metadata.',
|
||||
@@ -70,23 +98,53 @@ function downloadableCatalog(
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/example/download-test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/model.onnx`,
|
||||
size: modelBytes.byteLength,
|
||||
sha256: sha256(modelBytes)
|
||||
size: modelBytes.byteLength,
|
||||
sha256: sha256(modelBytes),
|
||||
targets: {
|
||||
modelscope: {
|
||||
url:
|
||||
'https://modelscope.cn/models/example/download-test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/model.onnx`,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/example/download-test-model',
|
||||
revision: 'a'.repeat(40),
|
||||
redirectHosts: []
|
||||
},
|
||||
'hugging-face': {
|
||||
url:
|
||||
'https://huggingface.co/example/download-test-model/' +
|
||||
`resolve/${'b'.repeat(40)}/model.onnx`,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/example/download-test-model',
|
||||
revision: 'b'.repeat(40),
|
||||
redirectHosts: []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens',
|
||||
download: {
|
||||
url:
|
||||
'https://modelscope.cn/models/example/download-test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/tokens.txt`,
|
||||
size: tokenBytes.byteLength,
|
||||
sha256: sha256(tokenBytes)
|
||||
size: tokenBytes.byteLength,
|
||||
sha256: sha256(tokenBytes),
|
||||
targets: {
|
||||
modelscope: {
|
||||
url:
|
||||
'https://modelscope.cn/models/example/download-test-model/' +
|
||||
`resolve/${'a'.repeat(40)}/tokens.txt`,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/example/download-test-model',
|
||||
revision: 'a'.repeat(40),
|
||||
redirectHosts: []
|
||||
},
|
||||
'hugging-face': {
|
||||
url:
|
||||
'https://huggingface.co/example/download-test-model/' +
|
||||
`resolve/${'b'.repeat(40)}/tokens.txt`,
|
||||
repositoryUrl:
|
||||
'https://huggingface.co/example/download-test-model',
|
||||
revision: 'b'.repeat(40),
|
||||
redirectHosts: []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -131,10 +189,19 @@ describe('speech model catalog', () => {
|
||||
license: { name: 'MIT License' }
|
||||
})
|
||||
expect(
|
||||
senseVoice?.files.every((file) => file.download !== undefined)
|
||||
senseVoice?.files.every(
|
||||
(file) =>
|
||||
file.targets.modelscope !== undefined &&
|
||||
file.targets['hugging-face'] !== undefined
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
whisper?.files.every(
|
||||
(file) =>
|
||||
file.targets.modelscope !== undefined &&
|
||||
file.targets['hugging-face'] !== undefined
|
||||
)
|
||||
).toBe(true)
|
||||
expect(whisper?.files.every((file) => file.download !== undefined))
|
||||
.toBe(true)
|
||||
expect(whisper?.files.map((file) => file.name)).toEqual([
|
||||
'tiny-encoder.int8.onnx',
|
||||
'tiny-decoder.int8.onnx',
|
||||
@@ -163,15 +230,21 @@ describe('speech model catalog', () => {
|
||||
})
|
||||
expect(SPEECH_MODEL_CATALOG).toHaveLength(6)
|
||||
for (const entry of SPEECH_MODEL_CATALOG) {
|
||||
expect(entry.repositoryUrl).toMatch(
|
||||
/^https:\/\/(?:modelscope\.cn\/models\/|huggingface\.co\/)/u
|
||||
)
|
||||
for (const file of entry.files) {
|
||||
expect(file.download?.url).toMatch(
|
||||
/^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
|
||||
)
|
||||
expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u)
|
||||
expect(file.size).toBeGreaterThan(0)
|
||||
for (const target of Object.values(file.targets)) {
|
||||
expect(target?.url).toMatch(
|
||||
/^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
paraformerBilingual?.files.some(
|
||||
(file) => file.targets.modelscope !== undefined
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,10 +315,14 @@ describe('SpeechModelManager downloads', () => {
|
||||
).toBe(false)
|
||||
|
||||
await manager.select('download-test-model')
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
const snapshot = await manager.snapshot()
|
||||
expect(snapshot).toMatchObject({
|
||||
selectedDownloadSource: 'modelscope',
|
||||
selectedModelId: 'download-test-model',
|
||||
operations: []
|
||||
})
|
||||
expect(snapshot.catalog[0]?.files[0]).not.toHaveProperty('targets')
|
||||
expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/')
|
||||
await manager.remove('download-test-model')
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
selectedModelId: null,
|
||||
@@ -253,7 +330,63 @@ describe('SpeechModelManager downloads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts arbitrary HTTP hosts and cross-host redirects', async () => {
|
||||
it('freezes the operation source when the global setting changes', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const tokenBytes = new TextEncoder().encode('tokens')
|
||||
let selectedSource: 'modelscope' | 'hugging-face' = 'modelscope'
|
||||
let releaseFirstRequest: (() => void) | undefined
|
||||
let markFirstRequestStarted: (() => void) | undefined
|
||||
const firstRequestStarted = new Promise<void>((resolveStarted) => {
|
||||
markFirstRequestStarted = resolveStarted
|
||||
})
|
||||
const firstRequestGate = new Promise<void>((resolveRequest) => {
|
||||
releaseFirstRequest = resolveRequest
|
||||
})
|
||||
let requestCount = 0
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
requestCount += 1
|
||||
if (requestCount === 1) {
|
||||
markFirstRequestStarted?.()
|
||||
await firstRequestGate
|
||||
}
|
||||
const bytes = String(input).endsWith('model.onnx')
|
||||
? modelBytes
|
||||
: tokenBytes
|
||||
return new Response(bytes, {
|
||||
headers: { 'content-length': String(bytes.byteLength) }
|
||||
})
|
||||
})
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog: downloadableCatalog(modelBytes, tokenBytes),
|
||||
fetch: transport,
|
||||
getDownloadSource: () => selectedSource
|
||||
})
|
||||
|
||||
const installing = manager.install('download-test-model')
|
||||
await firstRequestStarted
|
||||
selectedSource = 'hugging-face'
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
operations: [
|
||||
{
|
||||
modelId: 'download-test-model',
|
||||
kind: 'download',
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
})
|
||||
releaseFirstRequest?.()
|
||||
await installing
|
||||
expect(
|
||||
transport.mock.calls.every(
|
||||
([input]) => new URL(String(input)).hostname === 'modelscope.cn'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('follows only source-declared HTTPS redirect hosts', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const tokenBytes = new TextEncoder().encode('tokens')
|
||||
@@ -261,24 +394,24 @@ describe('SpeechModelManager downloads', () => {
|
||||
...entry,
|
||||
files: entry.files.map((file) => ({
|
||||
...file,
|
||||
download: file.download
|
||||
? {
|
||||
...file.download,
|
||||
url: file.download.url.replace(
|
||||
'https://modelscope.cn',
|
||||
'http://models.internal.example'
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
targets: {
|
||||
...file.targets,
|
||||
'hugging-face': file.targets['hugging-face']
|
||||
? {
|
||||
...file.targets['hugging-face'],
|
||||
redirectHosts: ['cdn-lfs.hf.co']
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}))
|
||||
}))
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(String(input))
|
||||
if (url.hostname === 'models.internal.example') {
|
||||
if (url.hostname === 'huggingface.co') {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: `https://cdn.example.net${url.pathname}`
|
||||
location: `https://cdn-lfs.hf.co${url.pathname}`
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -296,19 +429,91 @@ describe('SpeechModelManager downloads', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
redirected.install('download-test-model')
|
||||
redirected.install('download-test-model', 'hugging-face')
|
||||
).resolves.toMatchObject({ id: 'download-test-model' })
|
||||
expect(transport).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
transport.mock.calls.map(([input]) => new URL(String(input)).hostname)
|
||||
).toEqual([
|
||||
'models.internal.example',
|
||||
'cdn.example.net',
|
||||
'models.internal.example',
|
||||
'cdn.example.net'
|
||||
'huggingface.co',
|
||||
'cdn-lfs.hf.co',
|
||||
'huggingface.co',
|
||||
'cdn-lfs.hf.co'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects undeclared redirect hosts without following them', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const transport = vi.fn<typeof fetch>(async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'https://untrusted.example/model.onnx'
|
||||
}
|
||||
})
|
||||
)
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog: downloadableCatalog(modelBytes),
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(
|
||||
manager.install('download-test-model')
|
||||
).rejects.toThrow('未声明的主机')
|
||||
expect(transport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not request another source when selected coverage is missing', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const catalog = downloadableCatalog(modelBytes).map((entry) => ({
|
||||
...entry,
|
||||
repositoryUrls: {
|
||||
'hugging-face': entry.repositoryUrls['hugging-face']
|
||||
},
|
||||
files: entry.files.map((file) => ({
|
||||
...file,
|
||||
targets: {
|
||||
'hugging-face': file.targets['hugging-face']
|
||||
}
|
||||
}))
|
||||
}))
|
||||
const transport = vi.fn<typeof fetch>()
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog,
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(
|
||||
manager.install('download-test-model', 'modelscope')
|
||||
).rejects.toThrow('当前下载源')
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not request another source after a download failure', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
const transport = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 503 })
|
||||
)
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
catalog: downloadableCatalog(modelBytes),
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(
|
||||
manager.install('download-test-model', 'modelscope')
|
||||
).rejects.toThrow('HTTP 503')
|
||||
expect(transport).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
new URL(String(transport.mock.calls[0]?.[0])).hostname
|
||||
).toBe('modelscope.cn')
|
||||
})
|
||||
|
||||
it('rejects bad digests without installing', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('expected')
|
||||
|
||||
@@ -16,22 +16,31 @@ import { z } from 'zod'
|
||||
import {
|
||||
installedSpeechModelSchema,
|
||||
speechModelCatalogEntrySchema,
|
||||
speechModelCatalogViewEntrySchema,
|
||||
speechModelIdSchema,
|
||||
speechModelSnapshotSchema,
|
||||
type InstalledSpeechModel,
|
||||
type SpeechModelCatalogEntry,
|
||||
type SpeechModelCatalogViewEntry,
|
||||
type SpeechModelFileSpec,
|
||||
type SpeechModelOperation,
|
||||
type SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
getModelDownloadAvailability,
|
||||
resolveModelDownloadPackage,
|
||||
type ModelDownloadSource,
|
||||
type ResolvedModelArtifactFile
|
||||
} from '../../shared/model-download-contracts'
|
||||
import { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
|
||||
import {
|
||||
exportModelArchive,
|
||||
extractModelArchive
|
||||
} from '../model-archive'
|
||||
import { fetchModelDownloadResponse } from '../model-download-transport'
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
const MAX_REDIRECTS = 3
|
||||
const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
const SELECTION_FILE_NAME = '.selection.json'
|
||||
const PARTIAL_SUFFIX = '.partial'
|
||||
@@ -56,6 +65,9 @@ export type SpeechModelManagerOptions = {
|
||||
userDataDirectory: string
|
||||
fetch: typeof fetch
|
||||
catalog?: readonly SpeechModelCatalogEntry[]
|
||||
getDownloadSource?: () =>
|
||||
| ModelDownloadSource
|
||||
| Promise<ModelDownloadSource>
|
||||
maxFileBytes?: number
|
||||
}
|
||||
|
||||
@@ -72,6 +84,23 @@ function cloneCatalogEntry(
|
||||
return speechModelCatalogEntrySchema.parse(entry)
|
||||
}
|
||||
|
||||
function toCatalogView(entry: SpeechModelCatalogEntry) {
|
||||
const { repositoryUrls, files, ...metadata } = entry
|
||||
void repositoryUrls
|
||||
return speechModelCatalogViewEntrySchema.parse({
|
||||
...metadata,
|
||||
files: files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
})),
|
||||
downloadAvailability: MODEL_DOWNLOAD_SOURCES.map((source) =>
|
||||
getModelDownloadAvailability(files, source)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
@@ -102,17 +131,6 @@ function safeChild(parent: string, name: string): string {
|
||||
return child
|
||||
}
|
||||
|
||||
function validateDownloadUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'http:' &&
|
||||
url.protocol !== 'https:'
|
||||
) {
|
||||
throw new Error('模型下载地址必须使用 HTTP 或 HTTPS')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function hashFile(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
@@ -147,6 +165,10 @@ export class SpeechModelManager {
|
||||
|
||||
private readonly transport: typeof fetch
|
||||
private readonly catalog: SpeechModelCatalogEntry[]
|
||||
private readonly catalogViews: SpeechModelCatalogViewEntry[]
|
||||
private readonly getDownloadSource: () =>
|
||||
| ModelDownloadSource
|
||||
| Promise<ModelDownloadSource>
|
||||
private readonly maxFileBytes: number
|
||||
private readonly operations = new Map<string, ActiveOperation>()
|
||||
|
||||
@@ -160,23 +182,31 @@ export class SpeechModelManager {
|
||||
'speech'
|
||||
)
|
||||
this.transport = options.fetch
|
||||
this.getDownloadSource =
|
||||
options.getDownloadSource ?? (() => 'modelscope')
|
||||
this.catalog = (options.catalog ?? SPEECH_MODEL_CATALOG).map(
|
||||
cloneCatalogEntry
|
||||
)
|
||||
if (new Set(this.catalog.map((entry) => entry.id)).size !== this.catalog.length) {
|
||||
throw new Error('语音模型目录包含重复 ID')
|
||||
}
|
||||
this.catalogViews = this.catalog.map(toCatalogView)
|
||||
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
|
||||
}
|
||||
|
||||
async snapshot(): Promise<SpeechModelSnapshot> {
|
||||
await this.ensureRoot()
|
||||
const installed = await this.readInstalled()
|
||||
const selected = await this.readSelection()
|
||||
const [installed, selected, selectedDownloadSource] =
|
||||
await Promise.all([
|
||||
this.readInstalled(),
|
||||
this.readSelection(),
|
||||
this.getDownloadSource()
|
||||
])
|
||||
const installedIds = new Set(installed.map((model) => model.id))
|
||||
return speechModelSnapshotSchema.parse({
|
||||
rootDirectory: this.rootDirectory,
|
||||
catalog: this.catalog.map(cloneCatalogEntry),
|
||||
selectedDownloadSource,
|
||||
catalog: this.catalogViews,
|
||||
installed,
|
||||
operations: [...this.operations.values()].map((operation) => ({
|
||||
...operation.progress
|
||||
@@ -190,6 +220,19 @@ export class SpeechModelManager {
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
getRepositoryUrl(
|
||||
modelId: string,
|
||||
source: ModelDownloadSource
|
||||
): string {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
resolveModelDownloadPackage(entry.files, source)
|
||||
const repositoryUrl = entry.repositoryUrls[source]
|
||||
if (!repositoryUrl) {
|
||||
throw new Error('当前下载源暂不提供此模型的仓库')
|
||||
}
|
||||
return repositoryUrl
|
||||
}
|
||||
|
||||
async getSelectedRuntimeModel(): Promise<
|
||||
SelectedSpeechRuntimeModel | undefined
|
||||
> {
|
||||
@@ -216,6 +259,7 @@ export class SpeechModelManager {
|
||||
|
||||
async install(
|
||||
modelId: string,
|
||||
downloadSource?: ModelDownloadSource,
|
||||
externalSignal?: AbortSignal
|
||||
): Promise<InstalledSpeechModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
@@ -224,27 +268,17 @@ export class SpeechModelManager {
|
||||
entry.manualReason ?? '该模型只能从本地目录导入'
|
||||
)
|
||||
}
|
||||
const downloadableFiles = entry.files.filter(
|
||||
(
|
||||
file
|
||||
): file is SpeechModelFileSpec & {
|
||||
download: NonNullable<SpeechModelFileSpec['download']>
|
||||
} => file.download !== undefined
|
||||
const selectedDownloadSource =
|
||||
downloadSource ?? (await this.getDownloadSource())
|
||||
const resolvedPackage = resolveModelDownloadPackage(
|
||||
entry.files,
|
||||
selectedDownloadSource
|
||||
)
|
||||
if (downloadableFiles.length !== entry.files.length) {
|
||||
throw new Error('模型下载元数据不完整')
|
||||
}
|
||||
const totalBytes = downloadableFiles.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
0
|
||||
)
|
||||
if (!Number.isSafeInteger(totalBytes)) {
|
||||
throw new RangeError('模型总大小超出安全范围')
|
||||
}
|
||||
const operation = this.beginOperation(
|
||||
entry.id,
|
||||
'download',
|
||||
totalBytes
|
||||
resolvedPackage.totalBytes,
|
||||
resolvedPackage.source
|
||||
)
|
||||
const detachExternalAbort = this.attachExternalSignal(
|
||||
externalSignal,
|
||||
@@ -255,7 +289,7 @@ export class SpeechModelManager {
|
||||
await this.ensureRoot()
|
||||
await this.assertNotInstalled(entry.id)
|
||||
stagingDirectory = await this.createStagingDirectory(entry.id)
|
||||
for (const file of downloadableFiles) {
|
||||
for (const file of resolvedPackage.files) {
|
||||
ensureNotAborted(operation.controller.signal)
|
||||
operation.progress.phase = 'transferring'
|
||||
operation.progress.currentFile = file.name
|
||||
@@ -409,9 +443,8 @@ export class SpeechModelManager {
|
||||
!recorded ||
|
||||
recorded.size <= 0 ||
|
||||
recorded.size > this.maxFileBytes ||
|
||||
(expected.download &&
|
||||
(recorded.size !== expected.download.size ||
|
||||
recorded.sha256 !== expected.download.sha256))
|
||||
recorded.size !== expected.size ||
|
||||
recorded.sha256 !== expected.sha256
|
||||
) {
|
||||
throw new Error(`语音模型文件不可导出:${expected.name}`)
|
||||
}
|
||||
@@ -440,8 +473,7 @@ export class SpeechModelManager {
|
||||
): Promise<InstalledSpeechModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const expectedTotal = entry.files.reduce(
|
||||
(total, file) =>
|
||||
total + (file.download?.size ?? this.maxFileBytes),
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
const maximumTotalBytes = Math.min(
|
||||
@@ -488,9 +520,8 @@ export class SpeechModelManager {
|
||||
if (
|
||||
!archived ||
|
||||
archived.size > this.maxFileBytes ||
|
||||
(expected.download &&
|
||||
(archived.size !== expected.download.size ||
|
||||
archived.sha256 !== expected.download.sha256))
|
||||
archived.size !== expected.size ||
|
||||
archived.sha256 !== expected.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`语音模型 ZIP 与当前模型目录不匹配:${expected.name}`
|
||||
@@ -544,7 +575,8 @@ export class SpeechModelManager {
|
||||
private beginOperation(
|
||||
modelId: string,
|
||||
kind: SpeechModelOperation['kind'],
|
||||
totalBytes: number | null
|
||||
totalBytes: number | null,
|
||||
downloadSource?: ModelDownloadSource
|
||||
): ActiveOperation {
|
||||
if (this.operations.has(modelId)) {
|
||||
throw new Error('该模型已有进行中的操作')
|
||||
@@ -557,7 +589,8 @@ export class SpeechModelManager {
|
||||
phase: 'preparing',
|
||||
currentFile: null,
|
||||
completedBytes: 0,
|
||||
totalBytes
|
||||
totalBytes,
|
||||
...(downloadSource ? { downloadSource } : {})
|
||||
}
|
||||
}
|
||||
this.operations.set(modelId, operation)
|
||||
@@ -605,55 +638,25 @@ export class SpeechModelManager {
|
||||
return directory
|
||||
}
|
||||
|
||||
private async fetchFollowingRedirects(
|
||||
initialUrl: string,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
let url = validateDownloadUrl(initialUrl)
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
ensureNotAborted(signal)
|
||||
const response = await this.transport(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
signal
|
||||
})
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error('模型下载重定向次数过多')
|
||||
}
|
||||
const location = response.headers.get('location')
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
if (!location) {
|
||||
throw new Error('模型下载重定向缺少地址')
|
||||
}
|
||||
url = validateDownloadUrl(new URL(location, url).toString())
|
||||
continue
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(
|
||||
file: SpeechModelFileSpec & {
|
||||
download: NonNullable<SpeechModelFileSpec['download']>
|
||||
},
|
||||
file: ResolvedModelArtifactFile<SpeechModelFileSpec['role']>,
|
||||
destination: string,
|
||||
operation: ActiveOperation,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (
|
||||
file.download.size > this.maxFileBytes ||
|
||||
file.download.size <= 0
|
||||
file.size > this.maxFileBytes ||
|
||||
file.size <= 0
|
||||
) {
|
||||
throw new RangeError(`模型文件大小超出限制:${file.name}`)
|
||||
}
|
||||
const response = await this.fetchFollowingRedirects(
|
||||
file.download.url,
|
||||
signal
|
||||
)
|
||||
const response = await fetchModelDownloadResponse({
|
||||
transport: this.transport,
|
||||
initialUrl: file.target.url,
|
||||
redirectHosts: file.target.redirectHosts,
|
||||
signal,
|
||||
modelLabel: '模型'
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
throw new Error(`模型下载失败:HTTP ${response.status}`)
|
||||
@@ -666,7 +669,7 @@ export class SpeechModelManager {
|
||||
const parsedLength = Number(declaredLength)
|
||||
if (
|
||||
!Number.isSafeInteger(parsedLength) ||
|
||||
parsedLength !== file.download.size
|
||||
parsedLength !== file.size
|
||||
) {
|
||||
await response.body.cancel().catch(() => undefined)
|
||||
throw new Error(`模型文件大小不匹配:${file.name}`)
|
||||
@@ -687,7 +690,7 @@ export class SpeechModelManager {
|
||||
}
|
||||
written += result.value.byteLength
|
||||
if (
|
||||
written > file.download.size ||
|
||||
written > file.size ||
|
||||
written > this.maxFileBytes
|
||||
) {
|
||||
await reader.cancel()
|
||||
@@ -703,10 +706,10 @@ export class SpeechModelManager {
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (written !== file.download.size) {
|
||||
if (written !== file.size) {
|
||||
throw new Error(`模型文件大小不匹配:${file.name}`)
|
||||
}
|
||||
if (hash.digest('hex') !== file.download.sha256) {
|
||||
if (hash.digest('hex') !== file.sha256) {
|
||||
throw new Error(`模型文件校验失败:${file.name}`)
|
||||
}
|
||||
await rename(partialPath, destination)
|
||||
@@ -741,10 +744,9 @@ export class SpeechModelManager {
|
||||
throw new RangeError(`模型文件大小无效:${expectedFile.name}`)
|
||||
}
|
||||
if (
|
||||
expectedFile.download &&
|
||||
(sourceFileInfo.size !== expectedFile.download.size ||
|
||||
(await hashFile(sourceFile, signal)).sha256 !==
|
||||
expectedFile.download.sha256)
|
||||
sourceFileInfo.size !== expectedFile.size ||
|
||||
(await hashFile(sourceFile, signal)).sha256 !==
|
||||
expectedFile.sha256
|
||||
) {
|
||||
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
|
||||
}
|
||||
|
||||
+11
-4
@@ -72,6 +72,7 @@ import type {
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
ApplicationSettingsUpdate,
|
||||
ModelDownloadSource,
|
||||
VersionCheckResult
|
||||
} from '../shared/application-settings-contracts'
|
||||
import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts'
|
||||
@@ -404,10 +405,13 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.speechModelsGet
|
||||
) as Promise<SpeechModelSnapshot>,
|
||||
install: (modelId: string) =>
|
||||
install: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.speechModelsInstall,
|
||||
{ modelId }
|
||||
{ modelId, expectedDownloadSource }
|
||||
) as Promise<SpeechModelSnapshot>,
|
||||
cancel: (modelId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
@@ -481,10 +485,13 @@ const desktopApi: DesktopApi = {
|
||||
ipcChannels.documentParsingTest,
|
||||
{ purpose }
|
||||
) as Promise<DocumentParsingDiagnostic | undefined>,
|
||||
installOcrModel: (modelId: string) =>
|
||||
installOcrModel: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.documentOcrModelsInstall,
|
||||
{ modelId }
|
||||
{ modelId, expectedDownloadSource }
|
||||
) as Promise<DocumentParsingSnapshot>,
|
||||
cancelOcrModelOperation: (modelId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
|
||||
@@ -1084,6 +1084,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1176,6 +1177,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1183,6 +1185,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1276,6 +1279,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -1283,6 +1287,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7206,6 +7211,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7213,6 +7219,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7253,6 +7260,7 @@ describe('App', () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7260,6 +7268,7 @@ describe('App', () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github' as const,
|
||||
modelDownloadSource: 'modelscope' as const,
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
@@ -7292,6 +7301,7 @@ describe('App', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -7315,6 +7325,9 @@ describe('App', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '平台功能' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
|
||||
@@ -31,8 +31,6 @@ const modelEntry = {
|
||||
quality: 'basic' as const,
|
||||
speed: 'fast' as const,
|
||||
recommended: false,
|
||||
repositoryUrl:
|
||||
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
|
||||
license: {
|
||||
name: 'Apache License 2.0',
|
||||
notice: '使用前请阅读模型许可证。',
|
||||
@@ -42,29 +40,32 @@ const modelEntry = {
|
||||
{
|
||||
name: 'detection.onnx',
|
||||
role: 'detection' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/detection.onnx',
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'recognition.onnx',
|
||||
role: 'recognition' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/recognition.onnx',
|
||||
size: 2_000,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
size: 2_000,
|
||||
sha256: 'b'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'dictionary.yml',
|
||||
role: 'dictionary' as const,
|
||||
download: {
|
||||
url: 'https://modelscope.cn/models/example/dictionary.yml',
|
||||
size: 500,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
size: 500,
|
||||
sha256: 'c'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true,
|
||||
totalBytes: 3_500
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 3_500
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -101,6 +102,7 @@ const snapshot: DocumentParsingSnapshot = {
|
||||
},
|
||||
ocrModels: {
|
||||
rootDirectory: 'C:\\Users\\test\\models\\document-ocr',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [modelEntry, secondModelEntry, thirdModelEntry],
|
||||
installed: [
|
||||
{
|
||||
@@ -111,8 +113,8 @@ const snapshot: DocumentParsingSnapshot = {
|
||||
files: secondModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
],
|
||||
@@ -137,7 +139,7 @@ const test = vi.fn(async () => ({
|
||||
warnings: []
|
||||
}))
|
||||
const installOcrModel =
|
||||
vi.fn<() => Promise<DocumentParsingSnapshot>>(async () => ({
|
||||
vi.fn(async (): Promise<DocumentParsingSnapshot> => ({
|
||||
...snapshot,
|
||||
status: {
|
||||
...snapshot.status,
|
||||
@@ -159,8 +161,8 @@ const installOcrModel =
|
||||
files: modelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
]
|
||||
@@ -305,7 +307,10 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(installOcrModel).toHaveBeenCalledWith('pp-ocrv6-tiny')
|
||||
expect(installOcrModel).toHaveBeenCalledWith(
|
||||
'pp-ocrv6-tiny',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -336,8 +341,8 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
files: thirdModelEntry.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.download.size,
|
||||
sha256: file.download.sha256
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
} satisfies InstalledDocumentOcrModel
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
DocumentParsingDiagnostic,
|
||||
DocumentOcrModelCatalogEntry,
|
||||
DocumentOcrModelCatalogViewEntry,
|
||||
DocumentOcrModelOperation,
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string): string {
|
||||
@@ -54,9 +55,9 @@ function formatBytes(bytes: number): string {
|
||||
: `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: DocumentOcrModelCatalogEntry): number {
|
||||
function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
|
||||
return entry.files.reduce(
|
||||
(total, file) => total + file.download.size,
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
}
|
||||
@@ -207,7 +208,8 @@ function DiagnosticDialog({
|
||||
}
|
||||
|
||||
export function DocumentParsingSettingsSection({
|
||||
onNotify
|
||||
onNotify,
|
||||
onOpenModelDownloadSourceSettings
|
||||
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
|
||||
@@ -439,6 +441,13 @@ export function DocumentParsingSettingsSection({
|
||||
const modelProgress = modelOperation
|
||||
? progressPercent(modelOperation)
|
||||
: undefined
|
||||
const modelDownloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.ocrModels.selectedDownloadSource
|
||||
)
|
||||
const modelSourceAvailable =
|
||||
modelDownloadAvailability?.available === true
|
||||
const pendingModelSelection =
|
||||
draft.localOcrModelId !== snapshot.settings.localOcrModelId
|
||||
const settingsDirty =
|
||||
@@ -701,7 +710,17 @@ export function DocumentParsingSettingsSection({
|
||||
{entryDisplayName} ·{' '}
|
||||
{installed
|
||||
? t('documentParsing.ocr.installedOption')
|
||||
: t('documentParsing.ocr.downloadableOption')}
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.ocrModels
|
||||
.selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('documentParsing.ocr.downloadableOption')
|
||||
: t(
|
||||
'documentParsing.ocr.sourceUnavailableOption'
|
||||
)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -720,6 +739,13 @@ export function DocumentParsingSettingsSection({
|
||||
<code>{snapshot.ocrModels.rootDirectory}</code>
|
||||
{t('documentParsing.ocr.storageSuffix')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('documentParsing.ocr.downloadSource', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
|
||||
{model ? (
|
||||
<article className="document-ocr-model">
|
||||
@@ -735,7 +761,11 @@ export function DocumentParsingSettingsSection({
|
||||
</div>
|
||||
<p>{modelDescription}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">ModelScope</span>
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.languages
|
||||
.map((language) =>
|
||||
@@ -774,9 +804,15 @@ export function DocumentParsingSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.openRepository',
|
||||
{ name: modelDisplayName }
|
||||
{
|
||||
name: modelDisplayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
className="secondary-button document-ocr-model__repository"
|
||||
disabled={!modelSourceAvailable}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.documentParsing
|
||||
?.openOcrModelRepository(model.id)
|
||||
@@ -784,7 +820,11 @@ export function DocumentParsingSettingsSection({
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
{t('documentParsing.ocr.openRepository')}
|
||||
{t('documentParsing.ocr.openRepository', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -806,7 +846,12 @@ export function DocumentParsingSettingsSection({
|
||||
? 'documentParsing.ocr.operations.installing'
|
||||
: modelOperation.kind === 'import'
|
||||
? 'documentParsing.ocr.operations.importing'
|
||||
: 'documentParsing.ocr.operations.downloading'
|
||||
: 'documentParsing.ocr.operations.downloading',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${modelOperation.downloadSource}`
|
||||
)
|
||||
}
|
||||
)
|
||||
: t('documentParsing.ocr.installed')}
|
||||
</span>
|
||||
@@ -879,41 +924,61 @@ export function DocumentParsingSettingsSection({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(model.id)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: modelDisplayName }
|
||||
{modelSourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.downloadModel',
|
||||
{ name: modelDisplayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void runModelOperation(
|
||||
model.id,
|
||||
async () => {
|
||||
const installed =
|
||||
await window.goodbuddy.documentParsing!
|
||||
.installOcrModel(
|
||||
model.id,
|
||||
snapshot.ocrModels
|
||||
.selectedDownloadSource
|
||||
)
|
||||
if (!pendingModelSelection) {
|
||||
return installed
|
||||
}
|
||||
return window.goodbuddy.documentParsing!
|
||||
.update(draft)
|
||||
},
|
||||
t(
|
||||
pendingModelSelection
|
||||
? 'documentParsing.ocr.notifications.installedAndSelected'
|
||||
: 'documentParsing.ocr.notifications.installed',
|
||||
{ name: modelDisplayName }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{pendingModelSelection
|
||||
? t('documentParsing.ocr.downloadAndSelect')
|
||||
: t('documentParsing.ocr.download')}
|
||||
</button>
|
||||
)}
|
||||
{!modelSourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={
|
||||
onOpenModelDownloadSourceSettings
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'documentParsing.ocr.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'documentParsing.ocr.accessibility.importModelZip',
|
||||
@@ -951,6 +1016,16 @@ export function DocumentParsingSettingsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installedModel && !modelSourceAvailable && (
|
||||
<p className="settings-warning">
|
||||
{t('documentParsing.ocr.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{modelOperation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -169,6 +169,7 @@ const onAnalysisEvent = vi.fn<
|
||||
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -180,6 +181,7 @@ beforeEach(() => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -680,6 +682,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -711,6 +714,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-manual',
|
||||
magicNoteCommentFormat: 'narrative'
|
||||
@@ -825,6 +829,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
MagicNoteCommentMode
|
||||
MagicNoteCommentMode,
|
||||
ModelDownloadSource
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
PageTabs,
|
||||
SegmentedControl
|
||||
} from './WorkspacePrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
@@ -13,14 +18,21 @@ import {
|
||||
|
||||
type PlatformFeaturesSettingsSectionProps = {
|
||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
}
|
||||
|
||||
type PlatformFeaturesTab = 'general' | 'magic-notes'
|
||||
|
||||
export function PlatformFeaturesSettingsSection({
|
||||
onMagicNotesEnabledChange
|
||||
onMagicNotesEnabledChange,
|
||||
onNotify
|
||||
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PlatformFeaturesTab>('general')
|
||||
const [settings, setSettings] = useState<ApplicationSettings>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [sourceError, setSourceError] = useState<string>()
|
||||
const [error, setError] = useState<string | undefined>(() =>
|
||||
window.goodbuddy.updates
|
||||
? undefined
|
||||
@@ -52,6 +64,45 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const changeModelDownloadSource = async (
|
||||
modelDownloadSource: ModelDownloadSource
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (
|
||||
!updates ||
|
||||
!settings ||
|
||||
settings.modelDownloadSource === modelDownloadSource
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSourceError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
modelDownloadSource
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t(
|
||||
'platformFeatures.modelDownloadSource.notification',
|
||||
{
|
||||
source: t(
|
||||
`modelDownloadSources.${nextSettings.modelDownloadSource}`
|
||||
)
|
||||
}
|
||||
),
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
} catch {
|
||||
setSourceError(
|
||||
t('platformFeatures.errors.saveModelDownloadSourceFailed')
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
@@ -120,84 +171,220 @@ export function PlatformFeaturesSettingsSection({
|
||||
headingId="platform-features-heading"
|
||||
/>
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
<section
|
||||
aria-label={t('platformFeatures.label')}
|
||||
className="settings-section"
|
||||
>
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>{t('platformFeatures.magicNotes.description')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings?.magicNotesEnabled ?? false}
|
||||
disabled={!settings || saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
<div className="platform-features-tabs">
|
||||
<PageTabs
|
||||
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
|
||||
idPrefix="platform-features"
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{
|
||||
id: 'general',
|
||||
label: t('platformFeatures.tabs.general')
|
||||
},
|
||||
{
|
||||
id: 'magic-notes',
|
||||
label: t('platformFeatures.tabs.magicNotes')
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>{t('platformFeatures.magicNotes.commentMode')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentModeAria')}
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t('platformFeatures.magicNotes.modes.immediate')
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t('platformFeatures.magicNotes.modes.afterSaveAuto')
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-general"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'general'}
|
||||
id="platform-features-panel-general"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>
|
||||
{t('platformFeatures.modelDownloadSource.cardTitle')}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'platformFeatures.modelDownloadSource.cardDescription'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="model-download-source">
|
||||
<legend>
|
||||
{t('platformFeatures.modelDownloadSource.title')}
|
||||
</legend>
|
||||
<p>
|
||||
{t('platformFeatures.modelDownloadSource.description')}
|
||||
</p>
|
||||
{(
|
||||
['modelscope', 'hugging-face'] as const
|
||||
).map((source) => (
|
||||
<label
|
||||
className={
|
||||
source === settings.modelDownloadSource
|
||||
? 'model-download-source__option model-download-source__option--selected'
|
||||
: 'model-download-source__option'
|
||||
}
|
||||
key={source}
|
||||
>
|
||||
<input
|
||||
checked={source === settings.modelDownloadSource}
|
||||
disabled={saving}
|
||||
name="model-download-source"
|
||||
onChange={() =>
|
||||
void changeModelDownloadSource(source)
|
||||
}
|
||||
type="radio"
|
||||
value={source}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t(`modelDownloadSources.${source}`)}</strong>
|
||||
<small>
|
||||
{t(
|
||||
`platformFeatures.modelDownloadSource.options.${source}`
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{sourceError && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{sourceError}
|
||||
</p>
|
||||
)}
|
||||
<p className="model-download-source__current">
|
||||
{t('platformFeatures.modelDownloadSource.current', {
|
||||
source: t(
|
||||
`modelDownloadSources.${settings.modelDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('platformFeatures.modelDownloadSource.activeDownloadNote')}
|
||||
</p>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-magic-notes"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'magic-notes'}
|
||||
id="platform-features-panel-magic-notes"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.description')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings.magicNotesEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
}
|
||||
]}
|
||||
value={settings?.magicNoteCommentMode ?? 'immediate'}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>{t('platformFeatures.magicNotes.commentFormat')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria')}
|
||||
disabled={!settings || saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t('platformFeatures.magicNotes.formats.combined')
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t('platformFeatures.magicNotes.formats.narrative')
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t('platformFeatures.magicNotes.formats.structured')
|
||||
}
|
||||
]}
|
||||
value={settings?.magicNoteCommentFormat ?? 'combined'}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentMode')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentModeAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.immediate'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveAuto'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentMode}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentFormat')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentFormatAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.combined'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.narrative'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.structured'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentFormat}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -396,6 +396,7 @@ const diagnoseEmbedding = vi.fn(
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -423,14 +424,25 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://example.com/sensevoice',
|
||||
license: {
|
||||
name: 'Model License',
|
||||
notice: 'Review the model license before use.',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: []
|
||||
files: [],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'paraformer-bilingual-zh-en-int8',
|
||||
@@ -442,20 +454,32 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
|
||||
quality: 'high',
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://example.com/paraformer',
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Review the model license before use.',
|
||||
url: 'https://example.com/license'
|
||||
},
|
||||
manualOnly: false,
|
||||
files: []
|
||||
files: [],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const createSpeechModelSnapshot = (
|
||||
selectedModelId: string | null = 'sensevoice-small-int8'
|
||||
): SpeechModelSnapshot => ({
|
||||
rootDirectory: 'C:\\Users\\test\\models\\speech',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: speechCatalog,
|
||||
installed: speechCatalog.map((model) => ({
|
||||
id: model.id,
|
||||
@@ -562,6 +586,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
applicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: false,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -1033,6 +1058,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
@@ -1066,6 +1094,111 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the global model download source from General settings', async () => {
|
||||
const onNotify = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onNotify={onNotify}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
expect(
|
||||
await screen.findByRole('tab', { name: '通用设置' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
const modelScope = screen.getByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '显示魔法笔记入口' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(huggingFace)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
})
|
||||
)
|
||||
expect(huggingFace).toBeChecked()
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '模型下载源已切换为 Hugging Face。',
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '魔法笔记' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '显示魔法笔记入口' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not guess a model download source when settings fail to load', async () => {
|
||||
getApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('read failed')
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
|
||||
expect(
|
||||
await screen.findByText('读取平台功能设置失败')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: /ModelScope/u })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('当前选择:ModelScope')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the confirmed model download source when saving fails', async () => {
|
||||
updateApplicationSettings.mockRejectedValueOnce(
|
||||
new Error('save failed')
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
const modelScope = await screen.findByRole('radio', {
|
||||
name: /ModelScope/u
|
||||
})
|
||||
const huggingFace = screen.getByRole('radio', {
|
||||
name: /Hugging Face/u
|
||||
})
|
||||
fireEvent.click(huggingFace)
|
||||
|
||||
expect(
|
||||
await screen.findByText('保存模型下载源失败,请重试')
|
||||
).toBeInTheDocument()
|
||||
expect(modelScope).toBeChecked()
|
||||
expect(huggingFace).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
@@ -1095,6 +1228,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
|
||||
@@ -1610,6 +1610,7 @@ export function SettingsPanel({
|
||||
{activeTab === 'platform-features' && (
|
||||
<PlatformFeaturesSettingsSection
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'runtime' && (
|
||||
@@ -2924,6 +2925,9 @@ export function SettingsPanel({
|
||||
{modelType === 'speech' && (
|
||||
<SpeechModelSettingsSection
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
setActiveTab('platform-features')
|
||||
}
|
||||
onSelectedModelIdChange={(modelId, changed) => {
|
||||
setSpeechModelDraftId(modelId)
|
||||
setSpeechModelSelectionDirty(changed)
|
||||
@@ -3119,7 +3123,12 @@ export function SettingsPanel({
|
||||
)}
|
||||
|
||||
{activeTab === 'document-parsing' && (
|
||||
<DocumentParsingSettingsSection onNotify={onNotify} />
|
||||
<DocumentParsingSettingsSection
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
setActiveTab('platform-features')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
|
||||
@@ -21,7 +21,6 @@ const entry = {
|
||||
quality: 'high' as const,
|
||||
speed: 'fast' as const,
|
||||
recommended: true,
|
||||
repositoryUrl: 'https://huggingface.co/example/model',
|
||||
license: {
|
||||
name: '模型仓库自定义许可',
|
||||
notice: '使用前请阅读许可。',
|
||||
@@ -32,26 +31,33 @@ const entry = {
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/model/resolve/revision/model.int8.onnx',
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
role: 'tokens' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/model/resolve/revision/tokens.txt',
|
||||
size: 100,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
size: 100,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const snapshot: SpeechModelSnapshot = {
|
||||
rootDirectory: 'C:\\Users\\test\\models\\speech',
|
||||
selectedDownloadSource: 'modelscope',
|
||||
catalog: [entry],
|
||||
installed: [],
|
||||
operations: [],
|
||||
@@ -105,7 +111,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(screen.queryByText('Model details')).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Open the SenseVoiceSmall INT8 model repository'
|
||||
name: 'Open the ModelScope repository for SenseVoiceSmall INT8'
|
||||
})
|
||||
)
|
||||
expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
@@ -159,7 +165,10 @@ describe('SpeechModelSettingsSection', () => {
|
||||
}))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
'sensevoice-small-int8',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
@@ -176,7 +185,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
family: 'whisper' as const,
|
||||
files: [
|
||||
{
|
||||
...entry.files[0],
|
||||
...entry.files[0]!,
|
||||
name: 'tiny-encoder.int8.onnx',
|
||||
role: 'encoder' as const
|
||||
}
|
||||
@@ -212,10 +221,81 @@ describe('SpeechModelSettingsSection', () => {
|
||||
}))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
'whisper-tiny-multilingual',
|
||||
'modelscope'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an unavailable source explicit and offers General settings', async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
const onOpenModelDownloadSourceSettings = vi.fn()
|
||||
const unavailableSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
catalog: [
|
||||
{
|
||||
...entry,
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: false,
|
||||
unavailableReason:
|
||||
'当前下载源暂不提供此模型的完整已验证文件'
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
speechModels: {
|
||||
getSnapshot: vi.fn(async () => unavailableSnapshot),
|
||||
install: vi.fn(),
|
||||
cancel: vi.fn(async () => true),
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
importArchive: vi.fn(),
|
||||
exportArchive: vi.fn(),
|
||||
openRepository: vi.fn(),
|
||||
openModelsDirectory: vi.fn()
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(
|
||||
<SpeechModelSettingsSection
|
||||
onOpenModelDownloadSourceSettings={
|
||||
onOpenModelDownloadSourceSettings
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText('当前来源不可下载')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: '下载 SenseVoiceSmall INT8'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '打开 SenseVoiceSmall INT8 的 ModelScope 模型仓库'
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '前往通用设置' })
|
||||
)
|
||||
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('imports and exports verified speech model ZIP archives', async () => {
|
||||
const installedSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
@@ -306,7 +386,8 @@ describe('SpeechModelSettingsSection', () => {
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.int8.onnx',
|
||||
completedBytes: 550,
|
||||
totalBytes: 1_100
|
||||
totalBytes: 1_100,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -332,6 +413,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(await screen.findByRole('progressbar', {
|
||||
name: 'SenseVoiceSmall INT8下载进度'
|
||||
})).toHaveValue(50)
|
||||
expect(screen.getByText('正在从 ModelScope 下载')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '取消 SenseVoiceSmall INT8 操作'
|
||||
}))
|
||||
@@ -350,7 +432,8 @@ describe('SpeechModelSettingsSection', () => {
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.int8.onnx',
|
||||
completedBytes: 550,
|
||||
totalBytes: 1_100
|
||||
totalBytes: 1_100,
|
||||
downloadSource: 'modelscope'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TFunction } from 'i18next'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
SpeechModelCatalogEntry,
|
||||
SpeechModelCatalogViewEntry,
|
||||
SpeechModelOperation,
|
||||
SpeechModelSnapshot
|
||||
} from '../../shared/speech-model-contracts'
|
||||
@@ -27,6 +27,7 @@ type SpeechModelSettingsSectionProps = {
|
||||
changed: boolean
|
||||
) => void
|
||||
onSelectionInvalidated?: (modelId: string | null) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -36,14 +37,8 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function catalogSize(entry: SpeechModelCatalogEntry): number | undefined {
|
||||
const downloads = entry.files.map((file) => file.download)
|
||||
return downloads.every(Boolean)
|
||||
? downloads.reduce(
|
||||
(total, download) => total + (download?.size ?? 0),
|
||||
0
|
||||
)
|
||||
: undefined
|
||||
function catalogSize(entry: SpeechModelCatalogViewEntry): number {
|
||||
return entry.files.reduce((total, file) => total + file.size, 0)
|
||||
}
|
||||
|
||||
function progressPercent(operation: SpeechModelOperation): number | undefined {
|
||||
@@ -65,11 +60,19 @@ function operationLabel(
|
||||
if (operation.phase === 'preparing') {
|
||||
return operation.kind === 'import'
|
||||
? t('speech.operations.preparingImport')
|
||||
: t('speech.operations.preparingDownload')
|
||||
: t('speech.operations.preparingDownloadFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
return operation.kind === 'import'
|
||||
? t('speech.operations.importing')
|
||||
: t('speech.operations.downloading')
|
||||
: t('speech.operations.downloadingFrom', {
|
||||
source: t(
|
||||
`modelDownloadSources.${operation.downloadSource}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function SpeechModelSettingsSection({
|
||||
@@ -77,7 +80,8 @@ export function SpeechModelSettingsSection({
|
||||
persistedSelectedModelId,
|
||||
selectedModelId,
|
||||
onSelectedModelIdChange,
|
||||
onSelectionInvalidated
|
||||
onSelectionInvalidated,
|
||||
onOpenModelDownloadSourceSettings
|
||||
}: SpeechModelSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [snapshot, setSnapshot] = useState<SpeechModelSnapshot>()
|
||||
@@ -306,6 +310,11 @@ export function SpeechModelSettingsSection({
|
||||
? progressPercent(operation)
|
||||
: undefined
|
||||
const size = model ? catalogSize(model) : undefined
|
||||
const downloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source === snapshot.selectedDownloadSource
|
||||
)
|
||||
const sourceAvailable = downloadAvailability?.available === true
|
||||
const selected = model?.id === effectiveSelectedModelId
|
||||
const inUse = model?.id === effectivePersistedModelId
|
||||
const pendingSelection =
|
||||
@@ -322,7 +331,9 @@ export function SpeechModelSettingsSection({
|
||||
? t('speech.status.installed')
|
||||
: model?.manualOnly
|
||||
? t('speech.status.manualImport')
|
||||
: t('speech.status.availableToDownload')
|
||||
: sourceAvailable
|
||||
? t('speech.status.availableToDownload')
|
||||
: t('speech.status.sourceUnavailable')
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -354,6 +365,13 @@ export function SpeechModelSettingsSection({
|
||||
<code>{snapshot.rootDirectory}</code>
|
||||
{t('speech.storageSuffix')}
|
||||
</p>
|
||||
<p className="settings-notice">
|
||||
{t('speech.downloadSource', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
{error && <p className="settings-warning" role="alert">{error}</p>}
|
||||
|
||||
<label className="field document-ocr-model-selector">
|
||||
@@ -383,7 +401,14 @@ export function SpeechModelSettingsSection({
|
||||
{optionName} ·{' '}
|
||||
{installedById.has(entry.id)
|
||||
? t('speech.status.installed')
|
||||
: t('speech.status.availableToDownload')}
|
||||
: entry.downloadAvailability.some(
|
||||
(availability) =>
|
||||
availability.source ===
|
||||
snapshot.selectedDownloadSource &&
|
||||
availability.available
|
||||
)
|
||||
? t('speech.status.availableToDownload')
|
||||
: t('speech.status.sourceUnavailable')}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
@@ -411,9 +436,15 @@ export function SpeechModelSettingsSection({
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
className="icon-button speech-model-card__repository"
|
||||
disabled={!sourceAvailable}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels?.openRepository(
|
||||
model.id
|
||||
@@ -421,7 +452,12 @@ export function SpeechModelSettingsSection({
|
||||
}
|
||||
title={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
{
|
||||
name: displayName,
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
}
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
@@ -430,6 +466,11 @@ export function SpeechModelSettingsSection({
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">
|
||||
{t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{t('speech.family.' + model.family)}
|
||||
</span>
|
||||
@@ -540,7 +581,7 @@ export function SpeechModelSettingsSection({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!model.manualOnly && (
|
||||
{!model.manualOnly && sourceAvailable && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.downloadModel',
|
||||
@@ -553,7 +594,8 @@ export function SpeechModelSettingsSection({
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.install(
|
||||
model.id
|
||||
model.id,
|
||||
snapshot.selectedDownloadSource
|
||||
),
|
||||
t('speech.notifications.installed', {
|
||||
name: displayName
|
||||
@@ -567,6 +609,19 @@ export function SpeechModelSettingsSection({
|
||||
{t('speech.actions.download')}
|
||||
</button>
|
||||
)}
|
||||
{!model.manualOnly &&
|
||||
!sourceAvailable &&
|
||||
onOpenModelDownloadSourceSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onOpenModelDownloadSourceSettings}
|
||||
type="button"
|
||||
>
|
||||
{t(
|
||||
'speech.actions.openDownloadSourceSettings'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.importModelZip',
|
||||
@@ -596,6 +651,16 @@ export function SpeechModelSettingsSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installed && !sourceAvailable && !model.manualOnly && (
|
||||
<p className="settings-warning">
|
||||
{t('speech.sourceUnavailableDescription', {
|
||||
source: t(
|
||||
`modelDownloadSources.${snapshot.selectedDownloadSource}`
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{operation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('UpdateSettingsSection', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate' as const,
|
||||
magicNoteCommentFormat: 'combined' as const
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { settings as chineseSettings } from '../zh-CN/settings'
|
||||
|
||||
export const settings = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
center: {
|
||||
eyebrow: 'Settings',
|
||||
title: 'Settings',
|
||||
@@ -472,7 +476,7 @@ export const settings = {
|
||||
ocrReady:
|
||||
'The model is installed, SHA-256 verified, and available offline',
|
||||
ocrUnavailable:
|
||||
'The model is not installed or failed verification. Download it from ModelScope.',
|
||||
'The model is not installed or failed verification. Get it from the current model download source.',
|
||||
partialNotice:
|
||||
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scenario modes can use local OCR for scanned PDFs.'
|
||||
},
|
||||
@@ -524,11 +528,13 @@ export const settings = {
|
||||
'This model selection is not active yet. Save settings to switch.',
|
||||
installedOption: 'Installed',
|
||||
downloadableOption: 'Available to download',
|
||||
sourceUnavailableOption: 'Unavailable from current source',
|
||||
unavailableOption: 'Unavailable in this version',
|
||||
openModelsDirectory: 'Open model folder',
|
||||
storagePrefix: 'Models are installed on demand in',
|
||||
storageSuffix:
|
||||
' and can be exported as ZIP archives for offline devices.',
|
||||
downloadSource: 'Current model download source: {{source}}',
|
||||
recommended: 'Recommended',
|
||||
quality: {
|
||||
label: 'Quality: {{value}}',
|
||||
@@ -576,18 +582,21 @@ export const settings = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
cancel: 'Cancel',
|
||||
openRepository: 'Open ModelScope',
|
||||
openRepository: 'Open {{source}}',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
catalogUnavailable:
|
||||
'No OCR model catalog is available in this version.',
|
||||
selectedModelUnavailable:
|
||||
'The saved OCR model is unavailable in this version. Select and install another model above.',
|
||||
installBeforeSelecting:
|
||||
'Download this model first. It will become the current model after installation.',
|
||||
'Download or import this model first. It will become the current model after installation.',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
privacyNotice:
|
||||
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
|
||||
operations: {
|
||||
preparing: 'Preparing model files',
|
||||
downloading: 'Downloading from ModelScope',
|
||||
downloading: 'Downloading from {{source}}',
|
||||
importing: 'Importing model ZIP',
|
||||
installing: 'Verifying and installing'
|
||||
},
|
||||
@@ -598,7 +607,7 @@ export const settings = {
|
||||
deleteModel: 'Delete {{name}}',
|
||||
cancelOperation: 'Cancel {{name}} operation',
|
||||
downloadProgress: '{{name}} download progress',
|
||||
openRepository: 'Open the ModelScope page for {{name}}'
|
||||
openRepository: 'Open the {{source}} page for {{name}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
} from '../zh-CN/settingsSections'
|
||||
|
||||
export const settingsSections = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
speech: {
|
||||
title: 'Speech models',
|
||||
description:
|
||||
@@ -12,6 +16,7 @@ export const settingsSections = {
|
||||
storagePrefix: 'Models are stored in',
|
||||
storageSuffix:
|
||||
'. Automatic downloads pin the source revision and verify SHA-256 hashes. Export a ZIP on an online device and import it directly on an offline device.',
|
||||
downloadSource: 'Current model download source: {{source}}',
|
||||
modelSelector: 'Current speech model',
|
||||
modelSelectorDescription:
|
||||
'Choose an installed model, then select Save settings to switch speech recognition models.',
|
||||
@@ -45,9 +50,9 @@ export const settingsSections = {
|
||||
operations: {
|
||||
installing: 'Verifying and installing',
|
||||
preparingImport: 'Preparing import',
|
||||
preparingDownload: 'Preparing download',
|
||||
preparingDownloadFrom: 'Preparing to download from {{source}}',
|
||||
importing: 'Importing',
|
||||
downloading: 'Downloading',
|
||||
downloadingFrom: 'Downloading from {{source}}',
|
||||
processingFile: 'Processing {{file}}'
|
||||
},
|
||||
status: {
|
||||
@@ -56,6 +61,7 @@ export const settingsSections = {
|
||||
installed: 'Installed',
|
||||
manualImport: 'Manual import',
|
||||
availableToDownload: 'Available to download',
|
||||
sourceUnavailable: 'Unavailable from current source',
|
||||
unknownSize: 'Unknown size'
|
||||
},
|
||||
tags: {
|
||||
@@ -66,6 +72,7 @@ export const settingsSections = {
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
download: 'Download',
|
||||
openDownloadSourceSettings: 'Open General settings',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP'
|
||||
},
|
||||
@@ -76,7 +83,7 @@ export const settingsSections = {
|
||||
importModelZip: 'Import {{name}} from a ZIP archive',
|
||||
exportModelZip: 'Export {{name}} as a ZIP archive',
|
||||
downloadProgress: '{{name}} download progress',
|
||||
openRepository: 'Open the {{name}} model repository'
|
||||
openRepository: 'Open the {{source}} repository for {{name}}'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} installed',
|
||||
@@ -84,6 +91,8 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} exported as ZIP',
|
||||
removed: 'Speech model deleted'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
|
||||
languages: {
|
||||
中文: 'Chinese',
|
||||
粤语: 'Cantonese',
|
||||
@@ -211,6 +220,7 @@ export const settingsSections = {
|
||||
'No roles yet. Create a role to configure its system prompt.'
|
||||
},
|
||||
platformFeatures: {
|
||||
loading: 'Loading platform feature settings…',
|
||||
errors: {
|
||||
serviceUnavailable:
|
||||
'Application settings are not available in this version',
|
||||
@@ -219,9 +229,34 @@ export const settingsSections = {
|
||||
saveCommentModeFailed:
|
||||
'Could not save the AI comment mode. Try again.',
|
||||
saveCommentFormatFailed:
|
||||
'Could not save the AI comment format. Try again.'
|
||||
'Could not save the AI comment format. Try again.',
|
||||
saveModelDownloadSourceFailed:
|
||||
'Could not save the model download source. Try again.'
|
||||
},
|
||||
label: 'Platform feature options',
|
||||
tabs: {
|
||||
ariaLabel: 'Platform feature settings',
|
||||
general: 'General',
|
||||
magicNotes: 'Magic Notes'
|
||||
},
|
||||
modelDownloadSource: {
|
||||
cardTitle: 'Local models',
|
||||
cardDescription:
|
||||
'Manage how GoodBuddy-managed local models are downloaded',
|
||||
title: 'Model download source',
|
||||
description:
|
||||
'Choose the platform for future GoodBuddy-managed local model downloads. Installed models, ZIP imports, Ollama models, and app updates are not affected.',
|
||||
options: {
|
||||
modelscope:
|
||||
'Default. Use when your network prioritizes access to ModelScope.',
|
||||
'hugging-face':
|
||||
'Use when your network can access Hugging Face reliably.'
|
||||
},
|
||||
current: 'Current selection: {{source}}',
|
||||
activeDownloadNote:
|
||||
'Downloads already in progress keep the source they started with. New downloads use the current selection.',
|
||||
notification: 'Model download source changed to {{source}}.'
|
||||
},
|
||||
magicNotes: {
|
||||
title: 'Magic Notes',
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const settings = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
center: {
|
||||
eyebrow: '设置',
|
||||
title: '设置中心',
|
||||
@@ -431,7 +435,7 @@ export const settings = {
|
||||
localOcr: '本地 OCR',
|
||||
localOcrModel: '当前 OCR:{{name}}',
|
||||
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载',
|
||||
ocrUnavailable: '模型尚未安装或校验失败,请从当前模型下载源获取',
|
||||
partialNotice:
|
||||
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。'
|
||||
},
|
||||
@@ -477,10 +481,12 @@ export const settings = {
|
||||
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
|
||||
installedOption: '已安装',
|
||||
downloadableOption: '可下载',
|
||||
sourceUnavailableOption: '当前来源不可下载',
|
||||
unavailableOption: '当前版本不可用',
|
||||
openModelsDirectory: '打开模型目录',
|
||||
storagePrefix: '模型按需安装到',
|
||||
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
|
||||
downloadSource: '当前模型下载源:{{source}}',
|
||||
recommended: '推荐',
|
||||
quality: {
|
||||
label: '质量:{{value}}',
|
||||
@@ -528,16 +534,20 @@ export const settings = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
cancel: '取消',
|
||||
openRepository: '打开 ModelScope',
|
||||
openRepository: '打开 {{source}}',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
|
||||
selectedModelUnavailable:
|
||||
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
|
||||
installBeforeSelecting: '请先下载该模型;下载完成后会自动设为当前模型。',
|
||||
installBeforeSelecting:
|
||||
'请先下载或导入该模型;安装完成后会自动设为当前模型。',
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
privacyNotice:
|
||||
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
|
||||
operations: {
|
||||
preparing: '正在准备模型文件',
|
||||
downloading: '正在从 ModelScope 下载',
|
||||
downloading: '正在从 {{source}} 下载',
|
||||
importing: '正在导入模型 ZIP',
|
||||
installing: '正在校验并安装'
|
||||
},
|
||||
@@ -548,7 +558,7 @@ export const settings = {
|
||||
deleteModel: '删除 {{name}}',
|
||||
cancelOperation: '取消 {{name}} 操作',
|
||||
downloadProgress: '{{name}} 下载进度',
|
||||
openRepository: '打开 {{name}} 的 ModelScope 页面'
|
||||
openRepository: '打开 {{name}} 的 {{source}} 页面'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const settingsSections = {
|
||||
modelDownloadSources: {
|
||||
modelscope: 'ModelScope',
|
||||
'hugging-face': 'Hugging Face'
|
||||
},
|
||||
speech: {
|
||||
title: '语音模型',
|
||||
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
|
||||
@@ -6,6 +10,7 @@ export const settingsSections = {
|
||||
storagePrefix: '模型保存在',
|
||||
storageSuffix:
|
||||
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
|
||||
downloadSource: '当前模型下载源:{{source}}',
|
||||
modelSelector: '当前语音模型',
|
||||
modelSelectorDescription:
|
||||
'选择已安装模型后,点击“保存设置”切换语音识别模型。',
|
||||
@@ -37,9 +42,9 @@ export const settingsSections = {
|
||||
operations: {
|
||||
installing: '正在校验并安装',
|
||||
preparingImport: '正在准备导入',
|
||||
preparingDownload: '正在准备下载',
|
||||
preparingDownloadFrom: '正在准备从 {{source}} 下载',
|
||||
importing: '正在导入',
|
||||
downloading: '正在下载',
|
||||
downloadingFrom: '正在从 {{source}} 下载',
|
||||
processingFile: '正在处理 {{file}}'
|
||||
},
|
||||
status: {
|
||||
@@ -48,6 +53,7 @@ export const settingsSections = {
|
||||
installed: '已安装',
|
||||
manualImport: '手动导入',
|
||||
availableToDownload: '可下载',
|
||||
sourceUnavailable: '当前来源不可下载',
|
||||
unknownSize: '大小未知'
|
||||
},
|
||||
tags: {
|
||||
@@ -58,6 +64,7 @@ export const settingsSections = {
|
||||
delete: '删除',
|
||||
confirmDelete: '确认删除',
|
||||
download: '下载',
|
||||
openDownloadSourceSettings: '前往通用设置',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP'
|
||||
},
|
||||
@@ -68,7 +75,7 @@ export const settingsSections = {
|
||||
importModelZip: '从 ZIP 导入 {{name}}',
|
||||
exportModelZip: '将 {{name}} 导出为 ZIP',
|
||||
downloadProgress: '{{name}}下载进度',
|
||||
openRepository: '打开 {{name}} 模型仓库'
|
||||
openRepository: '打开 {{name}} 的 {{source}} 模型仓库'
|
||||
},
|
||||
notifications: {
|
||||
installed: '{{name}} 已安装',
|
||||
@@ -76,6 +83,8 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} 已导出为 ZIP',
|
||||
removed: '语音模型已删除'
|
||||
},
|
||||
sourceUnavailableDescription:
|
||||
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
|
||||
languages: {
|
||||
中文: '中文',
|
||||
粤语: '粤语',
|
||||
@@ -197,14 +206,36 @@ export const settingsSections = {
|
||||
empty: '还没有角色。新建角色后,可以为它配置系统提示词。'
|
||||
},
|
||||
platformFeatures: {
|
||||
loading: '正在读取平台功能设置…',
|
||||
errors: {
|
||||
serviceUnavailable: '当前版本未提供应用设置服务',
|
||||
readFailed: '读取平台功能设置失败',
|
||||
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
|
||||
saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
|
||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试'
|
||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
|
||||
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
|
||||
},
|
||||
label: '平台功能选项',
|
||||
tabs: {
|
||||
ariaLabel: '平台功能设置',
|
||||
general: '通用设置',
|
||||
magicNotes: '魔法笔记'
|
||||
},
|
||||
modelDownloadSource: {
|
||||
cardTitle: '本地模型',
|
||||
cardDescription: '管理 GoodBuddy 托管本地模型的获取方式',
|
||||
title: '模型下载源',
|
||||
description:
|
||||
'选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama 模型和应用更新不受影响。',
|
||||
options: {
|
||||
modelscope: '默认,适合优先访问 ModelScope 的网络环境。',
|
||||
'hugging-face': '适合可以稳定访问 Hugging Face 的网络环境。'
|
||||
},
|
||||
current: '当前选择:{{source}}',
|
||||
activeDownloadNote:
|
||||
'正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。',
|
||||
notification: '模型下载源已切换为 {{source}}。'
|
||||
},
|
||||
magicNotes: {
|
||||
title: '魔法笔记',
|
||||
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
|
||||
|
||||
@@ -5189,6 +5189,83 @@ button > svg {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.platform-features-tabs {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.platform-features-tabs + .settings-section,
|
||||
.platform-features-tabs ~ .settings-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.platform-features-tabs ~ .settings-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-download-source {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.model-download-source legend {
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.model-download-source > p {
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.model-download-source__option {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-download-source__option--selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-selected);
|
||||
}
|
||||
|
||||
.model-download-source__option:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.model-download-source__option input {
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.model-download-source__option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.model-download-source__option small {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.model-download-source__current {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-page .settings-tabs {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
|
||||
import { modelDownloadSourceSchema } from './model-download-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export {
|
||||
modelDownloadSourceSchema,
|
||||
type ModelDownloadSource
|
||||
} from './model-download-contracts'
|
||||
|
||||
export const magicNoteCommentModeSchema = z.enum([
|
||||
'immediate',
|
||||
'after-save-auto',
|
||||
@@ -22,6 +28,7 @@ const applicationPreferencesSchema = z
|
||||
.object({
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
updateSource: updateSourceSchema,
|
||||
modelDownloadSource: modelDownloadSourceSchema,
|
||||
magicNotesEnabled: z.boolean(),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema,
|
||||
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
||||
@@ -48,6 +55,7 @@ export type ApplicationSettingsUpdate = z.infer<
|
||||
typeof applicationSettingsUpdateSchema
|
||||
>
|
||||
|
||||
|
||||
export type VersionCheckFile = {
|
||||
name: string
|
||||
size: number
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
ApplicationSettingsUpdate,
|
||||
ModelDownloadSource,
|
||||
VersionCheckResult
|
||||
} from './application-settings-contracts'
|
||||
import type { ReleaseNotesSnapshot } from './release-notes-contracts'
|
||||
@@ -1373,7 +1374,10 @@ export type DesktopApi = {
|
||||
}
|
||||
speechModels?: {
|
||||
getSnapshot: () => Promise<SpeechModelSnapshot>
|
||||
install: (modelId: string) => Promise<SpeechModelSnapshot>
|
||||
install: (
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) => Promise<SpeechModelSnapshot>
|
||||
cancel: (modelId: string) => Promise<boolean>
|
||||
remove: (modelId: string) => Promise<SpeechModelSnapshot>
|
||||
select: (modelId: string | null) => Promise<SpeechModelSnapshot>
|
||||
@@ -1405,7 +1409,8 @@ export type DesktopApi = {
|
||||
purpose: DocumentParsingTestPurpose
|
||||
) => Promise<DocumentParsingDiagnostic | undefined>
|
||||
installOcrModel: (
|
||||
modelId: string
|
||||
modelId: string,
|
||||
expectedDownloadSource: ModelDownloadSource
|
||||
) => Promise<DocumentParsingSnapshot>
|
||||
cancelOcrModelOperation: (modelId: string) => Promise<boolean>
|
||||
removeOcrModel: (
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
modelArtifactIdentitySchema,
|
||||
modelDownloadAvailabilitySchema,
|
||||
modelDownloadSourceSchema
|
||||
} from './model-download-contracts'
|
||||
import { settingsWarningsSchema } from './settings-warning-contracts'
|
||||
|
||||
export const maximumDocumentExtractedCharacters = 5_000_000
|
||||
@@ -36,9 +42,8 @@ export const localOcrModelIdSchema = z
|
||||
.max(96)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)
|
||||
|
||||
const documentOcrSha256Schema = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
const documentOcrSha256Schema =
|
||||
modelArtifactIdentitySchema.shape.sha256
|
||||
|
||||
export const documentOcrModelFileRoleSchema = z.enum([
|
||||
'detection',
|
||||
@@ -46,13 +51,8 @@ export const documentOcrModelFileRoleSchema = z.enum([
|
||||
'dictionary'
|
||||
])
|
||||
|
||||
export const documentOcrModelDownloadSchema = z
|
||||
.object({
|
||||
url: z.url().max(2_048),
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: documentOcrSha256Schema
|
||||
})
|
||||
.strict()
|
||||
export const documentOcrModelArtifactSchema =
|
||||
modelArtifactIdentitySchema
|
||||
|
||||
export const documentOcrModelFileSchema = z
|
||||
.object({
|
||||
@@ -62,7 +62,9 @@ export const documentOcrModelFileSchema = z
|
||||
.max(255)
|
||||
.regex(/^[^/\\\0]+$/u),
|
||||
role: documentOcrModelFileRoleSchema,
|
||||
download: documentOcrModelDownloadSchema
|
||||
size: documentOcrModelArtifactSchema.shape.size,
|
||||
sha256: documentOcrModelArtifactSchema.shape.sha256,
|
||||
targets: documentOcrModelArtifactSchema.shape.targets
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -76,7 +78,12 @@ export const documentOcrModelCatalogEntrySchema = z
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
repositoryUrls: z
|
||||
.object({
|
||||
modelscope: z.url().max(2_048).optional(),
|
||||
'hugging-face': z.url().max(2_048).optional()
|
||||
})
|
||||
.strict(),
|
||||
license: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -108,8 +115,64 @@ export const documentOcrModelCatalogEntrySchema = z
|
||||
message: 'OCR 模型文件角色不能重复'
|
||||
})
|
||||
}
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
const targets = entry.files
|
||||
.map((file) => file.targets[source])
|
||||
.filter((target) => target !== undefined)
|
||||
if (
|
||||
targets.length > 0 &&
|
||||
(!entry.repositoryUrls[source] ||
|
||||
!targets.some(
|
||||
(target) =>
|
||||
target.repositoryUrl === entry.repositoryUrls[source]
|
||||
))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['repositoryUrls', source],
|
||||
message:
|
||||
'OCR 模型仓库地址必须对应到该下载源的一个文件目标'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
!MODEL_DOWNLOAD_SOURCES.some((source) =>
|
||||
entry.files.every((file) => file.targets[source])
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files'],
|
||||
message: 'OCR 模型必须至少由一个下载源提供完整文件'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const documentOcrModelCatalogViewEntrySchema =
|
||||
z
|
||||
.object({
|
||||
id: localOcrModelIdSchema,
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
|
||||
runtime: z.literal('onnxruntime-web-wasm'),
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
license: documentOcrModelCatalogEntrySchema.shape.license,
|
||||
files: z
|
||||
.array(
|
||||
documentOcrModelFileSchema.omit({
|
||||
targets: true
|
||||
})
|
||||
)
|
||||
.length(3),
|
||||
downloadAvailability: z
|
||||
.array(modelDownloadAvailabilitySchema)
|
||||
.length(MODEL_DOWNLOAD_SOURCES.length)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const installedDocumentOcrModelSchema = z
|
||||
.object({
|
||||
id: localOcrModelIdSchema,
|
||||
@@ -138,14 +201,28 @@ export const documentOcrModelOperationSchema = z
|
||||
phase: z.enum(['preparing', 'transferring', 'installing']),
|
||||
currentFile: z.string().min(1).max(255).nullable(),
|
||||
completedBytes: z.number().int().nonnegative().safe(),
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable()
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable(),
|
||||
downloadSource: modelDownloadSourceSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((operation, context) => {
|
||||
if (
|
||||
(operation.kind === 'download') !==
|
||||
(operation.downloadSource !== undefined)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['downloadSource'],
|
||||
message: 'OCR 下载操作必须且仅能包含下载源'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const documentOcrModelSnapshotSchema = z
|
||||
.object({
|
||||
rootDirectory: z.string().min(1).max(32_768),
|
||||
catalog: z.array(documentOcrModelCatalogEntrySchema).max(16),
|
||||
selectedDownloadSource: modelDownloadSourceSchema,
|
||||
catalog: z.array(documentOcrModelCatalogViewEntrySchema).max(16),
|
||||
installed: z.array(installedDocumentOcrModelSchema).max(16),
|
||||
operations: z.array(documentOcrModelOperationSchema).max(8)
|
||||
})
|
||||
@@ -157,6 +234,13 @@ export const documentOcrModelActionInputSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentOcrModelInstallInputSchema = z
|
||||
.object({
|
||||
modelId: localOcrModelIdSchema,
|
||||
expectedDownloadSource: modelDownloadSourceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentParsingSettingsSchema = z
|
||||
.object({
|
||||
chatWorkflow: chatDocumentWorkflowSchema,
|
||||
@@ -349,6 +433,9 @@ export type DocumentOcrModelFile = z.infer<
|
||||
export type DocumentOcrModelCatalogEntry = z.infer<
|
||||
typeof documentOcrModelCatalogEntrySchema
|
||||
>
|
||||
export type DocumentOcrModelCatalogViewEntry = z.infer<
|
||||
typeof documentOcrModelCatalogViewEntrySchema
|
||||
>
|
||||
export type InstalledDocumentOcrModel = z.infer<
|
||||
typeof installedDocumentOcrModelSchema
|
||||
>
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
application: {
|
||||
checkUpdatesOnStartup: true,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
magicNotesEnabled: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
const immutableRevisionPattern = /^[a-f0-9]{40,64}$/u
|
||||
const hostNamePattern =
|
||||
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$/u
|
||||
|
||||
export const modelDownloadSourceSchema = z.enum([
|
||||
'modelscope',
|
||||
'hugging-face'
|
||||
])
|
||||
|
||||
export type ModelDownloadSource = z.infer<
|
||||
typeof modelDownloadSourceSchema
|
||||
>
|
||||
|
||||
export const MODEL_DOWNLOAD_SOURCES = modelDownloadSourceSchema.options
|
||||
export const MODEL_DOWNLOAD_REDIRECT_HOSTS = {
|
||||
modelscope: [],
|
||||
'hugging-face': [
|
||||
'cdn-lfs.hf.co',
|
||||
'cdn-lfs-us-1.hf.co',
|
||||
'cdn-lfs-eu-1.hf.co',
|
||||
'cas-bridge.xethub.hf.co'
|
||||
]
|
||||
} as const satisfies Record<
|
||||
ModelDownloadSource,
|
||||
readonly string[]
|
||||
>
|
||||
|
||||
function isSourceHost(
|
||||
source: ModelDownloadSource,
|
||||
hostname: string
|
||||
): boolean {
|
||||
return source === 'modelscope'
|
||||
? hostname === 'modelscope.cn' || hostname === 'www.modelscope.cn'
|
||||
: hostname === 'huggingface.co'
|
||||
}
|
||||
|
||||
export const modelArtifactTargetSchema = z
|
||||
.object({
|
||||
url: z.url().max(2_048),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
revision: z.string().regex(immutableRevisionPattern),
|
||||
redirectHosts: z
|
||||
.array(z.string().max(253).regex(hostNamePattern))
|
||||
.max(16)
|
||||
.default([])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((target, context) => {
|
||||
for (const [key, value] of [
|
||||
['url', target.url],
|
||||
['repositoryUrl', target.repositoryUrl]
|
||||
] as const) {
|
||||
const parsed = new URL(value)
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
(parsed.port !== '' && parsed.port !== '443') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.hash
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [key],
|
||||
message:
|
||||
'模型地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL'
|
||||
})
|
||||
}
|
||||
}
|
||||
const encodedRevision = encodeURIComponent(target.revision)
|
||||
const downloadUrl = new URL(target.url)
|
||||
const repositoryUrl = new URL(target.repositoryUrl)
|
||||
const repositoryPath = repositoryUrl.pathname.replace(/\/+$/u, '')
|
||||
if (
|
||||
downloadUrl.origin !== repositoryUrl.origin ||
|
||||
!downloadUrl.pathname.startsWith(
|
||||
`${repositoryPath}/resolve/${encodedRevision}/`
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['url'],
|
||||
message: '模型下载地址必须属于声明仓库并包含固定 Revision'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const modelArtifactTargetsSchema = z
|
||||
.object({
|
||||
modelscope: modelArtifactTargetSchema.optional(),
|
||||
'hugging-face': modelArtifactTargetSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((targets, context) => {
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
const target = targets[source]
|
||||
if (!target) {
|
||||
continue
|
||||
}
|
||||
const downloadHost = new URL(target.url).hostname
|
||||
const repositoryHost = new URL(target.repositoryUrl).hostname
|
||||
if (
|
||||
!isSourceHost(source, downloadHost) ||
|
||||
!isSourceHost(source, repositoryHost)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [source],
|
||||
message: '模型地址与声明的下载源不匹配'
|
||||
})
|
||||
}
|
||||
const allowedRedirectHosts: ReadonlySet<string> = new Set(
|
||||
MODEL_DOWNLOAD_REDIRECT_HOSTS[source]
|
||||
)
|
||||
target.redirectHosts.forEach((hostname, index) => {
|
||||
if (!allowedRedirectHosts.has(hostname)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [source, 'redirectHosts', index],
|
||||
message: '模型重定向主机不属于声明的下载源'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const modelDownloadAvailabilitySchema = z
|
||||
.object({
|
||||
source: modelDownloadSourceSchema,
|
||||
available: z.boolean(),
|
||||
totalBytes: z.number().int().positive().safe().optional(),
|
||||
unavailableReason: z.string().trim().min(1).max(500).optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((availability, context) => {
|
||||
if (availability.available && availability.totalBytes === undefined) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['totalBytes'],
|
||||
message: '可下载模型必须提供总大小'
|
||||
})
|
||||
}
|
||||
if (
|
||||
!availability.available &&
|
||||
availability.unavailableReason === undefined
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['unavailableReason'],
|
||||
message: '不可下载模型必须说明原因'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type ModelArtifactTarget = z.infer<
|
||||
typeof modelArtifactTargetSchema
|
||||
>
|
||||
export type ModelArtifactTargets = z.infer<
|
||||
typeof modelArtifactTargetsSchema
|
||||
>
|
||||
export type ModelDownloadAvailability = z.infer<
|
||||
typeof modelDownloadAvailabilitySchema
|
||||
>
|
||||
|
||||
export type ResolvableModelArtifactFile<Role extends string = string> = {
|
||||
name: string
|
||||
role: Role
|
||||
size: number
|
||||
sha256: string
|
||||
targets: ModelArtifactTargets
|
||||
}
|
||||
|
||||
export type ResolvedModelArtifactFile<Role extends string = string> = {
|
||||
name: string
|
||||
role: Role
|
||||
size: number
|
||||
sha256: string
|
||||
target: ModelArtifactTarget
|
||||
}
|
||||
|
||||
export type ResolvedModelPackage<Role extends string = string> = {
|
||||
source: ModelDownloadSource
|
||||
totalBytes: number
|
||||
files: ResolvedModelArtifactFile<Role>[]
|
||||
}
|
||||
|
||||
export function getModelDownloadAvailability(
|
||||
files: readonly ResolvableModelArtifactFile[],
|
||||
source: ModelDownloadSource
|
||||
): ModelDownloadAvailability {
|
||||
const available =
|
||||
files.length > 0 && files.every((file) => file.targets[source])
|
||||
if (!available) {
|
||||
return modelDownloadAvailabilitySchema.parse({
|
||||
source,
|
||||
available: false,
|
||||
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
|
||||
})
|
||||
}
|
||||
const totalBytes = files.reduce((total, file) => total + file.size, 0)
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
|
||||
throw new RangeError('模型总大小超出安全范围')
|
||||
}
|
||||
return modelDownloadAvailabilitySchema.parse({
|
||||
source,
|
||||
available: true,
|
||||
totalBytes
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveModelDownloadPackage<Role extends string>(
|
||||
files: readonly ResolvableModelArtifactFile<Role>[],
|
||||
source: ModelDownloadSource
|
||||
): ResolvedModelPackage<Role> {
|
||||
const availability = getModelDownloadAvailability(files, source)
|
||||
if (!availability.available || availability.totalBytes === undefined) {
|
||||
throw new Error(
|
||||
availability.unavailableReason ??
|
||||
'当前下载源暂不提供此模型的完整已验证文件'
|
||||
)
|
||||
}
|
||||
return {
|
||||
source,
|
||||
totalBytes: availability.totalBytes,
|
||||
files: files.map((file) => {
|
||||
const target = file.targets[source]
|
||||
if (!target) {
|
||||
throw new Error('模型下载元数据不完整')
|
||||
}
|
||||
return {
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
target
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const modelArtifactIdentitySchema = z
|
||||
.object({
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(sha256Pattern),
|
||||
targets: modelArtifactTargetsSchema
|
||||
})
|
||||
.strict()
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
speechModelSnapshotSchema
|
||||
} from './speech-model-contracts'
|
||||
|
||||
const revision = 'a'.repeat(40)
|
||||
const repositoryUrl =
|
||||
'https://huggingface.co/example/test-speech-model'
|
||||
const downloadableEntry = {
|
||||
id: 'test-speech-model',
|
||||
displayName: 'Test speech model',
|
||||
@@ -15,7 +18,9 @@ const downloadableEntry = {
|
||||
quality: 'balanced' as const,
|
||||
speed: 'balanced' as const,
|
||||
recommended: false,
|
||||
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
|
||||
repositoryUrls: {
|
||||
'hugging-face': repositoryUrl
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
notice: 'Test license notice.',
|
||||
@@ -26,36 +31,175 @@ const downloadableEntry = {
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
download: {
|
||||
url: 'https://huggingface.co/example/test/resolve/main/model.onnx',
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64)
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
url: `${repositoryUrl}/resolve/${revision}/model.onnx`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
redirectHosts: ['cdn-lfs.hf.co']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const catalogView = {
|
||||
id: downloadableEntry.id,
|
||||
displayName: downloadableEntry.displayName,
|
||||
description: downloadableEntry.description,
|
||||
languages: downloadableEntry.languages,
|
||||
family: downloadableEntry.family,
|
||||
quantization: downloadableEntry.quantization,
|
||||
quality: downloadableEntry.quality,
|
||||
speed: downloadableEntry.speed,
|
||||
recommended: downloadableEntry.recommended,
|
||||
license: downloadableEntry.license,
|
||||
manualOnly: false,
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
],
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope' as const,
|
||||
available: false,
|
||||
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
|
||||
},
|
||||
{
|
||||
source: 'hugging-face' as const,
|
||||
available: true,
|
||||
totalBytes: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('speech model contracts', () => {
|
||||
it('requires verified download metadata for every automatic file', () => {
|
||||
it('requires canonical identity and one complete verified source', () => {
|
||||
expect(speechModelCatalogEntrySchema.parse(downloadableEntry)).toEqual(
|
||||
downloadableEntry
|
||||
)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [{ name: 'model.onnx', role: 'model' }]
|
||||
files: [
|
||||
{
|
||||
name: 'model.onnx',
|
||||
role: 'model',
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...downloadableEntry.files[0]!.targets[
|
||||
'hugging-face'
|
||||
],
|
||||
url:
|
||||
'https://huggingface.co/example/another-model/' +
|
||||
`resolve/${revision}/model.onnx`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects mutable revisions and source-host mismatches', () => {
|
||||
const target =
|
||||
downloadableEntry.files[0]!.targets['hugging-face']
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...target,
|
||||
revision: 'main',
|
||||
url: `${repositoryUrl}/resolve/main/model.onnx`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
modelscope: {
|
||||
...target
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
files: [
|
||||
{
|
||||
...downloadableEntry.files[0],
|
||||
targets: {
|
||||
'hugging-face': {
|
||||
...target,
|
||||
redirectHosts: ['modelscope.cn']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps repository links consistent with source targets', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
repositoryUrls: {
|
||||
'hugging-face':
|
||||
'https://huggingface.co/example/another-model'
|
||||
}
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a reason for manual-only models and rejects duplicate files', () => {
|
||||
const manualFile = {
|
||||
name: 'model.onnx',
|
||||
role: 'model' as const,
|
||||
size: 12,
|
||||
sha256: 'a'.repeat(64),
|
||||
targets: {}
|
||||
}
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
manualOnly: true,
|
||||
files: [
|
||||
{ name: 'model.onnx', role: 'model' },
|
||||
{ name: 'model.onnx', role: 'tokens' }
|
||||
manualFile,
|
||||
{ ...manualFile, role: 'tokens' }
|
||||
]
|
||||
}).success
|
||||
).toBe(false)
|
||||
@@ -63,13 +207,55 @@ describe('speech model contracts', () => {
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
manualOnly: true,
|
||||
manualReason: '上游没有可核验的大小和摘要。',
|
||||
files: [{ name: 'model.onnx', role: 'model' }]
|
||||
manualReason: '上游没有可核验的下载目标。',
|
||||
files: [manualFile]
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects traversal, unknown fields, and malformed snapshots', () => {
|
||||
it('keeps renderer snapshots URL-free and validates frozen sources', () => {
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [catalogView],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1,
|
||||
totalBytes: 12,
|
||||
downloadSource: 'hugging-face'
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
selectedDownloadSource: 'hugging-face',
|
||||
catalog: [catalogView],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: 1,
|
||||
totalBytes: 12
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects traversal and unknown local-directory fields', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
...downloadableEntry,
|
||||
@@ -88,23 +274,5 @@ describe('speech model contracts', () => {
|
||||
copyEverything: true
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
speechModelSnapshotSchema.safeParse({
|
||||
rootDirectory: 'C:\\models\\speech',
|
||||
catalog: [downloadableEntry],
|
||||
installed: [],
|
||||
operations: [
|
||||
{
|
||||
modelId: 'test-speech-model',
|
||||
kind: 'download',
|
||||
phase: 'transferring',
|
||||
currentFile: 'model.onnx',
|
||||
completedBytes: -1,
|
||||
totalBytes: 12
|
||||
}
|
||||
],
|
||||
selectedModelId: null
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
modelArtifactIdentitySchema,
|
||||
modelDownloadAvailabilitySchema,
|
||||
modelDownloadSourceSchema
|
||||
} from './model-download-contracts'
|
||||
|
||||
const safeIdentifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
|
||||
const safeFileNamePattern =
|
||||
/^(?!\.{1,2}$)(?!.*(?:^|[\\/])\.{1,2}(?:[\\/]|$))[^/\\\0]+$/u
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
export const SPEECH_TRANSCRIPTION_SAMPLE_RATE = 16_000
|
||||
export const SPEECH_TRANSCRIPTION_MAX_SECONDS = 20
|
||||
export const SPEECH_TRANSCRIPTION_MAX_SAMPLES =
|
||||
@@ -29,19 +34,15 @@ export const speechModelFileRoleSchema = z.enum([
|
||||
'configuration'
|
||||
])
|
||||
|
||||
export const speechModelDownloadSchema = z
|
||||
.object({
|
||||
url: z.url().max(2_048),
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(sha256Pattern)
|
||||
})
|
||||
.strict()
|
||||
export const speechModelArtifactSchema = modelArtifactIdentitySchema
|
||||
|
||||
export const speechModelFileSpecSchema = z
|
||||
.object({
|
||||
name: speechModelFileNameSchema,
|
||||
role: speechModelFileRoleSchema,
|
||||
download: speechModelDownloadSchema.optional()
|
||||
size: speechModelArtifactSchema.shape.size,
|
||||
sha256: speechModelArtifactSchema.shape.sha256,
|
||||
targets: speechModelArtifactSchema.shape.targets
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -64,7 +65,12 @@ export const speechModelCatalogEntrySchema = z
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
repositoryUrls: z
|
||||
.object({
|
||||
modelscope: z.url().max(2_048).optional(),
|
||||
'hugging-face': z.url().max(2_048).optional()
|
||||
})
|
||||
.strict(),
|
||||
license: speechModelLicenseSchema,
|
||||
manualOnly: z.boolean(),
|
||||
manualReason: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -86,24 +92,72 @@ export const speechModelCatalogEntrySchema = z
|
||||
message: '仅手动导入的模型必须说明原因'
|
||||
})
|
||||
}
|
||||
if (entry.manualOnly) {
|
||||
return
|
||||
}
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
const targets = entry.files
|
||||
.map((file) => file.targets[source])
|
||||
.filter((target) => target !== undefined)
|
||||
if (
|
||||
targets.length > 0 &&
|
||||
(!entry.repositoryUrls[source] ||
|
||||
targets.some(
|
||||
(target) =>
|
||||
target.repositoryUrl !== entry.repositoryUrls[source]
|
||||
))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['repositoryUrls', source],
|
||||
message: '模型仓库地址必须与该下载源的文件目标一致'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
!entry.manualOnly &&
|
||||
entry.files.some((file) => file.download === undefined)
|
||||
!MODEL_DOWNLOAD_SOURCES.some((source) =>
|
||||
entry.files.every((file) => file.targets[source])
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files'],
|
||||
message: '可下载模型的每个文件都必须提供已验证的大小和 SHA-256'
|
||||
message: '可下载模型必须至少由一个下载源提供完整文件'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const speechModelCatalogViewEntrySchema =
|
||||
z
|
||||
.object({
|
||||
id: speechModelIdSchema,
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
|
||||
family: z.enum(['sensevoice', 'whisper', 'paraformer']),
|
||||
quantization: z.enum(['int8', 'fp16', 'fp32']),
|
||||
quality: z.enum(['basic', 'balanced', 'high']),
|
||||
speed: z.enum(['fast', 'balanced', 'slow']),
|
||||
recommended: z.boolean(),
|
||||
license: speechModelLicenseSchema,
|
||||
manualOnly: z.boolean(),
|
||||
manualReason: z.string().trim().min(1).max(500).optional(),
|
||||
files: z
|
||||
.array(speechModelFileSpecSchema.omit({ targets: true }))
|
||||
.min(1)
|
||||
.max(32),
|
||||
downloadAvailability: z
|
||||
.array(modelDownloadAvailabilitySchema)
|
||||
.length(MODEL_DOWNLOAD_SOURCES.length)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const speechModelInstalledFileSchema = z
|
||||
.object({
|
||||
name: speechModelFileNameSchema,
|
||||
role: speechModelFileRoleSchema,
|
||||
size: z.number().int().nonnegative().safe(),
|
||||
sha256: z.string().regex(sha256Pattern)
|
||||
sha256: speechModelArtifactSchema.shape.sha256
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -124,14 +178,28 @@ export const speechModelOperationSchema = z
|
||||
phase: z.enum(['preparing', 'transferring', 'installing']),
|
||||
currentFile: speechModelFileNameSchema.nullable(),
|
||||
completedBytes: z.number().int().nonnegative().safe(),
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable()
|
||||
totalBytes: z.number().int().nonnegative().safe().nullable(),
|
||||
downloadSource: modelDownloadSourceSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((operation, context) => {
|
||||
if (
|
||||
(operation.kind === 'download') !==
|
||||
(operation.downloadSource !== undefined)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['downloadSource'],
|
||||
message: '下载操作必须且仅能包含下载源'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const speechModelSnapshotSchema = z
|
||||
.object({
|
||||
rootDirectory: z.string().min(1).max(32_768),
|
||||
catalog: z.array(speechModelCatalogEntrySchema).max(64),
|
||||
selectedDownloadSource: modelDownloadSourceSchema,
|
||||
catalog: z.array(speechModelCatalogViewEntrySchema).max(64),
|
||||
installed: z.array(installedSpeechModelSchema).max(64),
|
||||
operations: z.array(speechModelOperationSchema).max(16),
|
||||
selectedModelId: speechModelIdSchema.nullable()
|
||||
@@ -144,6 +212,13 @@ export const speechModelActionInputSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const speechModelInstallInputSchema = z
|
||||
.object({
|
||||
modelId: speechModelIdSchema,
|
||||
expectedDownloadSource: modelDownloadSourceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const speechModelSelectionInputSchema = z
|
||||
.object({
|
||||
modelId: speechModelIdSchema.nullable()
|
||||
@@ -185,6 +260,9 @@ export type SpeechModelFileSpec = z.infer<
|
||||
export type SpeechModelCatalogEntry = z.infer<
|
||||
typeof speechModelCatalogEntrySchema
|
||||
>
|
||||
export type SpeechModelCatalogViewEntry = z.infer<
|
||||
typeof speechModelCatalogViewEntrySchema
|
||||
>
|
||||
export type InstalledSpeechModel = z.infer<
|
||||
typeof installedSpeechModelSchema
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user