feat: add explicit managed model download sources

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

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

Release note: 可在“设置 → 平台功能 → 通用设置”中选择 ModelScope 或 Hugging Face 作为后续语音输入与 OCR 模型下载源;缺少完整已验证文件或下载失败时不会静默换源。
This commit is contained in:
mesalogo
2026-08-19 13:43:16 +08:00
parent c41d7e8b96
commit 43e1d162dc
44 changed files with 6083 additions and 839 deletions
+2 -1
View File
@@ -74,7 +74,8 @@
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。 - [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。 - [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。 - [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除 - [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。 - [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。 - [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 - [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
+1
View File
@@ -567,6 +567,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供 - 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。 “打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。 - 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 - 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 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 - 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 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
+3
View File
@@ -29,6 +29,9 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先
## 跨功能文档 ## 跨功能文档
- [自动化平台架构](./architecture/automation-platform-architecture.md) - [自动化平台架构](./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) - [DeepSeek Harness Runtime 设计](./architecture/deepseek-harness-runtime-design.md)
- [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md) - [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md)
- [长期助手路线图](./roadmap/long-term-assistant-roadmap.md) - [长期助手路线图](./roadmap/long-term-assistant-roadmap.md)
@@ -0,0 +1,944 @@
# GoodBuddy 全双工实时语音交互设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档类型 | 跨功能技术与产品架构 |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-08-19 |
| 适用产品 | GoodBuddy 桌面端 |
| 目标平台 | Windows、macOS、Linuxx64 与 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 立即对当前音频执行 2040 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` 的 24 项约束。
语音引擎卡片必须持续显示:
- 本地或云端。
- 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 |
| 播放队列常态深度 | 100400 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。
- 建立禁止静默降级测试矩阵。
- 建立会话快照、事件和诊断结构。
### 阶段 1Renderer 音频平面
- 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. 当前基线 ## 2. 当前基线
@@ -72,11 +74,12 @@ PDF 不是所有文档唯一的中间格式。解析应同时保留:
OCR 模型区沿用语音模型管理模式: OCR 模型区沿用语音模型管理模式:
- 应用不内置模型权重; - 应用不内置模型权重;
- 用户按需从 ModelScope 下载,下载完成后离线使用; - 用户按需从全局选择的 ModelScope 或 Hugging Face 下载,默认 ModelScope,下载完成后
离线使用;
- 显示来源、语言、运行时、模型体积、安装与校验状态; - 显示来源、语言、运行时、模型体积、安装与校验状态;
- 联网设备可导出已安装模型 ZIP,离线或内网设备可直接导入; - 联网设备可导出已安装模型 ZIP,离线或内网设备可直接导入;
- 支持下载进度、取消、删除、ZIP 导入导出、打开模型仓库和受管目录; - 支持下载进度、取消、删除、ZIP 导入导出、打开模型仓库和受管目录;
- “打开 ModelScope”直接显示在 OCR 模型卡片右上角,使用手动导入折叠区 - “打开模型仓库”直接显示在 OCR 模型卡片右上角,使用当前下载源对应仓库
- 模型操作即时生效,解析策略仍通过分类页头的“保存设置”提交。 - 模型操作即时生效,解析策略仍通过分类页头的“保存设置”提交。
### 4.1 第一阶段字段 ### 4.1 第一阶段字段
@@ -182,11 +185,14 @@ type ParsedDocument = {
### 7.2 下载与安装 ### 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. 禁用凭据与缓存,限制重定向次数和单文件大小; 2. 禁用凭据与缓存,限制重定向次数和单文件大小;
3. 写入受管目录下的随机临时安装目录; 3. 写入受管目录下的随机临时安装目录;
4. 边下载边计算 SHA-256,并核对完整字节数; 4. 边下载边计算 SHA-256,并核对完整字节数;
@@ -194,7 +200,9 @@ Tiny、Small 和 Medium 模型均由 PaddlePaddle 官方 ModelScope 仓库提供
6. 原子重命名为正式模型目录; 6. 原子重命名为正式模型目录;
7. 失败、取消或退出时删除临时文件。 7. 失败、取消或退出时删除临时文件。
模型只在下载或用户显式打开仓库时访问网络。OCR 推理从受管目录读取已校验文件,不发起网络请求。 单次任务只使用启动时冻结的一个来源。所选来源不可用或缺少任一必需文件时明确失败,
不请求另一个来源。模型只在下载或用户显式打开仓库时访问网络。OCR 推理从受管目录读取
已校验文件,不发起网络请求。
### 7.3 离线 ZIP 迁移 ### 7.3 离线 ZIP 迁移
@@ -298,7 +306,8 @@ DOC、XLS、PPT 通过 `DocumentConversionProvider` 转换:
- 新增文档解析设置分类和持久化契约; - 新增文档解析设置分类和持久化契约;
- 建立 `DocumentParsingService`,供聊天和知识库共用; - 建立 `DocumentParsingService`,供聊天和知识库共用;
- 将无文本 PDF 识别为可触发 OCR 的明确状态; - 将无文本 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,并在离线设备导入后完成真实推理; - 语音和 OCR 模型可在联网设备导出 ZIP,并在离线设备导入后完成真实推理;
- 路径穿越、未知条目、错误模型 ID、篡改文件和超限 ZIP 均被拒绝; - 路径穿越、未知条目、错误模型 ID、篡改文件和超限 ZIP 均被拒绝;
- 超页数、超时、取消和关闭不会留下运行任务; - 超页数、超时、取消和关闭不会留下运行任务;
@@ -9,6 +9,7 @@
| 日期 | 2026-08-11 | | 日期 | 2026-08-11 |
| 适用产品 | GoodBuddy 桌面端 | | 适用产品 | GoodBuddy 桌面端 |
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 | | 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
| 相关设计 | [本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md) |
## 1. 背景 ## 1. 背景
@@ -38,7 +39,8 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
3. 保留“模型按需检索”,并新增“每次先检索”模式。后者必须由 Main 进程 3. 保留“模型按需检索”,并新增“每次先检索”模式。后者必须由 Main 进程
预检索,不能只依赖提示词要求模型调用工具。 预检索,不能只依赖提示词要求模型调用工具。
4. 知识库新建后不默认启用全部已有知识库;对话中的范围继续由用户显式选择。 4. 知识库新建后不默认启用全部已有知识库;对话中的范围继续由用户显式选择。
5. 向量服务不可用时保留全文检索,但必须返回明确降级状态。 5. 向量服务不可用时保留全文与中文检索,但必须返回明确降级状态。这是可见的检索通道
降级,不得自动切换应用托管模型、Ollama、云端 Provider 或其他向量模型。
6. 中文召回使用应用内可控的 CJK n-gram 索引,不新增远程服务依赖。 6. 中文召回使用应用内可控的 CJK n-gram 索引,不新增远程服务依赖。
7. 混合检索保留 RRF 候选融合,并增加本地确定性重排、可选的 7. 混合检索保留 RRF 候选融合,并增加本地确定性重排、可选的
Cohere/Jina 兼容学习型重排、最低相关度和上下文预算。学习型重排失败时 Cohere/Jina 兼容学习型重排、最低相关度和上下文预算。学习型重排失败时
@@ -46,7 +48,9 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
8. 向量搜索取消 5,000 分块静默失效,使用有界内存的分页扫描。在没有稳定 8. 向量搜索取消 5,000 分块静默失效,使用有界内存的分页扫描。在没有稳定
跨平台向量扩展前,接受本地 CPU 线性扫描,并持续显示性能诊断。 跨平台向量扩展前,接受本地 CPU 线性扫描,并持续显示性能诊断。
9. 向量索引兼容性同时校验 Provider、Model、维度和 Provider Fingerprint。 9. 向量索引兼容性同时校验 Provider、Model、维度和 Provider Fingerprint。
同名模型切换端点后,旧向量不能继续参与召回。 同名模型切换端点后,旧向量不能继续参与召回。Fingerprint 的完整模型、编码与
数据路径定义以[本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md)
为准。
10. 失败或取消的重建不能停用上一版已就绪索引。新索引只有完整校验成功后才 10. 失败或取消的重建不能停用上一版已就绪索引。新索引只有完整校验成功后才
原子替换当前服务版本。 原子替换当前服务版本。
11. 分块设置属于知识库,修改后不会伪装为立即生效。用户需要显式重建索引。 11. 分块设置属于知识库,修改后不会伪装为立即生效。用户需要显式重建索引。
@@ -156,6 +160,11 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立 “检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
工作台,不新增第五个一级页签。 工作台,不新增第五个一级页签。
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。应用托管本地模型、
用户自行安装的 Ollama/自托管服务和云端兼容服务的界面、数据路径及切换语义以
[本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md)
为准,知识库页面只显示当前模型、索引兼容性、覆盖率和重建操作。
对话输入区的知识范围弹层包含: 对话输入区的知识范围弹层包含:
1. 已启用知识库多选。 1. 已启用知识库多选。
@@ -495,7 +504,7 @@ type KnowledgeRetrievalResponse = {
| 场景 | 行为 | | 场景 | 行为 |
| --- | --- | | --- | --- |
| 向量查询失败 | 继续文和图谱检索,显示降级原因 | | 向量查询失败 | 继续已配置的全文、中文和图谱通道,显示降级原因,不切换向量 Provider 或模型 |
| 部分文档无向量 | 使用可用文档,显示完成数和失败数 | | 部分文档无向量 | 使用可用文档,显示完成数和失败数 |
| CJK 索引迁移失败 | 回滚迁移,不损坏旧 FTS | | CJK 索引迁移失败 | 回滚迁移,不损坏旧 FTS |
| 重排失败 | 回退 RRF 排序并显示诊断 | | 重排失败 | 回退 RRF 排序并显示诊断 |
@@ -558,6 +567,7 @@ GoodBuddy 不上传私人检索查询或文档内容。本地诊断至少记录
- “每次先检索”在 Runtime 启动前产生检索诊断和引用,即使模型未调用工具。 - “每次先检索”在 Runtime 启动前产生检索诊断和引用,即使模型未调用工具。
- 未配置向量模型时,中文改写问题仍能通过 CJK 索引召回相关分块。 - 未配置向量模型时,中文改写问题仍能通过 CJK 索引召回相关分块。
- 向量查询失败时回答可继续,界面明确显示已降级。 - 向量查询失败时回答可继续,界面明确显示已降级。
- 应用托管模型、Ollama 和云端向量连接之间不会自动切换;实际数据路径持续可见。
- 10,000 个分块的向量测试能够返回正确 Top K,不出现固定上限空结果。 - 10,000 个分块的向量测试能够返回正确 Top K,不出现固定上限空结果。
- 同名模型切换端点后,不会读取 Fingerprint 不匹配的旧向量。 - 同名模型切换端点后,不会读取 Fingerprint 不匹配的旧向量。
- 重建失败时,上一版已就绪向量仍能继续召回。 - 重建失败时,上一版已就绪向量仍能继续召回。
+50 -3
View File
@@ -63,6 +63,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({ ).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -70,14 +71,16 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({ await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
}) })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 6, version: 7,
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined', magicNoteCommentFormat: 'combined',
@@ -102,6 +105,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({ ).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -124,6 +128,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({ await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -146,6 +151,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({ await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -168,6 +174,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({ await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual', magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -194,9 +201,10 @@ describe('ApplicationSettingsStore', () => {
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion() new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18') ).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 6, version: 7,
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual', magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative', magicNoteCommentFormat: 'narrative',
@@ -222,6 +230,7 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({ await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto', magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured' 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', () => { it('strictly rejects incomplete full settings', () => {
for (const input of [ for (const input of [
{}, {},
@@ -254,6 +296,7 @@ describe('ApplicationSettingsStore', () => {
for (const input of [ for (const input of [
{}, {},
{ checkUpdatesOnStartup: 'true' }, { checkUpdatesOnStartup: 'true' },
{ modelDownloadSource: 'automatic' },
{ anotherSetting: true }, { anotherSetting: true },
null null
]) { ]) {
@@ -274,6 +317,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({ ).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -387,14 +431,16 @@ describe('ApplicationSettingsStore', () => {
await expect(store.get()).resolves.toEqual({ await expect(store.get()).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
}) })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 6, version: 7,
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined', magicNoteCommentFormat: 'combined',
@@ -419,6 +465,7 @@ describe('ApplicationSettingsStore', () => {
).resolves.toEqual({ ).resolves.toEqual({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
+30 -4
View File
@@ -20,7 +20,7 @@ export {
} from '../shared/application-settings-contracts' } from '../shared/application-settings-contracts'
export type { ApplicationSettings } 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 const legacyStoredApplicationSettingsSchema = z
.object({ .object({
@@ -47,20 +47,28 @@ const versionThreeStoredApplicationSettingsSchema = z
.strict() .strict()
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ updateSource: true }) .omit({ updateSource: true, modelDownloadSource: true })
.extend({ .extend({
version: z.literal(4) version: z.literal(4)
}) })
.strict() .strict()
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ updateSource: true }) .omit({ updateSource: true, modelDownloadSource: true })
.extend({ .extend({
version: z.literal(5), version: z.literal(5),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
}) })
.strict() .strict()
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ modelDownloadSource: true })
.extend({
version: z.literal(6),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({ .extend({
version: z.literal(CURRENT_SETTINGS_VERSION), version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -75,6 +83,7 @@ type StoredApplicationSettings = z.infer<
export const defaultApplicationSettings: ApplicationSettings = { export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -131,13 +140,24 @@ export class ApplicationSettingsStore {
) )
const result = storedApplicationSettingsSchema.safeParse(parsed) const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) { 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 = const versionFiveResult =
versionFiveStoredApplicationSettingsSchema.safeParse(parsed) versionFiveStoredApplicationSettingsSchema.safeParse(parsed)
if (versionFiveResult.success) { if (versionFiveResult.success) {
this.settings = { this.settings = {
...versionFiveResult.data, ...versionFiveResult.data,
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
updateSource: 'github' updateSource: 'github',
modelDownloadSource: 'modelscope'
} }
return this.settings return this.settings
} }
@@ -148,6 +168,7 @@ export class ApplicationSettingsStore {
...versionFourResult.data, ...versionFourResult.data,
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
lastSeenReleaseNotesVersion: null lastSeenReleaseNotesVersion: null
} }
return this.settings return this.settings
@@ -159,6 +180,7 @@ export class ApplicationSettingsStore {
...versionThreeResult.data, ...versionThreeResult.data,
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNoteCommentFormat: 'combined', magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null lastSeenReleaseNotesVersion: null
} }
@@ -171,6 +193,7 @@ export class ApplicationSettingsStore {
...versionTwoResult.data, ...versionTwoResult.data,
version: CURRENT_SETTINGS_VERSION, version: CURRENT_SETTINGS_VERSION,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined', magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null lastSeenReleaseNotesVersion: null
@@ -185,6 +208,7 @@ export class ApplicationSettingsStore {
checkUpdatesOnStartup: checkUpdatesOnStartup:
legacyResult.data.checkUpdatesOnStartup, legacyResult.data.checkUpdatesOnStartup,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined', magicNoteCommentFormat: 'combined',
@@ -225,6 +249,7 @@ export class ApplicationSettingsStore {
return { return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup, checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
updateSource: stored.updateSource, updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
magicNotesEnabled: stored.magicNotesEnabled, magicNotesEnabled: stored.magicNotesEnabled,
magicNoteCommentMode: stored.magicNoteCommentMode, magicNoteCommentMode: stored.magicNoteCommentMode,
magicNoteCommentFormat: stored.magicNoteCommentFormat, magicNoteCommentFormat: stored.magicNoteCommentFormat,
@@ -257,6 +282,7 @@ export class ApplicationSettingsStore {
return { return {
checkUpdatesOnStartup: next.checkUpdatesOnStartup, checkUpdatesOnStartup: next.checkUpdatesOnStartup,
updateSource: next.updateSource, updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
magicNotesEnabled: next.magicNotesEnabled, magicNotesEnabled: next.magicNotesEnabled,
magicNoteCommentMode: next.magicNoteCommentMode, magicNoteCommentMode: next.magicNoteCommentMode,
magicNoteCommentFormat: next.magicNoteCommentFormat magicNoteCommentFormat: next.magicNoteCommentFormat
+101 -105
View File
@@ -2,19 +2,66 @@ import {
documentOcrModelCatalogEntrySchema, documentOcrModelCatalogEntrySchema,
type DocumentOcrModelCatalogEntry type DocumentOcrModelCatalogEntry
} from '../shared/document-parsing-contracts' } from '../shared/document-parsing-contracts'
import {
huggingFaceTarget,
modelScopeTarget
} from './model-download-targets'
const detectionRevision = const repositories = {
'7d7f5d128d9309ebf6de4f21f404dd583afdbae3' tinyDetection: 'PaddlePaddle/PP-OCRv6_tiny_det_onnx',
const recognitionRevision = tinyRecognition: 'PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
'afba04b618200c5f4824531c6e42c957c6439d9a' smallDetection: 'PaddlePaddle/PP-OCRv6_small_det_onnx',
const smallDetectionRevision = smallRecognition: 'PaddlePaddle/PP-OCRv6_small_rec_onnx',
'956a0b620a4017cc04056c692be1703b0025d028' mediumDetection: 'PaddlePaddle/PP-OCRv6_medium_det_onnx',
const smallRecognitionRevision = mediumRecognition: 'PaddlePaddle/PP-OCRv6_medium_rec_onnx'
'296d43bc0ebced0fd9c605174aa5962e49810ab6' } as const
const mediumDetectionRevision =
'c317b40325be40bfaaff58c8dcece2a075294f8a' const modelScopeRevisions = {
const mediumRecognitionRevision = tinyDetection: '7d7f5d128d9309ebf6de4f21f404dd583afdbae3',
'db5d610d492a14e3c34dc1fd4e9339bd369f79e6' 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[] = export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] =
documentOcrModelCatalogEntrySchema.array().parse([ documentOcrModelCatalogEntrySchema.array().parse([
@@ -28,54 +75,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
quality: 'basic', quality: 'basic',
speed: 'fast', speed: 'fast',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: repositoryUrls('tinyRecognition'),
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_tiny_rec_onnx',
license: { license: {
name: 'Apache License 2.0', name: 'Apache License 2.0',
notice: notice:
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', '检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE' url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
}, },
files: [ files: [
{ {
name: 'detection.onnx', name: 'detection.onnx',
role: 'detection', role: 'detection',
download: { size: 1_780_590,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + '193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8',
'PP-OCRv6_tiny_det_onnx/resolve/' + targets: targets('tinyDetection', 'inference.onnx')
`${detectionRevision}/inference.onnx`,
size: 1_780_590,
sha256:
'193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8'
}
}, },
{ {
name: 'recognition.onnx', name: 'recognition.onnx',
role: 'recognition', role: 'recognition',
download: { size: 4_462_639,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + '9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6',
'PP-OCRv6_tiny_rec_onnx/resolve/' + targets: targets('tinyRecognition', 'inference.onnx')
`${recognitionRevision}/inference.onnx`,
size: 4_462_639,
sha256:
'9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6'
}
}, },
{ {
name: 'dictionary.yml', name: 'dictionary.yml',
role: 'dictionary', role: 'dictionary',
download: { size: 55_571,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + '66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1',
'PP-OCRv6_tiny_rec_onnx/resolve/' + targets: targets('tinyRecognition', 'inference.yml')
`${recognitionRevision}/inference.yml`,
size: 55_571,
sha256:
'66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1'
}
} }
] ]
}, },
@@ -89,54 +119,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
quality: 'balanced', quality: 'balanced',
speed: 'balanced', speed: 'balanced',
recommended: true, recommended: true,
repositoryUrl: repositoryUrls: repositoryUrls('smallRecognition'),
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_small_rec_onnx',
license: { license: {
name: 'Apache License 2.0', name: 'Apache License 2.0',
notice: notice:
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', '检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE' url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
}, },
files: [ files: [
{ {
name: 'detection.onnx', name: 'detection.onnx',
role: 'detection', role: 'detection',
download: { size: 9_880_512,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + 'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e',
'PP-OCRv6_small_det_onnx/resolve/' + targets: targets('smallDetection', 'inference.onnx')
`${smallDetectionRevision}/inference.onnx`,
size: 9_880_512,
sha256:
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e'
}
}, },
{ {
name: 'recognition.onnx', name: 'recognition.onnx',
role: 'recognition', role: 'recognition',
download: { size: 21_159_378,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + '5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634',
'PP-OCRv6_small_rec_onnx/resolve/' + targets: targets('smallRecognition', 'inference.onnx')
`${smallRecognitionRevision}/inference.onnx`,
size: 21_159_378,
sha256:
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
}
}, },
{ {
name: 'dictionary.yml', name: 'dictionary.yml',
role: 'dictionary', role: 'dictionary',
download: { size: 150_579,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + 'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1',
'PP-OCRv6_small_rec_onnx/resolve/' + targets: targets('smallRecognition', 'inference.yml')
`${smallRecognitionRevision}/inference.yml`,
size: 150_579,
sha256:
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
}
} }
] ]
}, },
@@ -150,54 +163,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[]
quality: 'high', quality: 'high',
speed: 'slow', speed: 'slow',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: repositoryUrls('mediumRecognition'),
'https://modelscope.cn/models/PaddlePaddle/' +
'PP-OCRv6_medium_rec_onnx',
license: { license: {
name: 'Apache License 2.0', name: 'Apache License 2.0',
notice: notice:
'检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', '检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。',
url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE' url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE'
}, },
files: [ files: [
{ {
name: 'detection.onnx', name: 'detection.onnx',
role: 'detection', role: 'detection',
download: { size: 62_032_837,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + 'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1',
'PP-OCRv6_medium_det_onnx/resolve/' + targets: targets('mediumDetection', 'inference.onnx')
`${mediumDetectionRevision}/inference.onnx`,
size: 62_032_837,
sha256:
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
}
}, },
{ {
name: 'recognition.onnx', name: 'recognition.onnx',
role: 'recognition', role: 'recognition',
download: { size: 76_554_979,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + '9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba',
'PP-OCRv6_medium_rec_onnx/resolve/' + targets: targets('mediumRecognition', 'inference.onnx')
`${mediumRecognitionRevision}/inference.onnx`,
size: 76_554_979,
sha256:
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
}
}, },
{ {
name: 'dictionary.yml', name: 'dictionary.yml',
role: 'dictionary', role: 'dictionary',
download: { size: 150_580,
url: sha256:
'https://modelscope.cn/models/PaddlePaddle/' + '991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129',
'PP-OCRv6_medium_rec_onnx/resolve/' + targets: targets('mediumRecognition', 'inference.yml')
`${mediumRecognitionRevision}/inference.yml`,
size: 150_580,
sha256:
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
}
} }
] ]
} }
+127 -54
View File
@@ -70,8 +70,12 @@ function catalog(
quality: 'balanced', quality: 'balanced',
speed: 'fast', speed: 'fast',
recommended: true, recommended: true,
repositoryUrl: repositoryUrls: {
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx', modelscope:
'https://modelscope.cn/models/example/test-model',
'hugging-face':
'https://huggingface.co/example/test-model'
},
license: { license: {
name: 'Apache License 2.0', name: 'Apache License 2.0',
notice: 'Test license notice.', notice: 'Test license notice.',
@@ -80,10 +84,27 @@ function catalog(
files: files.map((file) => ({ files: files.map((file) => ({
name: file.name, name: file.name,
role: file.role, role: file.role,
download: { size: file.bytes.byteLength,
url: `https://modelscope.cn/models/example/resolve/revision/${file.name}`, sha256: sha256(file.bytes),
size: file.bytes.byteLength, targets: {
sha256: sha256(file.bytes) 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') throw new Error('Test OCR catalog is empty')
} }
const files = new Map( const files = new Map(
entry.files.map((file) => [ entry.files.flatMap((file) =>
file.download.url, Object.values(file.targets).map((target) => [
modelBytes[file.role] target.url,
]) modelBytes[file.role]
] as const)
)
) )
const transport = vi.fn(async (input: string | URL | Request) => { const transport = vi.fn(async (input: string | URL | Request) => {
const url = 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(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3)
expect( expect(
new Set(DOCUMENT_OCR_MODEL_CATALOG.map((entry) => entry.id)).size new Set(DOCUMENT_OCR_MODEL_CATALOG.map((entry) => entry.id)).size
@@ -186,20 +209,28 @@ describe('DocumentOcrModelManager', () => {
).toEqual(['pp-ocrv6-small']) ).toEqual(['pp-ocrv6-small'])
for (const entry of DOCUMENT_OCR_MODEL_CATALOG) { 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) { 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 /^https:\/\/modelscope\.cn\/models\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
) )
expect(file.download.sha256).toMatch(/^[a-f0-9]{64}$/u) expect(file.targets['hugging-face']?.url).toMatch(
expect(file.download.size).toBeGreaterThan(0) /^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( const small = DOCUMENT_OCR_MODEL_CATALOG.find(
DOCUMENT_OCR_MODEL_CATALOG.find( (entry) => entry.id === 'pp-ocrv6-small'
(entry) => entry.id === 'pp-ocrv6-small' )
) expect(small).toMatchObject({
).toMatchObject({
languages: ['50 种语言'], languages: ['50 种语言'],
quality: 'balanced', quality: 'balanced',
speed: 'balanced', speed: 'balanced',
@@ -207,30 +238,29 @@ describe('DocumentOcrModelManager', () => {
files: [ files: [
{ {
role: 'detection', role: 'detection',
download: { size: 9_880_512,
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/956a0b620a4017cc04056c692be1703b0025d028/inference.onnx', sha256:
size: 9_880_512, 'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e',
sha256: targets: {
'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e' modelscope: {
revision: '956a0b620a4017cc04056c692be1703b0025d028'
},
'hugging-face': {
revision: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62'
}
} }
}, },
{ {
role: 'recognition', role: 'recognition',
download: { size: 21_159_378,
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.onnx', sha256:
size: 21_159_378, '5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
sha256:
'5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634'
}
}, },
{ {
role: 'dictionary', role: 'dictionary',
download: { size: 150_579,
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.yml', sha256:
size: 150_579, 'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
sha256:
'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1'
}
} }
] ]
}) })
@@ -246,30 +276,21 @@ describe('DocumentOcrModelManager', () => {
files: [ files: [
{ {
role: 'detection', role: 'detection',
download: { size: 62_032_837,
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_det_onnx/resolve/c317b40325be40bfaaff58c8dcece2a075294f8a/inference.onnx', sha256:
size: 62_032_837, 'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
sha256:
'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1'
}
}, },
{ {
role: 'recognition', role: 'recognition',
download: { size: 76_554_979,
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.onnx', sha256:
size: 76_554_979, '9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
sha256:
'9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba'
}
}, },
{ {
role: 'dictionary', role: 'dictionary',
download: { size: 150_580,
url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.yml', sha256:
size: 150_580, '991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
sha256:
'991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129'
}
} }
] ]
}) })
@@ -296,6 +317,58 @@ describe('DocumentOcrModelManager', () => {
expect(new TextDecoder().decode(assets.dictionary)).toContain( expect(new TextDecoder().decode(assets.dictionary)).toContain(
"!\n\"\n'\n" "!\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 () => { it('rejects an imported model whose hash does not match', async () => {
+99 -74
View File
@@ -15,26 +15,35 @@ import { dirname, resolve } from 'node:path'
import { import {
documentOcrAssetsSchema, documentOcrAssetsSchema,
documentOcrModelCatalogEntrySchema, documentOcrModelCatalogEntrySchema,
documentOcrModelCatalogViewEntrySchema,
documentOcrModelSnapshotSchema, documentOcrModelSnapshotSchema,
documentParsingModelStatusSchema, documentParsingModelStatusSchema,
installedDocumentOcrModelSchema, installedDocumentOcrModelSchema,
localOcrModelIdSchema, localOcrModelIdSchema,
type DocumentOcrAssets, type DocumentOcrAssets,
type DocumentOcrModelCatalogEntry, type DocumentOcrModelCatalogEntry,
type DocumentOcrModelCatalogViewEntry,
type DocumentOcrModelFile, type DocumentOcrModelFile,
type DocumentOcrModelOperation, type DocumentOcrModelOperation,
type DocumentOcrModelSnapshot, type DocumentOcrModelSnapshot,
type InstalledDocumentOcrModel type InstalledDocumentOcrModel
} from '../shared/document-parsing-contracts' } 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 { DOCUMENT_OCR_MODEL_CATALOG } from './document-ocr-model-catalog'
import { import {
exportModelArchive, exportModelArchive,
extractModelArchive extractModelArchive
} from './model-archive' } from './model-archive'
import { fetchModelDownloadResponse } from './model-download-transport'
const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024 const DEFAULT_MAX_FILE_BYTES = 96 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json' const MANIFEST_FILE_NAME = 'manifest.json'
const MAX_REDIRECTS = 3
const PARTIAL_SUFFIX = '.partial' const PARTIAL_SUFFIX = '.partial'
const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024 const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024 const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
@@ -50,6 +59,9 @@ export type DocumentOcrModelManagerOptions = {
userDataDirectory: string userDataDirectory: string
fetch: typeof fetch fetch: typeof fetch
catalog?: readonly DocumentOcrModelCatalogEntry[] catalog?: readonly DocumentOcrModelCatalogEntry[]
getDownloadSource?: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number maxFileBytes?: number
} }
@@ -69,6 +81,23 @@ function cloneCatalogEntry(
return documentOcrModelCatalogEntrySchema.parse(entry) 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 { function safeChild(parent: string, name: string): string {
const child = resolve(parent, name) const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) { if (dirname(child) !== resolve(parent)) {
@@ -77,14 +106,6 @@ function safeChild(parent: string, name: string): string {
return child 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 { function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return Uint8Array.from(buffer).buffer return Uint8Array.from(buffer).buffer
} }
@@ -157,6 +178,10 @@ export class DocumentOcrModelManager {
private readonly transport: typeof fetch private readonly transport: typeof fetch
private readonly catalog: DocumentOcrModelCatalogEntry[] private readonly catalog: DocumentOcrModelCatalogEntry[]
private readonly catalogViews: DocumentOcrModelCatalogViewEntry[]
private readonly getDownloadSource: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>() private readonly operations = new Map<string, ActiveOperation>()
private readonly verifiedModels = new Map<string, Promise<void>>() private readonly verifiedModels = new Map<string, Promise<void>>()
@@ -171,6 +196,8 @@ export class DocumentOcrModelManager {
'document-ocr' 'document-ocr'
) )
this.transport = options.fetch this.transport = options.fetch
this.getDownloadSource =
options.getDownloadSource ?? (() => 'modelscope')
this.catalog = (options.catalog ?? DOCUMENT_OCR_MODEL_CATALOG).map( this.catalog = (options.catalog ?? DOCUMENT_OCR_MODEL_CATALOG).map(
cloneCatalogEntry cloneCatalogEntry
) )
@@ -180,6 +207,7 @@ export class DocumentOcrModelManager {
) { ) {
throw new Error('OCR 模型目录包含重复 ID') throw new Error('OCR 模型目录包含重复 ID')
} }
this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
if ( if (
!Number.isSafeInteger(this.maxFileBytes) || !Number.isSafeInteger(this.maxFileBytes) ||
@@ -192,10 +220,15 @@ export class DocumentOcrModelManager {
async getSnapshot(): Promise<DocumentOcrModelSnapshot> { async getSnapshot(): Promise<DocumentOcrModelSnapshot> {
await this.ensureRoot() await this.ensureRoot()
const [selectedDownloadSource, installed] = await Promise.all([
this.getDownloadSource(),
this.readInstalled()
])
return documentOcrModelSnapshotSchema.parse({ return documentOcrModelSnapshotSchema.parse({
rootDirectory: this.rootDirectory, rootDirectory: this.rootDirectory,
catalog: this.catalog.map(cloneCatalogEntry), selectedDownloadSource,
installed: await this.readInstalled(), catalog: this.catalogViews,
installed,
operations: [...this.operations.values()].map((operation) => ({ operations: [...this.operations.values()].map((operation) => ({
...operation.progress ...operation.progress
})) }))
@@ -234,7 +267,7 @@ export class DocumentOcrModelManager {
available: false, available: false,
verified: false, verified: false,
runtime: entry.runtime, runtime: entry.runtime,
detail: '模型尚未安装或校验失败,请从 ModelScope 下载' detail: '模型尚未安装或校验失败,请从当前模型下载源获取'
}) })
} }
} }
@@ -243,19 +276,37 @@ export class DocumentOcrModelManager {
return this.loadVerifiedAssets(this.requireCatalogEntry(modelId)) 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( async install(
modelId: string, modelId: string,
downloadSource?: ModelDownloadSource,
externalSignal?: AbortSignal externalSignal?: AbortSignal
): Promise<InstalledDocumentOcrModel> { ): Promise<InstalledDocumentOcrModel> {
const entry = this.requireCatalogEntry(modelId) const entry = this.requireCatalogEntry(modelId)
const totalBytes = entry.files.reduce( const selectedDownloadSource =
(total, file) => total + file.download.size, downloadSource ?? (await this.getDownloadSource())
0 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( const detachAbort = this.attachExternalSignal(
externalSignal, externalSignal,
operation.controller operation.controller
@@ -265,7 +316,7 @@ export class DocumentOcrModelManager {
await this.ensureRoot() await this.ensureRoot()
await this.assertNotInstalled(entry.id) await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of entry.files) { for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal) ensureNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring' operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name operation.progress.currentFile = file.name
@@ -376,8 +427,8 @@ export class DocumentOcrModelManager {
) )
if ( if (
!recorded || !recorded ||
recorded.size !== expected.download.size || recorded.size !== expected.size ||
recorded.sha256 !== expected.download.sha256 recorded.sha256 !== expected.sha256
) { ) {
throw new Error(`OCR 模型文件校验失败:${expected.name}`) throw new Error(`OCR 模型文件校验失败:${expected.name}`)
} }
@@ -406,7 +457,7 @@ export class DocumentOcrModelManager {
): Promise<InstalledDocumentOcrModel> { ): Promise<InstalledDocumentOcrModel> {
const entry = this.requireCatalogEntry(modelId) const entry = this.requireCatalogEntry(modelId)
const expectedTotal = entry.files.reduce( const expectedTotal = entry.files.reduce(
(total, file) => total + file.download.size, (total, file) => total + file.size,
0 0
) )
const operation = this.beginOperation( const operation = this.beginOperation(
@@ -448,8 +499,8 @@ export class DocumentOcrModelManager {
) )
if ( if (
!archived || !archived ||
archived.size !== expected.download.size || archived.size !== expected.size ||
archived.sha256 !== expected.download.sha256 archived.sha256 !== expected.sha256
) { ) {
throw new Error( throw new Error(
`OCR 模型 ZIP 与当前模型目录不匹配:${expected.name}` `OCR 模型 ZIP 与当前模型目录不匹配:${expected.name}`
@@ -536,7 +587,8 @@ export class DocumentOcrModelManager {
private beginOperation( private beginOperation(
modelId: string, modelId: string,
kind: DocumentOcrModelOperation['kind'], kind: DocumentOcrModelOperation['kind'],
totalBytes: number | null totalBytes: number | null,
downloadSource?: ModelDownloadSource
): ActiveOperation { ): ActiveOperation {
if (this.operations.has(modelId)) { if (this.operations.has(modelId)) {
throw new Error('该 OCR 模型已有进行中的操作') throw new Error('该 OCR 模型已有进行中的操作')
@@ -549,7 +601,8 @@ export class DocumentOcrModelManager {
phase: 'preparing', phase: 'preparing',
currentFile: null, currentFile: null,
completedBytes: 0, completedBytes: 0,
totalBytes totalBytes,
...(downloadSource ? { downloadSource } : {})
} }
} }
this.operations.set(modelId, operation) this.operations.set(modelId, operation)
@@ -597,50 +650,22 @@ export class DocumentOcrModelManager {
return directory 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( private async downloadFile(
file: DocumentOcrModelFile, file: ResolvedModelArtifactFile<DocumentOcrModelFile['role']>,
destination: string, destination: string,
operation: ActiveOperation, operation: ActiveOperation,
signal: AbortSignal signal: AbortSignal
): Promise<void> { ): Promise<void> {
if (file.download.size > this.maxFileBytes) { if (file.size > this.maxFileBytes) {
throw new RangeError(`OCR 模型文件过大:${file.name}`) throw new RangeError(`OCR 模型文件过大:${file.name}`)
} }
const response = await this.fetchFollowingRedirects( const response = await fetchModelDownloadResponse({
file.download.url, transport: this.transport,
signal initialUrl: file.target.url,
) redirectHosts: file.target.redirectHosts,
signal,
modelLabel: 'OCR 模型'
})
if (!response.ok) { if (!response.ok) {
await response.body?.cancel().catch(() => undefined) await response.body?.cancel().catch(() => undefined)
throw new Error(`OCR 模型下载失败:HTTP ${response.status}`) throw new Error(`OCR 模型下载失败:HTTP ${response.status}`)
@@ -651,7 +676,7 @@ export class DocumentOcrModelManager {
const declaredLength = response.headers.get('content-length') const declaredLength = response.headers.get('content-length')
if ( if (
declaredLength !== null && declaredLength !== null &&
Number(declaredLength) !== file.download.size Number(declaredLength) !== file.size
) { ) {
await response.body.cancel().catch(() => undefined) await response.body.cancel().catch(() => undefined)
throw new Error(`OCR 模型文件大小不匹配:${file.name}`) throw new Error(`OCR 模型文件大小不匹配:${file.name}`)
@@ -671,7 +696,7 @@ export class DocumentOcrModelManager {
} }
written += result.value.byteLength written += result.value.byteLength
if ( if (
written > file.download.size || written > file.size ||
written > this.maxFileBytes written > this.maxFileBytes
) { ) {
await reader.cancel() await reader.cancel()
@@ -688,8 +713,8 @@ export class DocumentOcrModelManager {
await handle.close() await handle.close()
} }
if ( if (
written !== file.download.size || written !== file.size ||
hash.digest('hex') !== file.download.sha256 hash.digest('hex') !== file.sha256
) { ) {
throw new Error(`OCR 模型文件校验失败:${file.name}`) throw new Error(`OCR 模型文件校验失败:${file.name}`)
} }
@@ -724,8 +749,8 @@ export class DocumentOcrModelManager {
} }
const actual = await hashFile(path, signal) const actual = await hashFile(path, signal)
if ( if (
actual.size !== file.download.size || actual.size !== file.size ||
actual.sha256 !== file.download.sha256 actual.sha256 !== file.sha256
) { ) {
throw new Error(`本地 OCR 模型文件校验失败:${file.name}`) throw new Error(`本地 OCR 模型文件校验失败:${file.name}`)
} }
@@ -832,8 +857,8 @@ export class DocumentOcrModelManager {
const actual = await hashFile(safeChild(directory, file.name)) const actual = await hashFile(safeChild(directory, file.name))
if ( if (
!installed || !installed ||
actual.size !== file.download.size || actual.size !== file.size ||
actual.sha256 !== file.download.sha256 || actual.sha256 !== file.sha256 ||
actual.size !== installed.size || actual.size !== installed.size ||
actual.sha256 !== installed.sha256 actual.sha256 !== installed.sha256
) { ) {
@@ -879,8 +904,8 @@ export class DocumentOcrModelManager {
} }
if ( if (
!installed || !installed ||
actual.size !== file.download.size || actual.size !== file.size ||
actual.sha256 !== file.download.sha256 || actual.sha256 !== file.sha256 ||
actual.size !== installed.size || actual.size !== installed.size ||
actual.sha256 !== installed.sha256 actual.sha256 !== installed.sha256
) { ) {
+6 -2
View File
@@ -440,7 +440,9 @@ if (hasSingleInstanceLock) {
) )
documentOcrModelManager = new DocumentOcrModelManager({ documentOcrModelManager = new DocumentOcrModelManager({
userDataDirectory: app.getPath('userData'), userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch fetch: globalThis.fetch,
getDownloadSource: async () =>
(await applicationSettingsStore.get()).modelDownloadSource
}) })
documentOcrBroker = new DocumentOcrBroker(mainWindow) documentOcrBroker = new DocumentOcrBroker(mainWindow)
const documentParsingService = new DocumentParsingService( const documentParsingService = new DocumentParsingService(
@@ -456,7 +458,9 @@ if (hasSingleInstanceLock) {
}) })
const speechModelManager = new SpeechModelManager({ const speechModelManager = new SpeechModelManager({
userDataDirectory: app.getPath('userData'), userDataDirectory: app.getPath('userData'),
fetch: globalThis.fetch fetch: globalThis.fetch,
getDownloadSource: async () =>
(await applicationSettingsStore.get()).modelDownloadSource
}) })
const speechTranscriptionService = new SpeechTranscriptionService( const speechTranscriptionService = new SpeechTranscriptionService(
speechModelManager speechModelManager
+173
View File
@@ -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', () => ({ vi.mock('electron', () => ({
app: { app: {
getName: vi.fn(() => 'GoodBuddy'), getName: vi.fn(() => 'GoodBuddy'),
+43 -24
View File
@@ -109,6 +109,7 @@ import { applicationSettingsUpdateSchema } from '../shared/application-settings-
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts' import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
import { import {
speechModelActionInputSchema, speechModelActionInputSchema,
speechModelInstallInputSchema,
speechModelSelectionInputSchema speechModelSelectionInputSchema
} from '../shared/speech-model-contracts' } from '../shared/speech-model-contracts'
import { import {
@@ -119,6 +120,7 @@ import {
} from '../shared/embedding-contracts' } from '../shared/embedding-contracts'
import { import {
documentOcrModelActionInputSchema, documentOcrModelActionInputSchema,
documentOcrModelInstallInputSchema,
documentOcrFailureSchema, documentOcrFailureSchema,
documentOcrResultSchema, documentOcrResultSchema,
documentParsingSettingsUpdateSchema, documentParsingSettingsUpdateSchema,
@@ -3513,16 +3515,25 @@ export function registerIpcHandlers(
registerHandler( registerHandler(
ipcChannels.documentOcrModelsInstall, ipcChannels.documentOcrModelsInstall,
(event, input: unknown) => { async (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
if (!documentOcrModelManager || !documentParsingService) { if (
!documentOcrModelManager ||
!documentParsingService ||
!applicationSettingsStore
) {
throw new Error('本地 OCR 模型服务不可用') throw new Error('本地 OCR 模型服务不可用')
} }
const { modelId } = const { modelId, expectedDownloadSource } =
documentOcrModelActionInputSchema.parse(input) documentOcrModelInstallInputSchema.parse(input)
const { modelDownloadSource: selectedDownloadSource } =
await applicationSettingsStore.get()
if (selectedDownloadSource !== expectedDownloadSource) {
throw new Error('模型下载源已变化,请刷新后重试')
}
return trackExecution( return trackExecution(
documentOcrModelManager documentOcrModelManager
.install(modelId) .install(modelId, selectedDownloadSource)
.then(() => documentParsingService.snapshot()) .then(() => documentParsingService.snapshot())
) )
} }
@@ -3611,19 +3622,19 @@ export function registerIpcHandlers(
ipcChannels.documentOcrModelsOpenRepository, ipcChannels.documentOcrModelsOpenRepository,
async (event, input: unknown) => { async (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
if (!documentOcrModelManager) { if (!documentOcrModelManager || !applicationSettingsStore) {
throw new Error('本地 OCR 模型服务不可用') throw new Error('本地 OCR 模型服务不可用')
} }
const { modelId } = const { modelId } =
documentOcrModelActionInputSchema.parse(input) documentOcrModelActionInputSchema.parse(input)
const snapshot = await documentOcrModelManager.getSnapshot() const { modelDownloadSource: selectedDownloadSource } =
const entry = snapshot.catalog.find( await applicationSettingsStore.get()
(candidate) => candidate.id === modelId 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( registerHandler(
ipcChannels.speechModelsInstall, ipcChannels.speechModelsInstall,
(event, input: unknown) => { async (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
if (!speechModelManager) { if (!speechModelManager || !applicationSettingsStore) {
throw new Error('语音模型服务不可用') 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( return trackExecution(
speechModelManager speechModelManager
.install(modelId) .install(modelId, selectedDownloadSource)
.then(() => speechModelManager.getSnapshot()) .then(() => speechModelManager.getSnapshot())
) )
} }
@@ -3862,16 +3879,18 @@ export function registerIpcHandlers(
ipcChannels.speechModelsOpenRepository, ipcChannels.speechModelsOpenRepository,
async (event, input: unknown) => { async (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
if (!speechModelManager) { if (!speechModelManager || !applicationSettingsStore) {
throw new Error('语音模型服务不可用') throw new Error('语音模型服务不可用')
} }
const { modelId } = speechModelActionInputSchema.parse(input) const { modelId } = speechModelActionInputSchema.parse(input)
const snapshot = await speechModelManager.getSnapshot() const { modelDownloadSource: selectedDownloadSource } =
const entry = snapshot.catalog.find((item) => item.id === modelId) await applicationSettingsStore.get()
if (!entry) { await shell.openExternal(
throw new Error('未知的语音模型') speechModelManager.getRepositoryUrl(
} modelId,
await shell.openExternal(entry.repositoryUrl) selectedDownloadSource
)
)
} }
) )
+34
View File
@@ -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']
]
}
}
+71
View File
@@ -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
}
}
+214 -155
View File
@@ -2,11 +2,33 @@ import {
speechModelCatalogEntrySchema, speechModelCatalogEntrySchema,
type SpeechModelCatalogEntry type SpeechModelCatalogEntry
} from '../../shared/speech-model-contracts' } 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 * Model weights are never bundled with GoodBuddy. Canonical file identity is
* bundled with GoodBuddy. Entries remain manual-only until every downloadable * source-independent; a source target is included only after its bytes match
* file has a pinned revision, byte size, and independently verified SHA-256. * the declared size and SHA-256.
*/ */
export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
speechModelCatalogEntrySchema.array().parse([ speechModelCatalogEntrySchema.array().parse([
@@ -21,13 +43,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high', quality: 'high',
speed: 'fast', speed: 'fast',
recommended: true, recommended: true,
repositoryUrl: repositoryUrls: {
'https://modelscope.cn/models/pengzhendong/' + modelscope:
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue', `https://modelscope.cn/models/${senseVoiceModelScopeRepository}`,
'hugging-face':
`https://huggingface.co/${senseVoiceHuggingFaceRepository}`
},
license: { license: {
name: '模型仓库自定义许可(Model License', name: '模型仓库自定义许可(Model License',
notice: 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' url: 'https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE'
}, },
manualOnly: false, manualOnly: false,
@@ -35,29 +60,39 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{ {
name: 'model.int8.onnx', name: 'model.int8.onnx',
role: 'model', role: 'model',
download: { size: 239_233_841,
url: sha256:
'https://modelscope.cn/models/pengzhendong/' + 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' + targets: {
'resolve/73eca47697f980daa3d16112404174b6b950b514/' + modelscope: modelScopeTarget(
'model.int8.onnx', senseVoiceModelScopeRepository,
size: 239_233_841, senseVoiceModelScopeRevision,
sha256: 'model.int8.onnx'
'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51' ),
'hugging-face': huggingFaceTarget(
senseVoiceHuggingFaceRepository,
senseVoiceHuggingFaceRevision,
'model.int8.onnx'
)
} }
}, },
{ {
name: 'tokens.txt', name: 'tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: 315_894,
url: sha256:
'https://modelscope.cn/models/pengzhendong/' + 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' + targets: {
'resolve/73eca47697f980daa3d16112404174b6b950b514/' + modelscope: modelScopeTarget(
'tokens.txt', senseVoiceModelScopeRepository,
size: 315_894, senseVoiceModelScopeRevision,
sha256: 'tokens.txt'
'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc' ),
'hugging-face': huggingFaceTarget(
senseVoiceHuggingFaceRepository,
senseVoiceHuggingFaceRevision,
'tokens.txt'
)
} }
} }
] ]
@@ -73,9 +108,12 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'basic', quality: 'basic',
speed: 'fast', speed: 'fast',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: {
'https://modelscope.cn/models/pengzhendong/' + modelscope:
'sherpa-onnx-whisper-tiny', `https://modelscope.cn/models/${whisperTinyModelScopeRepository}`,
'hugging-face':
`https://huggingface.co/${whisperTinyHuggingFaceRepository}`
},
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: notice:
@@ -87,43 +125,58 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{ {
name: 'tiny-encoder.int8.onnx', name: 'tiny-encoder.int8.onnx',
role: 'encoder', role: 'encoder',
download: { size: 12_937_772,
url: sha256:
'https://modelscope.cn/models/pengzhendong/' + 'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434',
'sherpa-onnx-whisper-tiny/resolve/' + targets: {
'33a655645234f82ce833cf27b689d9c2212e693f/' + modelscope: modelScopeTarget(
'tiny-encoder.int8.onnx', whisperTinyModelScopeRepository,
size: 12_937_772, whisperTinyModelScopeRevision,
sha256: 'tiny-encoder.int8.onnx'
'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434' ),
'hugging-face': huggingFaceTarget(
whisperTinyHuggingFaceRepository,
whisperTinyHuggingFaceRevision,
'tiny-encoder.int8.onnx'
)
} }
}, },
{ {
name: 'tiny-decoder.int8.onnx', name: 'tiny-decoder.int8.onnx',
role: 'decoder', role: 'decoder',
download: { size: 89_855_401,
url: sha256:
'https://modelscope.cn/models/pengzhendong/' + 'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925',
'sherpa-onnx-whisper-tiny/resolve/' + targets: {
'33a655645234f82ce833cf27b689d9c2212e693f/' + modelscope: modelScopeTarget(
'tiny-decoder.int8.onnx', whisperTinyModelScopeRepository,
size: 89_855_401, whisperTinyModelScopeRevision,
sha256: 'tiny-decoder.int8.onnx'
'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925' ),
'hugging-face': huggingFaceTarget(
whisperTinyHuggingFaceRepository,
whisperTinyHuggingFaceRevision,
'tiny-decoder.int8.onnx'
)
} }
}, },
{ {
name: 'tiny-tokens.txt', name: 'tiny-tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: 816_730,
url: sha256:
'https://modelscope.cn/models/pengzhendong/' + 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
'sherpa-onnx-whisper-tiny/resolve/' + targets: {
'33a655645234f82ce833cf27b689d9c2212e693f/' + modelscope: modelScopeTarget(
'tiny-tokens.txt', whisperTinyModelScopeRepository,
size: 816_730, whisperTinyModelScopeRevision,
sha256: 'tiny-tokens.txt'
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126' ),
'hugging-face': huggingFaceTarget(
whisperTinyHuggingFaceRepository,
whisperTinyHuggingFaceRevision,
'tiny-tokens.txt'
)
} }
} }
] ]
@@ -139,9 +192,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high', quality: 'high',
speed: 'fast', speed: 'fast',
recommended: true, recommended: true,
repositoryUrl: repositoryUrls: {
'https://huggingface.co/csukuangfj/' + 'hugging-face':
'sherpa-onnx-paraformer-bilingual-zh-en', 'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en'
},
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: notice:
@@ -156,29 +210,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{ {
name: 'model.int8.onnx', name: 'model.int8.onnx',
role: 'model', role: 'model',
download: { size: 223_385_835,
url: sha256:
'https://huggingface.co/csukuangfj/' + '9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7',
'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' + targets: {
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' + 'hugging-face': huggingFaceTarget(
'model.int8.onnx', 'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
size: 223_385_835, '4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
sha256: 'model.int8.onnx'
'9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7' )
} }
}, },
{ {
name: 'tokens.txt', name: 'tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: 75_756,
url: sha256:
'https://huggingface.co/csukuangfj/' + '59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6',
'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' + targets: {
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' + 'hugging-face': huggingFaceTarget(
'tokens.txt', 'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
size: 75_756, '4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
sha256: 'tokens.txt'
'59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6' )
} }
} }
] ]
@@ -194,9 +248,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high', quality: 'high',
speed: 'balanced', speed: 'balanced',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: {
'https://huggingface.co/csukuangfj/' + 'hugging-face':
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en', 'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en'
},
license: { license: {
name: 'Apache License 2.0', name: 'Apache License 2.0',
notice: notice:
@@ -211,29 +266,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{ {
name: 'model.int8.onnx', name: 'model.int8.onnx',
role: 'model', role: 'model',
download: { size: 244_684_152,
url: sha256:
'https://huggingface.co/csukuangfj/' + 'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144',
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' + targets: {
'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' + 'hugging-face': huggingFaceTarget(
'model.int8.onnx', 'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
size: 244_684_152, '8d90151338178bb433354c9fb677bd3acb8023cd',
sha256: 'model.int8.onnx'
'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144' )
} }
}, },
{ {
name: 'tokens.txt', name: 'tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: 118_931,
url: sha256:
'https://huggingface.co/csukuangfj/' + '8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492',
'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' + targets: {
'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' + 'hugging-face': huggingFaceTarget(
'tokens.txt', 'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
size: 118_931, '8d90151338178bb433354c9fb677bd3acb8023cd',
sha256: 'tokens.txt'
'8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492' )
} }
} }
] ]
@@ -249,8 +304,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'balanced', quality: 'balanced',
speed: 'balanced', speed: 'balanced',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: {
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small', 'hugging-face':
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small'
},
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: notice:
@@ -262,43 +319,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{ {
name: 'small-encoder.int8.onnx', name: 'small-encoder.int8.onnx',
role: 'encoder', role: 'encoder',
download: { size: 112_442_483,
url: sha256:
'https://huggingface.co/csukuangfj/' + '4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9',
'sherpa-onnx-whisper-small/resolve/' + targets: {
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' + 'hugging-face': huggingFaceTarget(
'small-encoder.int8.onnx', 'csukuangfj/sherpa-onnx-whisper-small',
size: 112_442_483, '8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
sha256: 'small-encoder.int8.onnx'
'4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9' )
} }
}, },
{ {
name: 'small-decoder.int8.onnx', name: 'small-decoder.int8.onnx',
role: 'decoder', role: 'decoder',
download: { size: 262_226_114,
url: sha256:
'https://huggingface.co/csukuangfj/' + 'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee',
'sherpa-onnx-whisper-small/resolve/' + targets: {
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' + 'hugging-face': huggingFaceTarget(
'small-decoder.int8.onnx', 'csukuangfj/sherpa-onnx-whisper-small',
size: 262_226_114, '8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
sha256: 'small-decoder.int8.onnx'
'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee' )
} }
}, },
{ {
name: 'small-tokens.txt', name: 'small-tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: 816_730,
url: sha256:
'https://huggingface.co/csukuangfj/' + 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
'sherpa-onnx-whisper-small/resolve/' + targets: {
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' + 'hugging-face': huggingFaceTarget(
'small-tokens.txt', 'csukuangfj/sherpa-onnx-whisper-small',
size: 816_730, '8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
sha256: 'small-tokens.txt'
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126' )
} }
} }
] ]
@@ -314,8 +371,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
quality: 'high', quality: 'high',
speed: 'slow', speed: 'slow',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: {
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium', 'hugging-face':
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium'
},
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: notice:
@@ -327,43 +386,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
{ {
name: 'medium-encoder.int8.onnx', name: 'medium-encoder.int8.onnx',
role: 'encoder', role: 'encoder',
download: { size: 374_196_283,
url: sha256:
'https://huggingface.co/csukuangfj/' + '1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e',
'sherpa-onnx-whisper-medium/resolve/' + targets: {
'8c31d28503847560985df21f90e14f0c736e075e/' + 'hugging-face': huggingFaceTarget(
'medium-encoder.int8.onnx', 'csukuangfj/sherpa-onnx-whisper-medium',
size: 374_196_283, '8c31d28503847560985df21f90e14f0c736e075e',
sha256: 'medium-encoder.int8.onnx'
'1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e' )
} }
}, },
{ {
name: 'medium-decoder.int8.onnx', name: 'medium-decoder.int8.onnx',
role: 'decoder', role: 'decoder',
download: { size: 571_059_257,
url: sha256:
'https://huggingface.co/csukuangfj/' + '595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747',
'sherpa-onnx-whisper-medium/resolve/' + targets: {
'8c31d28503847560985df21f90e14f0c736e075e/' + 'hugging-face': huggingFaceTarget(
'medium-decoder.int8.onnx', 'csukuangfj/sherpa-onnx-whisper-medium',
size: 571_059_257, '8c31d28503847560985df21f90e14f0c736e075e',
sha256: 'medium-decoder.int8.onnx'
'595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747' )
} }
}, },
{ {
name: 'medium-tokens.txt', name: 'medium-tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: 816_730,
url: sha256:
'https://huggingface.co/csukuangfj/' + 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
'sherpa-onnx-whisper-medium/resolve/' + targets: {
'8c31d28503847560985df21f90e14f0c736e075e/' + 'hugging-face': huggingFaceTarget(
'medium-tokens.txt', 'csukuangfj/sherpa-onnx-whisper-medium',
size: 816_730, '8c31d28503847560985df21f90e14f0c736e075e',
sha256: 'medium-tokens.txt'
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126' )
} }
} }
] ]
+250 -45
View File
@@ -35,12 +35,36 @@ function sha256(value: Uint8Array): string {
} }
function manualCatalog(): SpeechModelCatalogEntry[] { 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, ...entry,
manualOnly: true, manualOnly: true,
manualReason: entry.manualReason ?? '测试使用本地目录导入。', manualReason: '测试使用本地目录导入。',
files: entry.files.map(({ name, role }) => ({ name, role })) 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( function downloadableCatalog(
@@ -58,8 +82,12 @@ function downloadableCatalog(
quality: 'balanced', quality: 'balanced',
speed: 'balanced', speed: 'balanced',
recommended: false, recommended: false,
repositoryUrl: repositoryUrls: {
'https://modelscope.cn/models/example/download-test-model', modelscope:
'https://modelscope.cn/models/example/download-test-model',
'hugging-face':
'https://huggingface.co/example/download-test-model'
},
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: 'Test-only model metadata.', notice: 'Test-only model metadata.',
@@ -70,23 +98,53 @@ function downloadableCatalog(
{ {
name: 'model.onnx', name: 'model.onnx',
role: 'model', role: 'model',
download: { size: modelBytes.byteLength,
url: sha256: sha256(modelBytes),
'https://modelscope.cn/models/example/download-test-model/' + targets: {
`resolve/${'a'.repeat(40)}/model.onnx`, modelscope: {
size: modelBytes.byteLength, url:
sha256: sha256(modelBytes) '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', name: 'tokens.txt',
role: 'tokens', role: 'tokens',
download: { size: tokenBytes.byteLength,
url: sha256: sha256(tokenBytes),
'https://modelscope.cn/models/example/download-test-model/' + targets: {
`resolve/${'a'.repeat(40)}/tokens.txt`, modelscope: {
size: tokenBytes.byteLength, url:
sha256: sha256(tokenBytes) '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' } license: { name: 'MIT License' }
}) })
expect( 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) ).toBe(true)
expect(whisper?.files.every((file) => file.download !== undefined))
.toBe(true)
expect(whisper?.files.map((file) => file.name)).toEqual([ expect(whisper?.files.map((file) => file.name)).toEqual([
'tiny-encoder.int8.onnx', 'tiny-encoder.int8.onnx',
'tiny-decoder.int8.onnx', 'tiny-decoder.int8.onnx',
@@ -163,15 +230,21 @@ describe('speech model catalog', () => {
}) })
expect(SPEECH_MODEL_CATALOG).toHaveLength(6) expect(SPEECH_MODEL_CATALOG).toHaveLength(6)
for (const entry of SPEECH_MODEL_CATALOG) { for (const entry of SPEECH_MODEL_CATALOG) {
expect(entry.repositoryUrl).toMatch(
/^https:\/\/(?:modelscope\.cn\/models\/|huggingface\.co\/)/u
)
for (const file of entry.files) { for (const file of entry.files) {
expect(file.download?.url).toMatch( expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u)
/^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/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) ).toBe(false)
await manager.select('download-test-model') 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', selectedModelId: 'download-test-model',
operations: [] 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 manager.remove('download-test-model')
await expect(manager.snapshot()).resolves.toMatchObject({ await expect(manager.snapshot()).resolves.toMatchObject({
selectedModelId: null, 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 userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected') const modelBytes = new TextEncoder().encode('expected')
const tokenBytes = new TextEncoder().encode('tokens') const tokenBytes = new TextEncoder().encode('tokens')
@@ -261,24 +394,24 @@ describe('SpeechModelManager downloads', () => {
...entry, ...entry,
files: entry.files.map((file) => ({ files: entry.files.map((file) => ({
...file, ...file,
download: file.download targets: {
? { ...file.targets,
...file.download, 'hugging-face': file.targets['hugging-face']
url: file.download.url.replace( ? {
'https://modelscope.cn', ...file.targets['hugging-face'],
'http://models.internal.example' redirectHosts: ['cdn-lfs.hf.co']
) }
} : undefined
: undefined }
})) }))
})) }))
const transport = vi.fn<typeof fetch>(async (input) => { const transport = vi.fn<typeof fetch>(async (input) => {
const url = new URL(String(input)) const url = new URL(String(input))
if (url.hostname === 'models.internal.example') { if (url.hostname === 'huggingface.co') {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { 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( await expect(
redirected.install('download-test-model') redirected.install('download-test-model', 'hugging-face')
).resolves.toMatchObject({ id: 'download-test-model' }) ).resolves.toMatchObject({ id: 'download-test-model' })
expect(transport).toHaveBeenCalledTimes(4) expect(transport).toHaveBeenCalledTimes(4)
expect( expect(
transport.mock.calls.map(([input]) => new URL(String(input)).hostname) transport.mock.calls.map(([input]) => new URL(String(input)).hostname)
).toEqual([ ).toEqual([
'models.internal.example', 'huggingface.co',
'cdn.example.net', 'cdn-lfs.hf.co',
'models.internal.example', 'huggingface.co',
'cdn.example.net' '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 () => { it('rejects bad digests without installing', async () => {
const userData = await temporaryDirectory() const userData = await temporaryDirectory()
const modelBytes = new TextEncoder().encode('expected') const modelBytes = new TextEncoder().encode('expected')
+93 -91
View File
@@ -16,22 +16,31 @@ import { z } from 'zod'
import { import {
installedSpeechModelSchema, installedSpeechModelSchema,
speechModelCatalogEntrySchema, speechModelCatalogEntrySchema,
speechModelCatalogViewEntrySchema,
speechModelIdSchema, speechModelIdSchema,
speechModelSnapshotSchema, speechModelSnapshotSchema,
type InstalledSpeechModel, type InstalledSpeechModel,
type SpeechModelCatalogEntry, type SpeechModelCatalogEntry,
type SpeechModelCatalogViewEntry,
type SpeechModelFileSpec, type SpeechModelFileSpec,
type SpeechModelOperation, type SpeechModelOperation,
type SpeechModelSnapshot type SpeechModelSnapshot
} from '../../shared/speech-model-contracts' } 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 { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
import { import {
exportModelArchive, exportModelArchive,
extractModelArchive extractModelArchive
} from '../model-archive' } from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MAX_REDIRECTS = 3
const MANIFEST_FILE_NAME = 'manifest.json' const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json' const SELECTION_FILE_NAME = '.selection.json'
const PARTIAL_SUFFIX = '.partial' const PARTIAL_SUFFIX = '.partial'
@@ -56,6 +65,9 @@ export type SpeechModelManagerOptions = {
userDataDirectory: string userDataDirectory: string
fetch: typeof fetch fetch: typeof fetch
catalog?: readonly SpeechModelCatalogEntry[] catalog?: readonly SpeechModelCatalogEntry[]
getDownloadSource?: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number maxFileBytes?: number
} }
@@ -72,6 +84,23 @@ function cloneCatalogEntry(
return speechModelCatalogEntrySchema.parse(entry) 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 { function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError') return new DOMException('The operation was aborted', 'AbortError')
} }
@@ -102,17 +131,6 @@ function safeChild(parent: string, name: string): string {
return child 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( async function hashFile(
path: string, path: string,
signal?: AbortSignal signal?: AbortSignal
@@ -147,6 +165,10 @@ export class SpeechModelManager {
private readonly transport: typeof fetch private readonly transport: typeof fetch
private readonly catalog: SpeechModelCatalogEntry[] private readonly catalog: SpeechModelCatalogEntry[]
private readonly catalogViews: SpeechModelCatalogViewEntry[]
private readonly getDownloadSource: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>() private readonly operations = new Map<string, ActiveOperation>()
@@ -160,23 +182,31 @@ export class SpeechModelManager {
'speech' 'speech'
) )
this.transport = options.fetch this.transport = options.fetch
this.getDownloadSource =
options.getDownloadSource ?? (() => 'modelscope')
this.catalog = (options.catalog ?? SPEECH_MODEL_CATALOG).map( this.catalog = (options.catalog ?? SPEECH_MODEL_CATALOG).map(
cloneCatalogEntry cloneCatalogEntry
) )
if (new Set(this.catalog.map((entry) => entry.id)).size !== this.catalog.length) { if (new Set(this.catalog.map((entry) => entry.id)).size !== this.catalog.length) {
throw new Error('语音模型目录包含重复 ID') throw new Error('语音模型目录包含重复 ID')
} }
this.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes) this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
} }
async snapshot(): Promise<SpeechModelSnapshot> { async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot() await this.ensureRoot()
const installed = await this.readInstalled() const [installed, selected, selectedDownloadSource] =
const selected = await this.readSelection() await Promise.all([
this.readInstalled(),
this.readSelection(),
this.getDownloadSource()
])
const installedIds = new Set(installed.map((model) => model.id)) const installedIds = new Set(installed.map((model) => model.id))
return speechModelSnapshotSchema.parse({ return speechModelSnapshotSchema.parse({
rootDirectory: this.rootDirectory, rootDirectory: this.rootDirectory,
catalog: this.catalog.map(cloneCatalogEntry), selectedDownloadSource,
catalog: this.catalogViews,
installed, installed,
operations: [...this.operations.values()].map((operation) => ({ operations: [...this.operations.values()].map((operation) => ({
...operation.progress ...operation.progress
@@ -190,6 +220,19 @@ export class SpeechModelManager {
return this.snapshot() 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< async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined SelectedSpeechRuntimeModel | undefined
> { > {
@@ -216,6 +259,7 @@ export class SpeechModelManager {
async install( async install(
modelId: string, modelId: string,
downloadSource?: ModelDownloadSource,
externalSignal?: AbortSignal externalSignal?: AbortSignal
): Promise<InstalledSpeechModel> { ): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId) const entry = this.requireCatalogEntry(modelId)
@@ -224,27 +268,17 @@ export class SpeechModelManager {
entry.manualReason ?? '该模型只能从本地目录导入' entry.manualReason ?? '该模型只能从本地目录导入'
) )
} }
const downloadableFiles = entry.files.filter( const selectedDownloadSource =
( downloadSource ?? (await this.getDownloadSource())
file const resolvedPackage = resolveModelDownloadPackage(
): file is SpeechModelFileSpec & { entry.files,
download: NonNullable<SpeechModelFileSpec['download']> selectedDownloadSource
} => file.download !== undefined
) )
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( const operation = this.beginOperation(
entry.id, entry.id,
'download', 'download',
totalBytes resolvedPackage.totalBytes,
resolvedPackage.source
) )
const detachExternalAbort = this.attachExternalSignal( const detachExternalAbort = this.attachExternalSignal(
externalSignal, externalSignal,
@@ -255,7 +289,7 @@ export class SpeechModelManager {
await this.ensureRoot() await this.ensureRoot()
await this.assertNotInstalled(entry.id) await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id) stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of downloadableFiles) { for (const file of resolvedPackage.files) {
ensureNotAborted(operation.controller.signal) ensureNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring' operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name operation.progress.currentFile = file.name
@@ -409,9 +443,8 @@ export class SpeechModelManager {
!recorded || !recorded ||
recorded.size <= 0 || recorded.size <= 0 ||
recorded.size > this.maxFileBytes || recorded.size > this.maxFileBytes ||
(expected.download && recorded.size !== expected.size ||
(recorded.size !== expected.download.size || recorded.sha256 !== expected.sha256
recorded.sha256 !== expected.download.sha256))
) { ) {
throw new Error(`语音模型文件不可导出:${expected.name}`) throw new Error(`语音模型文件不可导出:${expected.name}`)
} }
@@ -440,8 +473,7 @@ export class SpeechModelManager {
): Promise<InstalledSpeechModel> { ): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId) const entry = this.requireCatalogEntry(modelId)
const expectedTotal = entry.files.reduce( const expectedTotal = entry.files.reduce(
(total, file) => (total, file) => total + file.size,
total + (file.download?.size ?? this.maxFileBytes),
0 0
) )
const maximumTotalBytes = Math.min( const maximumTotalBytes = Math.min(
@@ -488,9 +520,8 @@ export class SpeechModelManager {
if ( if (
!archived || !archived ||
archived.size > this.maxFileBytes || archived.size > this.maxFileBytes ||
(expected.download && archived.size !== expected.size ||
(archived.size !== expected.download.size || archived.sha256 !== expected.sha256
archived.sha256 !== expected.download.sha256))
) { ) {
throw new Error( throw new Error(
`语音模型 ZIP 与当前模型目录不匹配:${expected.name}` `语音模型 ZIP 与当前模型目录不匹配:${expected.name}`
@@ -544,7 +575,8 @@ export class SpeechModelManager {
private beginOperation( private beginOperation(
modelId: string, modelId: string,
kind: SpeechModelOperation['kind'], kind: SpeechModelOperation['kind'],
totalBytes: number | null totalBytes: number | null,
downloadSource?: ModelDownloadSource
): ActiveOperation { ): ActiveOperation {
if (this.operations.has(modelId)) { if (this.operations.has(modelId)) {
throw new Error('该模型已有进行中的操作') throw new Error('该模型已有进行中的操作')
@@ -557,7 +589,8 @@ export class SpeechModelManager {
phase: 'preparing', phase: 'preparing',
currentFile: null, currentFile: null,
completedBytes: 0, completedBytes: 0,
totalBytes totalBytes,
...(downloadSource ? { downloadSource } : {})
} }
} }
this.operations.set(modelId, operation) this.operations.set(modelId, operation)
@@ -605,55 +638,25 @@ export class SpeechModelManager {
return directory 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( private async downloadFile(
file: SpeechModelFileSpec & { file: ResolvedModelArtifactFile<SpeechModelFileSpec['role']>,
download: NonNullable<SpeechModelFileSpec['download']>
},
destination: string, destination: string,
operation: ActiveOperation, operation: ActiveOperation,
signal: AbortSignal signal: AbortSignal
): Promise<void> { ): Promise<void> {
if ( if (
file.download.size > this.maxFileBytes || file.size > this.maxFileBytes ||
file.download.size <= 0 file.size <= 0
) { ) {
throw new RangeError(`模型文件大小超出限制:${file.name}`) throw new RangeError(`模型文件大小超出限制:${file.name}`)
} }
const response = await this.fetchFollowingRedirects( const response = await fetchModelDownloadResponse({
file.download.url, transport: this.transport,
signal initialUrl: file.target.url,
) redirectHosts: file.target.redirectHosts,
signal,
modelLabel: '模型'
})
if (!response.ok) { if (!response.ok) {
await response.body?.cancel().catch(() => undefined) await response.body?.cancel().catch(() => undefined)
throw new Error(`模型下载失败:HTTP ${response.status}`) throw new Error(`模型下载失败:HTTP ${response.status}`)
@@ -666,7 +669,7 @@ export class SpeechModelManager {
const parsedLength = Number(declaredLength) const parsedLength = Number(declaredLength)
if ( if (
!Number.isSafeInteger(parsedLength) || !Number.isSafeInteger(parsedLength) ||
parsedLength !== file.download.size parsedLength !== file.size
) { ) {
await response.body.cancel().catch(() => undefined) await response.body.cancel().catch(() => undefined)
throw new Error(`模型文件大小不匹配:${file.name}`) throw new Error(`模型文件大小不匹配:${file.name}`)
@@ -687,7 +690,7 @@ export class SpeechModelManager {
} }
written += result.value.byteLength written += result.value.byteLength
if ( if (
written > file.download.size || written > file.size ||
written > this.maxFileBytes written > this.maxFileBytes
) { ) {
await reader.cancel() await reader.cancel()
@@ -703,10 +706,10 @@ export class SpeechModelManager {
} finally { } finally {
await handle.close() await handle.close()
} }
if (written !== file.download.size) { if (written !== file.size) {
throw new Error(`模型文件大小不匹配:${file.name}`) throw new Error(`模型文件大小不匹配:${file.name}`)
} }
if (hash.digest('hex') !== file.download.sha256) { if (hash.digest('hex') !== file.sha256) {
throw new Error(`模型文件校验失败:${file.name}`) throw new Error(`模型文件校验失败:${file.name}`)
} }
await rename(partialPath, destination) await rename(partialPath, destination)
@@ -741,10 +744,9 @@ export class SpeechModelManager {
throw new RangeError(`模型文件大小无效:${expectedFile.name}`) throw new RangeError(`模型文件大小无效:${expectedFile.name}`)
} }
if ( if (
expectedFile.download && sourceFileInfo.size !== expectedFile.size ||
(sourceFileInfo.size !== expectedFile.download.size || (await hashFile(sourceFile, signal)).sha256 !==
(await hashFile(sourceFile, signal)).sha256 !== expectedFile.sha256
expectedFile.download.sha256)
) { ) {
throw new Error(`本地模型文件校验失败:${expectedFile.name}`) throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
} }
+11 -4
View File
@@ -72,6 +72,7 @@ import type {
import type { import type {
ApplicationSettings, ApplicationSettings,
ApplicationSettingsUpdate, ApplicationSettingsUpdate,
ModelDownloadSource,
VersionCheckResult VersionCheckResult
} from '../shared/application-settings-contracts' } from '../shared/application-settings-contracts'
import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts' import type { ReleaseNotesSnapshot } from '../shared/release-notes-contracts'
@@ -404,10 +405,13 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.speechModelsGet ipcChannels.speechModelsGet
) as Promise<SpeechModelSnapshot>, ) as Promise<SpeechModelSnapshot>,
install: (modelId: string) => install: (
modelId: string,
expectedDownloadSource: ModelDownloadSource
) =>
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.speechModelsInstall, ipcChannels.speechModelsInstall,
{ modelId } { modelId, expectedDownloadSource }
) as Promise<SpeechModelSnapshot>, ) as Promise<SpeechModelSnapshot>,
cancel: (modelId: string) => cancel: (modelId: string) =>
ipcRenderer.invoke( ipcRenderer.invoke(
@@ -481,10 +485,13 @@ const desktopApi: DesktopApi = {
ipcChannels.documentParsingTest, ipcChannels.documentParsingTest,
{ purpose } { purpose }
) as Promise<DocumentParsingDiagnostic | undefined>, ) as Promise<DocumentParsingDiagnostic | undefined>,
installOcrModel: (modelId: string) => installOcrModel: (
modelId: string,
expectedDownloadSource: ModelDownloadSource
) =>
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.documentOcrModelsInstall, ipcChannels.documentOcrModelsInstall,
{ modelId } { modelId, expectedDownloadSource }
) as Promise<DocumentParsingSnapshot>, ) as Promise<DocumentParsingSnapshot>,
cancelOcrModelOperation: (modelId: string) => cancelOcrModelOperation: (modelId: string) =>
ipcRenderer.invoke( ipcRenderer.invoke(
+13
View File
@@ -1084,6 +1084,7 @@ describe('App', () => {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -1176,6 +1177,7 @@ describe('App', () => {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -1183,6 +1185,7 @@ describe('App', () => {
updateSettings: vi.fn(async () => ({ updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -1276,6 +1279,7 @@ describe('App', () => {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -1283,6 +1287,7 @@ describe('App', () => {
updateSettings: vi.fn(async () => ({ updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -7206,6 +7211,7 @@ describe('App', () => {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -7213,6 +7219,7 @@ describe('App', () => {
updateSettings: vi.fn(async () => ({ updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -7253,6 +7260,7 @@ describe('App', () => {
getSettings: vi.fn(async () => ({ getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -7260,6 +7268,7 @@ describe('App', () => {
updateSettings: vi.fn(async () => ({ updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github' as const, updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const magicNoteCommentFormat: 'combined' as const
@@ -7292,6 +7301,7 @@ describe('App', () => {
let applicationSettings: ApplicationSettings = { let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -7315,6 +7325,9 @@ describe('App', () => {
fireEvent.click( fireEvent.click(
screen.getByRole('tab', { name: '平台功能' }) screen.getByRole('tab', { name: '平台功能' })
) )
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
const toggle = await screen.findByRole('switch', { const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口' name: '显示魔法笔记入口'
}) })
@@ -31,8 +31,6 @@ const modelEntry = {
quality: 'basic' as const, quality: 'basic' as const,
speed: 'fast' as const, speed: 'fast' as const,
recommended: false, recommended: false,
repositoryUrl:
'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx',
license: { license: {
name: 'Apache License 2.0', name: 'Apache License 2.0',
notice: '使用前请阅读模型许可证。', notice: '使用前请阅读模型许可证。',
@@ -42,29 +40,32 @@ const modelEntry = {
{ {
name: 'detection.onnx', name: 'detection.onnx',
role: 'detection' as const, role: 'detection' as const,
download: { size: 1_000,
url: 'https://modelscope.cn/models/example/detection.onnx', sha256: 'a'.repeat(64)
size: 1_000,
sha256: 'a'.repeat(64)
}
}, },
{ {
name: 'recognition.onnx', name: 'recognition.onnx',
role: 'recognition' as const, role: 'recognition' as const,
download: { size: 2_000,
url: 'https://modelscope.cn/models/example/recognition.onnx', sha256: 'b'.repeat(64)
size: 2_000,
sha256: 'b'.repeat(64)
}
}, },
{ {
name: 'dictionary.yml', name: 'dictionary.yml',
role: 'dictionary' as const, role: 'dictionary' as const,
download: { size: 500,
url: 'https://modelscope.cn/models/example/dictionary.yml', 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: { ocrModels: {
rootDirectory: 'C:\\Users\\test\\models\\document-ocr', rootDirectory: 'C:\\Users\\test\\models\\document-ocr',
selectedDownloadSource: 'modelscope',
catalog: [modelEntry, secondModelEntry, thirdModelEntry], catalog: [modelEntry, secondModelEntry, thirdModelEntry],
installed: [ installed: [
{ {
@@ -111,8 +113,8 @@ const snapshot: DocumentParsingSnapshot = {
files: secondModelEntry.files.map((file) => ({ files: secondModelEntry.files.map((file) => ({
name: file.name, name: file.name,
role: file.role, role: file.role,
size: file.download.size, size: file.size,
sha256: file.download.sha256 sha256: file.sha256
})) }))
} }
], ],
@@ -137,7 +139,7 @@ const test = vi.fn(async () => ({
warnings: [] warnings: []
})) }))
const installOcrModel = const installOcrModel =
vi.fn<() => Promise<DocumentParsingSnapshot>>(async () => ({ vi.fn(async (): Promise<DocumentParsingSnapshot> => ({
...snapshot, ...snapshot,
status: { status: {
...snapshot.status, ...snapshot.status,
@@ -159,8 +161,8 @@ const installOcrModel =
files: modelEntry.files.map((file) => ({ files: modelEntry.files.map((file) => ({
name: file.name, name: file.name,
role: file.role, role: file.role,
size: file.download.size, size: file.size,
sha256: file.download.sha256 sha256: file.sha256
})) }))
} }
] ]
@@ -305,7 +307,10 @@ describe('DocumentParsingSettingsSection', () => {
) )
await waitFor(() => await waitFor(() =>
expect(installOcrModel).toHaveBeenCalledWith('pp-ocrv6-tiny') expect(installOcrModel).toHaveBeenCalledWith(
'pp-ocrv6-tiny',
'modelscope'
)
) )
expect(onNotify).toHaveBeenCalledWith( expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -336,8 +341,8 @@ describe('DocumentParsingSettingsSection', () => {
files: thirdModelEntry.files.map((file) => ({ files: thirdModelEntry.files.map((file) => ({
name: file.name, name: file.name,
role: file.role, role: file.role,
size: file.download.size, size: file.size,
sha256: file.download.sha256 sha256: file.sha256
})) }))
} satisfies InstalledDocumentOcrModel } satisfies InstalledDocumentOcrModel
] ]
@@ -22,7 +22,7 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { import type {
DocumentParsingDiagnostic, DocumentParsingDiagnostic,
DocumentOcrModelCatalogEntry, DocumentOcrModelCatalogViewEntry,
DocumentOcrModelOperation, DocumentOcrModelOperation,
DocumentParsingSettings, DocumentParsingSettings,
DocumentParsingSnapshot, DocumentParsingSnapshot,
@@ -36,6 +36,7 @@ import {
type DocumentParsingSettingsSectionProps = { type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
onOpenModelDownloadSourceSettings?: () => void
} }
function errorMessage(reason: unknown, fallback: string): string { function errorMessage(reason: unknown, fallback: string): string {
@@ -54,9 +55,9 @@ function formatBytes(bytes: number): string {
: `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024).toFixed(1)} KB`
} }
function catalogSize(entry: DocumentOcrModelCatalogEntry): number { function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
return entry.files.reduce( return entry.files.reduce(
(total, file) => total + file.download.size, (total, file) => total + file.size,
0 0
) )
} }
@@ -207,7 +208,8 @@ function DiagnosticDialog({
} }
export function DocumentParsingSettingsSection({ export function DocumentParsingSettingsSection({
onNotify onNotify,
onOpenModelDownloadSourceSettings
}: DocumentParsingSettingsSectionProps): React.JSX.Element { }: DocumentParsingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>() const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
@@ -439,6 +441,13 @@ export function DocumentParsingSettingsSection({
const modelProgress = modelOperation const modelProgress = modelOperation
? progressPercent(modelOperation) ? progressPercent(modelOperation)
: undefined : undefined
const modelDownloadAvailability = model?.downloadAvailability.find(
(availability) =>
availability.source ===
snapshot.ocrModels.selectedDownloadSource
)
const modelSourceAvailable =
modelDownloadAvailability?.available === true
const pendingModelSelection = const pendingModelSelection =
draft.localOcrModelId !== snapshot.settings.localOcrModelId draft.localOcrModelId !== snapshot.settings.localOcrModelId
const settingsDirty = const settingsDirty =
@@ -701,7 +710,17 @@ export function DocumentParsingSettingsSection({
{entryDisplayName} ·{' '} {entryDisplayName} ·{' '}
{installed {installed
? t('documentParsing.ocr.installedOption') ? 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> </option>
) )
})} })}
@@ -720,6 +739,13 @@ export function DocumentParsingSettingsSection({
<code>{snapshot.ocrModels.rootDirectory}</code> <code>{snapshot.ocrModels.rootDirectory}</code>
{t('documentParsing.ocr.storageSuffix')} {t('documentParsing.ocr.storageSuffix')}
</p> </p>
<p className="settings-notice">
{t('documentParsing.ocr.downloadSource', {
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
})}
</p>
{model ? ( {model ? (
<article className="document-ocr-model"> <article className="document-ocr-model">
@@ -735,7 +761,11 @@ export function DocumentParsingSettingsSection({
</div> </div>
<p>{modelDescription}</p> <p>{modelDescription}</p>
<div className="document-ocr-model__tags"> <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"> <span className="speech-model-tag">
{model.languages {model.languages
.map((language) => .map((language) =>
@@ -774,9 +804,15 @@ export function DocumentParsingSettingsSection({
<button <button
aria-label={t( aria-label={t(
'documentParsing.ocr.accessibility.openRepository', 'documentParsing.ocr.accessibility.openRepository',
{ name: modelDisplayName } {
name: modelDisplayName,
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
}
)} )}
className="secondary-button document-ocr-model__repository" className="secondary-button document-ocr-model__repository"
disabled={!modelSourceAvailable}
onClick={() => onClick={() =>
void window.goodbuddy.documentParsing void window.goodbuddy.documentParsing
?.openOcrModelRepository(model.id) ?.openOcrModelRepository(model.id)
@@ -784,7 +820,11 @@ export function DocumentParsingSettingsSection({
type="button" type="button"
> >
<ExternalLink aria-hidden="true" size={13} /> <ExternalLink aria-hidden="true" size={13} />
{t('documentParsing.ocr.openRepository')} {t('documentParsing.ocr.openRepository', {
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
})}
</button> </button>
</div> </div>
@@ -806,7 +846,12 @@ export function DocumentParsingSettingsSection({
? 'documentParsing.ocr.operations.installing' ? 'documentParsing.ocr.operations.installing'
: modelOperation.kind === 'import' : modelOperation.kind === 'import'
? 'documentParsing.ocr.operations.importing' ? 'documentParsing.ocr.operations.importing'
: 'documentParsing.ocr.operations.downloading' : 'documentParsing.ocr.operations.downloading',
{
source: t(
`modelDownloadSources.${modelOperation.downloadSource}`
)
}
) )
: t('documentParsing.ocr.installed')} : t('documentParsing.ocr.installed')}
</span> </span>
@@ -879,41 +924,61 @@ export function DocumentParsingSettingsSection({
</> </>
) : ( ) : (
<> <>
<button {modelSourceAvailable && (
aria-label={t( <button
'documentParsing.ocr.accessibility.downloadModel', aria-label={t(
{ name: modelDisplayName } 'documentParsing.ocr.accessibility.downloadModel',
)} { name: modelDisplayName }
className="primary-button" )}
disabled={busyModelId === model.id} className="primary-button"
onClick={() => disabled={busyModelId === model.id}
void runModelOperation( onClick={() =>
model.id, void runModelOperation(
async () => { model.id,
const installed = async () => {
await window.goodbuddy.documentParsing! const installed =
.installOcrModel(model.id) await window.goodbuddy.documentParsing!
if (!pendingModelSelection) { .installOcrModel(
return installed model.id,
} snapshot.ocrModels
return window.goodbuddy.documentParsing! .selectedDownloadSource
.update(draft) )
}, if (!pendingModelSelection) {
t( return installed
pendingModelSelection }
? 'documentParsing.ocr.notifications.installedAndSelected' return window.goodbuddy.documentParsing!
: 'documentParsing.ocr.notifications.installed', .update(draft)
{ name: modelDisplayName } },
t(
pendingModelSelection
? 'documentParsing.ocr.notifications.installedAndSelected'
: 'documentParsing.ocr.notifications.installed',
{ name: modelDisplayName }
)
) )
) }
} type="button"
type="button" >
> <Download aria-hidden="true" size={13} />
<Download aria-hidden="true" size={13} /> {pendingModelSelection
{pendingModelSelection ? t('documentParsing.ocr.downloadAndSelect')
? t('documentParsing.ocr.downloadAndSelect') : t('documentParsing.ocr.download')}
: t('documentParsing.ocr.download')} </button>
</button> )}
{!modelSourceAvailable &&
onOpenModelDownloadSourceSettings && (
<button
className="secondary-button"
onClick={
onOpenModelDownloadSourceSettings
}
type="button"
>
{t(
'documentParsing.ocr.openDownloadSourceSettings'
)}
</button>
)}
<button <button
aria-label={t( aria-label={t(
'documentParsing.ocr.accessibility.importModelZip', 'documentParsing.ocr.accessibility.importModelZip',
@@ -951,6 +1016,16 @@ export function DocumentParsingSettingsSection({
)} )}
</div> </div>
{!installedModel && !modelSourceAvailable && (
<p className="settings-warning">
{t('documentParsing.ocr.sourceUnavailableDescription', {
source: t(
`modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}`
)
})}
</p>
)}
{modelOperation && ( {modelOperation && (
<div <div
aria-live="polite" aria-live="polite"
@@ -169,6 +169,7 @@ const onAnalysisEvent = vi.fn<
const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async () => ({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -180,6 +181,7 @@ beforeEach(() => {
getApplicationSettings.mockResolvedValue({ getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -680,6 +682,7 @@ describe('MagicNotesWorkspace', () => {
getApplicationSettings.mockResolvedValue({ getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual', magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -711,6 +714,7 @@ describe('MagicNotesWorkspace', () => {
getApplicationSettings.mockResolvedValue({ getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual', magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative' magicNoteCommentFormat: 'narrative'
@@ -825,6 +829,7 @@ describe('MagicNotesWorkspace', () => {
getApplicationSettings.mockResolvedValue({ getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto', magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -2,10 +2,15 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { import type {
ApplicationSettings, ApplicationSettings,
MagicNoteCommentMode MagicNoteCommentMode,
ModelDownloadSource
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts' import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import { SegmentedControl } from './WorkspacePrimitives' import type { AppNotificationInput } from './notifications'
import {
PageTabs,
SegmentedControl
} from './WorkspacePrimitives'
import { import {
SettingsCategoryHeader, SettingsCategoryHeader,
SettingsWarningList SettingsWarningList
@@ -13,14 +18,21 @@ import {
type PlatformFeaturesSettingsSectionProps = { type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void onMagicNotesEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
} }
type PlatformFeaturesTab = 'general' | 'magic-notes'
export function PlatformFeaturesSettingsSection({ export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange onMagicNotesEnabledChange,
onNotify
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element { }: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections') const { t } = useTranslation('settingsSections')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>() const [settings, setSettings] = useState<ApplicationSettings>()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>()
const [error, setError] = useState<string | undefined>(() => const [error, setError] = useState<string | undefined>(() =>
window.goodbuddy.updates window.goodbuddy.updates
? undefined ? undefined
@@ -52,6 +64,45 @@ export function PlatformFeaturesSettingsSection({
} }
}, [t]) }, [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 changeMagicNotes = async (enabled: boolean): Promise<void> => {
const updates = window.goodbuddy.updates const updates = window.goodbuddy.updates
if (!updates || !settings) { if (!updates || !settings) {
@@ -120,84 +171,220 @@ export function PlatformFeaturesSettingsSection({
headingId="platform-features-heading" headingId="platform-features-heading"
/> />
<SettingsWarningList warnings={settings?.warnings} /> <SettingsWarningList warnings={settings?.warnings} />
<section <div className="platform-features-tabs">
aria-label={t('platformFeatures.label')} <PageTabs
className="settings-section" ariaLabel={t('platformFeatures.tabs.ariaLabel')}
> idPrefix="platform-features"
<article className="capability-card"> onChange={setActiveSection}
<div className="capability-card__header"> tabs={[
<div> {
<strong>{t('platformFeatures.magicNotes.title')}</strong> id: 'general',
<small>{t('platformFeatures.magicNotes.description')}</small> label: t('platformFeatures.tabs.general')
</div> },
</div> {
<label className="toggle-row"> id: 'magic-notes',
<input label: t('platformFeatures.tabs.magicNotes')
checked={settings?.magicNotesEnabled ?? false}
disabled={!settings || saving}
onChange={(event) =>
void changeMagicNotes(event.target.checked)
} }
role="switch" ]}
type="checkbox" value={activeSection}
/> variant="segmented"
<span>{t('platformFeatures.magicNotes.showEntry')}</span> />
</label> </div>
<div className="platform-feature-option">
<span>{t('platformFeatures.magicNotes.commentMode')}</span> <section
<SegmentedControl aria-labelledby="platform-features-tab-general"
ariaLabel={t('platformFeatures.magicNotes.commentModeAria')} className="settings-section"
disabled={!settings || saving} hidden={activeSection !== 'general'}
onChange={(value) => void changeCommentMode(value)} id="platform-features-panel-general"
options={[ role="tabpanel"
{ >
value: 'immediate', {settings ? (
label: t('platformFeatures.magicNotes.modes.immediate') <article className="capability-card">
}, <div className="capability-card__header">
{ <div>
value: 'after-save-auto', <strong>
label: t('platformFeatures.magicNotes.modes.afterSaveAuto') {t('platformFeatures.modelDownloadSource.cardTitle')}
}, </strong>
{ <small>
value: 'after-save-manual', {t(
label: t( 'platformFeatures.modelDownloadSource.cardDescription'
'platformFeatures.magicNotes.modes.afterSaveManual' )}
) </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)
} }
]} role="switch"
value={settings?.magicNoteCommentMode ?? 'immediate'} type="checkbox"
/> />
<small> <span>{t('platformFeatures.magicNotes.showEntry')}</span>
{t('platformFeatures.magicNotes.commentModeHelp')} </label>
</small> <div className="platform-feature-option">
</div> <span>
<div className="platform-feature-option"> {t('platformFeatures.magicNotes.commentMode')}
<span>{t('platformFeatures.magicNotes.commentFormat')}</span> </span>
<SegmentedControl <SegmentedControl
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria')} ariaLabel={t(
disabled={!settings || saving} 'platformFeatures.magicNotes.commentModeAria'
onChange={(value) => void changeCommentFormat(value)} )}
options={[ disabled={saving}
{ onChange={(value) => void changeCommentMode(value)}
value: 'combined', options={[
label: t('platformFeatures.magicNotes.formats.combined') {
}, value: 'immediate',
{ label: t(
value: 'narrative', 'platformFeatures.magicNotes.modes.immediate'
label: t('platformFeatures.magicNotes.formats.narrative') )
}, },
{ {
value: 'structured', value: 'after-save-auto',
label: t('platformFeatures.magicNotes.formats.structured') label: t(
} 'platformFeatures.magicNotes.modes.afterSaveAuto'
]} )
value={settings?.magicNoteCommentFormat ?? 'combined'} },
/> {
<small> value: 'after-save-manual',
{t('platformFeatures.magicNotes.commentFormatHelp')} label: t(
</small> 'platformFeatures.magicNotes.modes.afterSaveManual'
</div> )
</article> }
]}
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> </section>
</> </>
) )
+140 -4
View File
@@ -396,6 +396,7 @@ const diagnoseEmbedding = vi.fn(
let applicationSettings: ApplicationSettings = { let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -423,14 +424,25 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
quality: 'high', quality: 'high',
speed: 'fast', speed: 'fast',
recommended: true, recommended: true,
repositoryUrl: 'https://example.com/sensevoice',
license: { license: {
name: 'Model License', name: 'Model License',
notice: 'Review the model license before use.', notice: 'Review the model license before use.',
url: 'https://example.com/license' url: 'https://example.com/license'
}, },
manualOnly: false, manualOnly: false,
files: [] files: [],
downloadAvailability: [
{
source: 'modelscope',
available: true,
totalBytes: 1
},
{
source: 'hugging-face',
available: true,
totalBytes: 1
}
]
}, },
{ {
id: 'paraformer-bilingual-zh-en-int8', id: 'paraformer-bilingual-zh-en-int8',
@@ -442,20 +454,32 @@ const speechCatalog: SpeechModelSnapshot['catalog'] = [
quality: 'high', quality: 'high',
speed: 'fast', speed: 'fast',
recommended: true, recommended: true,
repositoryUrl: 'https://example.com/paraformer',
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: 'Review the model license before use.', notice: 'Review the model license before use.',
url: 'https://example.com/license' url: 'https://example.com/license'
}, },
manualOnly: false, manualOnly: false,
files: [] files: [],
downloadAvailability: [
{
source: 'modelscope',
available: true,
totalBytes: 1
},
{
source: 'hugging-face',
available: true,
totalBytes: 1
}
]
} }
] ]
const createSpeechModelSnapshot = ( const createSpeechModelSnapshot = (
selectedModelId: string | null = 'sensevoice-small-int8' selectedModelId: string | null = 'sensevoice-small-int8'
): SpeechModelSnapshot => ({ ): SpeechModelSnapshot => ({
rootDirectory: 'C:\\Users\\test\\models\\speech', rootDirectory: 'C:\\Users\\test\\models\\speech',
selectedDownloadSource: 'modelscope',
catalog: speechCatalog, catalog: speechCatalog,
installed: speechCatalog.map((model) => ({ installed: speechCatalog.map((model) => ({
id: model.id, id: model.id,
@@ -562,6 +586,7 @@ describe('SettingsPanel runtime files', () => {
applicationSettings = { applicationSettings = {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false, magicNotesEnabled: false,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
@@ -1033,6 +1058,9 @@ describe('SettingsPanel runtime files', () => {
) )
fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
const toggle = await screen.findByRole('switch', { const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口' 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 () => { it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
function Harness(): React.JSX.Element { function Harness(): React.JSX.Element {
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false) const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
@@ -1095,6 +1228,9 @@ describe('SettingsPanel runtime files', () => {
) )
fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', { name: '魔法笔记' })
)
fireEvent.click( fireEvent.click(
await screen.findByRole('switch', { await screen.findByRole('switch', {
name: '显示魔法笔记入口' name: '显示魔法笔记入口'
+10 -1
View File
@@ -1610,6 +1610,7 @@ export function SettingsPanel({
{activeTab === 'platform-features' && ( {activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection <PlatformFeaturesSettingsSection
onMagicNotesEnabledChange={onMagicNotesEnabledChange} onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onNotify={onNotify}
/> />
)} )}
{activeTab === 'runtime' && ( {activeTab === 'runtime' && (
@@ -2924,6 +2925,9 @@ export function SettingsPanel({
{modelType === 'speech' && ( {modelType === 'speech' && (
<SpeechModelSettingsSection <SpeechModelSettingsSection
onNotify={onNotify} onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features')
}
onSelectedModelIdChange={(modelId, changed) => { onSelectedModelIdChange={(modelId, changed) => {
setSpeechModelDraftId(modelId) setSpeechModelDraftId(modelId)
setSpeechModelSelectionDirty(changed) setSpeechModelSelectionDirty(changed)
@@ -3119,7 +3123,12 @@ export function SettingsPanel({
)} )}
{activeTab === 'document-parsing' && ( {activeTab === 'document-parsing' && (
<DocumentParsingSettingsSection onNotify={onNotify} /> <DocumentParsingSettingsSection
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features')
}
/>
)} )}
{activeTab === 'security' && ( {activeTab === 'security' && (
@@ -21,7 +21,6 @@ const entry = {
quality: 'high' as const, quality: 'high' as const,
speed: 'fast' as const, speed: 'fast' as const,
recommended: true, recommended: true,
repositoryUrl: 'https://huggingface.co/example/model',
license: { license: {
name: '模型仓库自定义许可', name: '模型仓库自定义许可',
notice: '使用前请阅读许可。', notice: '使用前请阅读许可。',
@@ -32,26 +31,33 @@ const entry = {
{ {
name: 'model.int8.onnx', name: 'model.int8.onnx',
role: 'model' as const, role: 'model' as const,
download: { size: 1_000,
url: 'https://huggingface.co/example/model/resolve/revision/model.int8.onnx', sha256: 'a'.repeat(64)
size: 1_000,
sha256: 'a'.repeat(64)
}
}, },
{ {
name: 'tokens.txt', name: 'tokens.txt',
role: 'tokens' as const, role: 'tokens' as const,
download: { size: 100,
url: 'https://huggingface.co/example/model/resolve/revision/tokens.txt', 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 = { const snapshot: SpeechModelSnapshot = {
rootDirectory: 'C:\\Users\\test\\models\\speech', rootDirectory: 'C:\\Users\\test\\models\\speech',
selectedDownloadSource: 'modelscope',
catalog: [entry], catalog: [entry],
installed: [], installed: [],
operations: [], operations: [],
@@ -105,7 +111,7 @@ describe('SpeechModelSettingsSection', () => {
expect(screen.queryByText('Model details')).not.toBeInTheDocument() expect(screen.queryByText('Model details')).not.toBeInTheDocument()
fireEvent.click( fireEvent.click(
screen.getByRole('button', { screen.getByRole('button', {
name: 'Open the SenseVoiceSmall INT8 model repository' name: 'Open the ModelScope repository for SenseVoiceSmall INT8'
}) })
) )
expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8') expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8')
@@ -159,7 +165,10 @@ describe('SpeechModelSettingsSection', () => {
})) }))
await waitFor(() => await waitFor(() =>
expect(install).toHaveBeenCalledWith('sensevoice-small-int8') expect(install).toHaveBeenCalledWith(
'sensevoice-small-int8',
'modelscope'
)
) )
expect(onNotify).toHaveBeenCalledWith({ expect(onNotify).toHaveBeenCalledWith({
tone: 'success', tone: 'success',
@@ -176,7 +185,7 @@ describe('SpeechModelSettingsSection', () => {
family: 'whisper' as const, family: 'whisper' as const,
files: [ files: [
{ {
...entry.files[0], ...entry.files[0]!,
name: 'tiny-encoder.int8.onnx', name: 'tiny-encoder.int8.onnx',
role: 'encoder' as const role: 'encoder' as const
} }
@@ -212,10 +221,81 @@ describe('SpeechModelSettingsSection', () => {
})) }))
await waitFor(() => 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 () => { it('imports and exports verified speech model ZIP archives', async () => {
const installedSnapshot: SpeechModelSnapshot = { const installedSnapshot: SpeechModelSnapshot = {
...snapshot, ...snapshot,
@@ -306,7 +386,8 @@ describe('SpeechModelSettingsSection', () => {
phase: 'transferring', phase: 'transferring',
currentFile: 'model.int8.onnx', currentFile: 'model.int8.onnx',
completedBytes: 550, completedBytes: 550,
totalBytes: 1_100 totalBytes: 1_100,
downloadSource: 'modelscope'
} }
] ]
} }
@@ -332,6 +413,7 @@ describe('SpeechModelSettingsSection', () => {
expect(await screen.findByRole('progressbar', { expect(await screen.findByRole('progressbar', {
name: 'SenseVoiceSmall INT8下载进度' name: 'SenseVoiceSmall INT8下载进度'
})).toHaveValue(50) })).toHaveValue(50)
expect(screen.getByText('正在从 ModelScope 下载')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { fireEvent.click(screen.getByRole('button', {
name: '取消 SenseVoiceSmall INT8 操作' name: '取消 SenseVoiceSmall INT8 操作'
})) }))
@@ -350,7 +432,8 @@ describe('SpeechModelSettingsSection', () => {
phase: 'transferring', phase: 'transferring',
currentFile: 'model.int8.onnx', currentFile: 'model.int8.onnx',
completedBytes: 550, completedBytes: 550,
totalBytes: 1_100 totalBytes: 1_100,
downloadSource: 'modelscope'
} }
] ]
} }
+83 -18
View File
@@ -12,7 +12,7 @@ import type { TFunction } from 'i18next'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { import type {
SpeechModelCatalogEntry, SpeechModelCatalogViewEntry,
SpeechModelOperation, SpeechModelOperation,
SpeechModelSnapshot SpeechModelSnapshot
} from '../../shared/speech-model-contracts' } from '../../shared/speech-model-contracts'
@@ -27,6 +27,7 @@ type SpeechModelSettingsSectionProps = {
changed: boolean changed: boolean
) => void ) => void
onSelectionInvalidated?: (modelId: string | null) => void onSelectionInvalidated?: (modelId: string | null) => void
onOpenModelDownloadSourceSettings?: () => void
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -36,14 +37,8 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
function catalogSize(entry: SpeechModelCatalogEntry): number | undefined { function catalogSize(entry: SpeechModelCatalogViewEntry): number {
const downloads = entry.files.map((file) => file.download) return entry.files.reduce((total, file) => total + file.size, 0)
return downloads.every(Boolean)
? downloads.reduce(
(total, download) => total + (download?.size ?? 0),
0
)
: undefined
} }
function progressPercent(operation: SpeechModelOperation): number | undefined { function progressPercent(operation: SpeechModelOperation): number | undefined {
@@ -65,11 +60,19 @@ function operationLabel(
if (operation.phase === 'preparing') { if (operation.phase === 'preparing') {
return operation.kind === 'import' return operation.kind === 'import'
? t('speech.operations.preparingImport') ? t('speech.operations.preparingImport')
: t('speech.operations.preparingDownload') : t('speech.operations.preparingDownloadFrom', {
source: t(
`modelDownloadSources.${operation.downloadSource}`
)
})
} }
return operation.kind === 'import' return operation.kind === 'import'
? t('speech.operations.importing') ? t('speech.operations.importing')
: t('speech.operations.downloading') : t('speech.operations.downloadingFrom', {
source: t(
`modelDownloadSources.${operation.downloadSource}`
)
})
} }
export function SpeechModelSettingsSection({ export function SpeechModelSettingsSection({
@@ -77,7 +80,8 @@ export function SpeechModelSettingsSection({
persistedSelectedModelId, persistedSelectedModelId,
selectedModelId, selectedModelId,
onSelectedModelIdChange, onSelectedModelIdChange,
onSelectionInvalidated onSelectionInvalidated,
onOpenModelDownloadSourceSettings
}: SpeechModelSettingsSectionProps): React.JSX.Element { }: SpeechModelSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections') const { t } = useTranslation('settingsSections')
const [snapshot, setSnapshot] = useState<SpeechModelSnapshot>() const [snapshot, setSnapshot] = useState<SpeechModelSnapshot>()
@@ -306,6 +310,11 @@ export function SpeechModelSettingsSection({
? progressPercent(operation) ? progressPercent(operation)
: undefined : undefined
const size = model ? catalogSize(model) : 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 selected = model?.id === effectiveSelectedModelId
const inUse = model?.id === effectivePersistedModelId const inUse = model?.id === effectivePersistedModelId
const pendingSelection = const pendingSelection =
@@ -322,7 +331,9 @@ export function SpeechModelSettingsSection({
? t('speech.status.installed') ? t('speech.status.installed')
: model?.manualOnly : model?.manualOnly
? t('speech.status.manualImport') ? t('speech.status.manualImport')
: t('speech.status.availableToDownload') : sourceAvailable
? t('speech.status.availableToDownload')
: t('speech.status.sourceUnavailable')
return ( return (
<section <section
@@ -354,6 +365,13 @@ export function SpeechModelSettingsSection({
<code>{snapshot.rootDirectory}</code> <code>{snapshot.rootDirectory}</code>
{t('speech.storageSuffix')} {t('speech.storageSuffix')}
</p> </p>
<p className="settings-notice">
{t('speech.downloadSource', {
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
)
})}
</p>
{error && <p className="settings-warning" role="alert">{error}</p>} {error && <p className="settings-warning" role="alert">{error}</p>}
<label className="field document-ocr-model-selector"> <label className="field document-ocr-model-selector">
@@ -383,7 +401,14 @@ export function SpeechModelSettingsSection({
{optionName} ·{' '} {optionName} ·{' '}
{installedById.has(entry.id) {installedById.has(entry.id)
? t('speech.status.installed') ? 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> </option>
) )
})} })}
@@ -411,9 +436,15 @@ export function SpeechModelSettingsSection({
<button <button
aria-label={t( aria-label={t(
'speech.accessibility.openRepository', 'speech.accessibility.openRepository',
{ name: displayName } {
name: displayName,
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
)
}
)} )}
className="icon-button speech-model-card__repository" className="icon-button speech-model-card__repository"
disabled={!sourceAvailable}
onClick={() => onClick={() =>
void window.goodbuddy.speechModels?.openRepository( void window.goodbuddy.speechModels?.openRepository(
model.id model.id
@@ -421,7 +452,12 @@ export function SpeechModelSettingsSection({
} }
title={t( title={t(
'speech.accessibility.openRepository', 'speech.accessibility.openRepository',
{ name: displayName } {
name: displayName,
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
)
}
)} )}
type="button" type="button"
> >
@@ -430,6 +466,11 @@ export function SpeechModelSettingsSection({
</div> </div>
<p>{description}</p> <p>{description}</p>
<div className="document-ocr-model__tags"> <div className="document-ocr-model__tags">
<span className="speech-model-tag">
{t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
)}
</span>
<span className="speech-model-tag"> <span className="speech-model-tag">
{t('speech.family.' + model.family)} {t('speech.family.' + model.family)}
</span> </span>
@@ -540,7 +581,7 @@ export function SpeechModelSettingsSection({
</> </>
) : ( ) : (
<> <>
{!model.manualOnly && ( {!model.manualOnly && sourceAvailable && (
<button <button
aria-label={t( aria-label={t(
'speech.accessibility.downloadModel', 'speech.accessibility.downloadModel',
@@ -553,7 +594,8 @@ export function SpeechModelSettingsSection({
model.id, model.id,
() => () =>
window.goodbuddy.speechModels!.install( window.goodbuddy.speechModels!.install(
model.id model.id,
snapshot.selectedDownloadSource
), ),
t('speech.notifications.installed', { t('speech.notifications.installed', {
name: displayName name: displayName
@@ -567,6 +609,19 @@ export function SpeechModelSettingsSection({
{t('speech.actions.download')} {t('speech.actions.download')}
</button> </button>
)} )}
{!model.manualOnly &&
!sourceAvailable &&
onOpenModelDownloadSourceSettings && (
<button
className="secondary-button"
onClick={onOpenModelDownloadSourceSettings}
type="button"
>
{t(
'speech.actions.openDownloadSourceSettings'
)}
</button>
)}
<button <button
aria-label={t( aria-label={t(
'speech.accessibility.importModelZip', 'speech.accessibility.importModelZip',
@@ -596,6 +651,16 @@ export function SpeechModelSettingsSection({
)} )}
</div> </div>
{!installed && !sourceAvailable && !model.manualOnly && (
<p className="settings-warning">
{t('speech.sourceUnavailableDescription', {
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
)
})}
</p>
)}
{operation && ( {operation && (
<div <div
aria-live="polite" aria-live="polite"
@@ -20,6 +20,7 @@ describe('UpdateSettingsSection', () => {
let applicationSettings: ApplicationSettings = { let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate' as const, magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' 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' import type { settings as chineseSettings } from '../zh-CN/settings'
export const settings = { export const settings = {
modelDownloadSources: {
modelscope: 'ModelScope',
'hugging-face': 'Hugging Face'
},
center: { center: {
eyebrow: 'Settings', eyebrow: 'Settings',
title: 'Settings', title: 'Settings',
@@ -472,7 +476,7 @@ export const settings = {
ocrReady: ocrReady:
'The model is installed, SHA-256 verified, and available offline', 'The model is installed, SHA-256 verified, and available offline',
ocrUnavailable: 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: partialNotice:
'Basic document parsing is available. Legacy Office conversion is not implemented yet; scenario modes can use local OCR for scanned PDFs.' '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.', 'This model selection is not active yet. Save settings to switch.',
installedOption: 'Installed', installedOption: 'Installed',
downloadableOption: 'Available to download', downloadableOption: 'Available to download',
sourceUnavailableOption: 'Unavailable from current source',
unavailableOption: 'Unavailable in this version', unavailableOption: 'Unavailable in this version',
openModelsDirectory: 'Open model folder', openModelsDirectory: 'Open model folder',
storagePrefix: 'Models are installed on demand in', storagePrefix: 'Models are installed on demand in',
storageSuffix: storageSuffix:
' and can be exported as ZIP archives for offline devices.', ' and can be exported as ZIP archives for offline devices.',
downloadSource: 'Current model download source: {{source}}',
recommended: 'Recommended', recommended: 'Recommended',
quality: { quality: {
label: 'Quality: {{value}}', label: 'Quality: {{value}}',
@@ -576,18 +582,21 @@ export const settings = {
delete: 'Delete', delete: 'Delete',
confirmDelete: 'Confirm delete', confirmDelete: 'Confirm delete',
cancel: 'Cancel', cancel: 'Cancel',
openRepository: 'Open ModelScope', openRepository: 'Open {{source}}',
openDownloadSourceSettings: 'Open General settings',
catalogUnavailable: catalogUnavailable:
'No OCR model catalog is available in this version.', 'No OCR model catalog is available in this version.',
selectedModelUnavailable: selectedModelUnavailable:
'The saved OCR model is unavailable in this version. Select and install another model above.', 'The saved OCR model is unavailable in this version. Select and install another model above.',
installBeforeSelecting: 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: privacyNotice:
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.', 'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
operations: { operations: {
preparing: 'Preparing model files', preparing: 'Preparing model files',
downloading: 'Downloading from ModelScope', downloading: 'Downloading from {{source}}',
importing: 'Importing model ZIP', importing: 'Importing model ZIP',
installing: 'Verifying and installing' installing: 'Verifying and installing'
}, },
@@ -598,7 +607,7 @@ export const settings = {
deleteModel: 'Delete {{name}}', deleteModel: 'Delete {{name}}',
cancelOperation: 'Cancel {{name}} operation', cancelOperation: 'Cancel {{name}} operation',
downloadProgress: '{{name}} download progress', downloadProgress: '{{name}} download progress',
openRepository: 'Open the ModelScope page for {{name}}' openRepository: 'Open the {{source}} page for {{name}}'
}, },
notifications: { notifications: {
installed: '{{name}} installed', installed: '{{name}} installed',
@@ -4,6 +4,10 @@ import type {
} from '../zh-CN/settingsSections' } from '../zh-CN/settingsSections'
export const settingsSections = { export const settingsSections = {
modelDownloadSources: {
modelscope: 'ModelScope',
'hugging-face': 'Hugging Face'
},
speech: { speech: {
title: 'Speech models', title: 'Speech models',
description: description:
@@ -12,6 +16,7 @@ export const settingsSections = {
storagePrefix: 'Models are stored in', storagePrefix: 'Models are stored in',
storageSuffix: 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.', '. 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', modelSelector: 'Current speech model',
modelSelectorDescription: modelSelectorDescription:
'Choose an installed model, then select Save settings to switch speech recognition models.', 'Choose an installed model, then select Save settings to switch speech recognition models.',
@@ -45,9 +50,9 @@ export const settingsSections = {
operations: { operations: {
installing: 'Verifying and installing', installing: 'Verifying and installing',
preparingImport: 'Preparing import', preparingImport: 'Preparing import',
preparingDownload: 'Preparing download', preparingDownloadFrom: 'Preparing to download from {{source}}',
importing: 'Importing', importing: 'Importing',
downloading: 'Downloading', downloadingFrom: 'Downloading from {{source}}',
processingFile: 'Processing {{file}}' processingFile: 'Processing {{file}}'
}, },
status: { status: {
@@ -56,6 +61,7 @@ export const settingsSections = {
installed: 'Installed', installed: 'Installed',
manualImport: 'Manual import', manualImport: 'Manual import',
availableToDownload: 'Available to download', availableToDownload: 'Available to download',
sourceUnavailable: 'Unavailable from current source',
unknownSize: 'Unknown size' unknownSize: 'Unknown size'
}, },
tags: { tags: {
@@ -66,6 +72,7 @@ export const settingsSections = {
delete: 'Delete', delete: 'Delete',
confirmDelete: 'Confirm delete', confirmDelete: 'Confirm delete',
download: 'Download', download: 'Download',
openDownloadSourceSettings: 'Open General settings',
importZip: 'Import ZIP', importZip: 'Import ZIP',
exportZip: 'Export ZIP' exportZip: 'Export ZIP'
}, },
@@ -76,7 +83,7 @@ export const settingsSections = {
importModelZip: 'Import {{name}} from a ZIP archive', importModelZip: 'Import {{name}} from a ZIP archive',
exportModelZip: 'Export {{name}} as a ZIP archive', exportModelZip: 'Export {{name}} as a ZIP archive',
downloadProgress: '{{name}} download progress', downloadProgress: '{{name}} download progress',
openRepository: 'Open the {{name}} model repository' openRepository: 'Open the {{source}} repository for {{name}}'
}, },
notifications: { notifications: {
installed: '{{name}} installed', installed: '{{name}} installed',
@@ -84,6 +91,8 @@ export const settingsSections = {
exportedZip: '{{name}} exported as ZIP', exportedZip: '{{name}} exported as ZIP',
removed: 'Speech model deleted' 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: { languages: {
: 'Chinese', : 'Chinese',
: 'Cantonese', : 'Cantonese',
@@ -211,6 +220,7 @@ export const settingsSections = {
'No roles yet. Create a role to configure its system prompt.' 'No roles yet. Create a role to configure its system prompt.'
}, },
platformFeatures: { platformFeatures: {
loading: 'Loading platform feature settings…',
errors: { errors: {
serviceUnavailable: serviceUnavailable:
'Application settings are not available in this version', 'Application settings are not available in this version',
@@ -219,9 +229,34 @@ export const settingsSections = {
saveCommentModeFailed: saveCommentModeFailed:
'Could not save the AI comment mode. Try again.', 'Could not save the AI comment mode. Try again.',
saveCommentFormatFailed: 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', 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: { magicNotes: {
title: 'Magic Notes', title: 'Magic Notes',
description: description:
@@ -1,4 +1,8 @@
export const settings = { export const settings = {
modelDownloadSources: {
modelscope: 'ModelScope',
'hugging-face': 'Hugging Face'
},
center: { center: {
eyebrow: '设置', eyebrow: '设置',
title: '设置中心', title: '设置中心',
@@ -431,7 +435,7 @@ export const settings = {
localOcr: '本地 OCR', localOcr: '本地 OCR',
localOcrModel: '当前 OCR{{name}}', localOcrModel: '当前 OCR{{name}}',
ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用', ocrReady: '模型已安装并通过 SHA-256 校验,可离线使用',
ocrUnavailable: '模型尚未安装或校验失败,请从 ModelScope 下载', ocrUnavailable: '模型尚未安装或校验失败,请从当前模型下载源获取',
partialNotice: partialNotice:
'基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。' '基础文档解析可用。旧版 Office 转换尚未实现;扫描 PDF 可按场景模式使用本地 OCR。'
}, },
@@ -477,10 +481,12 @@ export const settings = {
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。', pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
installedOption: '已安装', installedOption: '已安装',
downloadableOption: '可下载', downloadableOption: '可下载',
sourceUnavailableOption: '当前来源不可下载',
unavailableOption: '当前版本不可用', unavailableOption: '当前版本不可用',
openModelsDirectory: '打开模型目录', openModelsDirectory: '打开模型目录',
storagePrefix: '模型按需安装到', storagePrefix: '模型按需安装到',
storageSuffix: '。可导出 ZIP,并在内网设备直接导入。', storageSuffix: '。可导出 ZIP,并在内网设备直接导入。',
downloadSource: '当前模型下载源:{{source}}',
recommended: '推荐', recommended: '推荐',
quality: { quality: {
label: '质量:{{value}}', label: '质量:{{value}}',
@@ -528,16 +534,20 @@ export const settings = {
delete: '删除', delete: '删除',
confirmDelete: '确认删除', confirmDelete: '确认删除',
cancel: '取消', cancel: '取消',
openRepository: '打开 ModelScope', openRepository: '打开 {{source}}',
openDownloadSourceSettings: '前往通用设置',
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。', catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
selectedModelUnavailable: selectedModelUnavailable:
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。', '已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
installBeforeSelecting: '请先下载该模型;下载完成后会自动设为当前模型。', installBeforeSelecting:
'请先下载或导入该模型;安装完成后会自动设为当前模型。',
sourceUnavailableDescription:
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
privacyNotice: privacyNotice:
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。', 'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
operations: { operations: {
preparing: '正在准备模型文件', preparing: '正在准备模型文件',
downloading: '正在从 ModelScope 下载', downloading: '正在从 {{source}} 下载',
importing: '正在导入模型 ZIP', importing: '正在导入模型 ZIP',
installing: '正在校验并安装' installing: '正在校验并安装'
}, },
@@ -548,7 +558,7 @@ export const settings = {
deleteModel: '删除 {{name}}', deleteModel: '删除 {{name}}',
cancelOperation: '取消 {{name}} 操作', cancelOperation: '取消 {{name}} 操作',
downloadProgress: '{{name}} 下载进度', downloadProgress: '{{name}} 下载进度',
openRepository: '打开 {{name}} 的 ModelScope 页面' openRepository: '打开 {{name}} 的 {{source}} 页面'
}, },
notifications: { notifications: {
installed: '{{name}} 已安装', installed: '{{name}} 已安装',
@@ -1,4 +1,8 @@
export const settingsSections = { export const settingsSections = {
modelDownloadSources: {
modelscope: 'ModelScope',
'hugging-face': 'Hugging Face'
},
speech: { speech: {
title: '语音模型', title: '语音模型',
description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移', description: '应用不内置模型权重,按需下载或通过 ZIP 离线迁移',
@@ -6,6 +10,7 @@ export const settingsSections = {
storagePrefix: '模型保存在', storagePrefix: '模型保存在',
storageSuffix: storageSuffix:
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。', '。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
downloadSource: '当前模型下载源:{{source}}',
modelSelector: '当前语音模型', modelSelector: '当前语音模型',
modelSelectorDescription: modelSelectorDescription:
'选择已安装模型后,点击“保存设置”切换语音识别模型。', '选择已安装模型后,点击“保存设置”切换语音识别模型。',
@@ -37,9 +42,9 @@ export const settingsSections = {
operations: { operations: {
installing: '正在校验并安装', installing: '正在校验并安装',
preparingImport: '正在准备导入', preparingImport: '正在准备导入',
preparingDownload: '正在准备下载', preparingDownloadFrom: '正在准备从 {{source}} 下载',
importing: '正在导入', importing: '正在导入',
downloading: '正在下载', downloadingFrom: '正在从 {{source}} 下载',
processingFile: '正在处理 {{file}}' processingFile: '正在处理 {{file}}'
}, },
status: { status: {
@@ -48,6 +53,7 @@ export const settingsSections = {
installed: '已安装', installed: '已安装',
manualImport: '手动导入', manualImport: '手动导入',
availableToDownload: '可下载', availableToDownload: '可下载',
sourceUnavailable: '当前来源不可下载',
unknownSize: '大小未知' unknownSize: '大小未知'
}, },
tags: { tags: {
@@ -58,6 +64,7 @@ export const settingsSections = {
delete: '删除', delete: '删除',
confirmDelete: '确认删除', confirmDelete: '确认删除',
download: '下载', download: '下载',
openDownloadSourceSettings: '前往通用设置',
importZip: '导入 ZIP', importZip: '导入 ZIP',
exportZip: '导出 ZIP' exportZip: '导出 ZIP'
}, },
@@ -68,7 +75,7 @@ export const settingsSections = {
importModelZip: '从 ZIP 导入 {{name}}', importModelZip: '从 ZIP 导入 {{name}}',
exportModelZip: '将 {{name}} 导出为 ZIP', exportModelZip: '将 {{name}} 导出为 ZIP',
downloadProgress: '{{name}}下载进度', downloadProgress: '{{name}}下载进度',
openRepository: '打开 {{name}} 模型仓库' openRepository: '打开 {{name}} 的 {{source}} 模型仓库'
}, },
notifications: { notifications: {
installed: '{{name}} 已安装', installed: '{{name}} 已安装',
@@ -76,6 +83,8 @@ export const settingsSections = {
exportedZip: '{{name}} 已导出为 ZIP', exportedZip: '{{name}} 已导出为 ZIP',
removed: '语音模型已删除' removed: '语音模型已删除'
}, },
sourceUnavailableDescription:
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
languages: { languages: {
: '中文', : '中文',
: '粤语', : '粤语',
@@ -197,14 +206,36 @@ export const settingsSections = {
empty: '还没有角色。新建角色后,可以为它配置系统提示词。' empty: '还没有角色。新建角色后,可以为它配置系统提示词。'
}, },
platformFeatures: { platformFeatures: {
loading: '正在读取平台功能设置…',
errors: { errors: {
serviceUnavailable: '当前版本未提供应用设置服务', serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败', readFailed: '读取平台功能设置失败',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试', saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveCommentModeFailed: '保存 AI 评论方式失败,请重试', saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试' saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
}, },
label: '平台功能选项', 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: { magicNotes: {
title: '魔法笔记', title: '魔法笔记',
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容', description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
+77
View File
@@ -5189,6 +5189,83 @@ button > svg {
line-height: 1.6; 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 { .settings-page .settings-tabs {
display: flex; display: flex;
min-height: 0; min-height: 0;
@@ -1,7 +1,13 @@
import { z } from 'zod' import { z } from 'zod'
import { magicNoteCommentFormatSchema } from './magic-notes-contracts' import { magicNoteCommentFormatSchema } from './magic-notes-contracts'
import { modelDownloadSourceSchema } from './model-download-contracts'
import { settingsWarningsSchema } from './settings-warning-contracts' import { settingsWarningsSchema } from './settings-warning-contracts'
export {
modelDownloadSourceSchema,
type ModelDownloadSource
} from './model-download-contracts'
export const magicNoteCommentModeSchema = z.enum([ export const magicNoteCommentModeSchema = z.enum([
'immediate', 'immediate',
'after-save-auto', 'after-save-auto',
@@ -22,6 +28,7 @@ const applicationPreferencesSchema = z
.object({ .object({
checkUpdatesOnStartup: z.boolean(), checkUpdatesOnStartup: z.boolean(),
updateSource: updateSourceSchema, updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
magicNotesEnabled: z.boolean(), magicNotesEnabled: z.boolean(),
magicNoteCommentMode: magicNoteCommentModeSchema, magicNoteCommentMode: magicNoteCommentModeSchema,
magicNoteCommentFormat: magicNoteCommentFormatSchema magicNoteCommentFormat: magicNoteCommentFormatSchema
@@ -48,6 +55,7 @@ export type ApplicationSettingsUpdate = z.infer<
typeof applicationSettingsUpdateSchema typeof applicationSettingsUpdateSchema
> >
export type VersionCheckFile = { export type VersionCheckFile = {
name: string name: string
size: number size: number
+7 -2
View File
@@ -74,6 +74,7 @@ import type {
import type { import type {
ApplicationSettings, ApplicationSettings,
ApplicationSettingsUpdate, ApplicationSettingsUpdate,
ModelDownloadSource,
VersionCheckResult VersionCheckResult
} from './application-settings-contracts' } from './application-settings-contracts'
import type { ReleaseNotesSnapshot } from './release-notes-contracts' import type { ReleaseNotesSnapshot } from './release-notes-contracts'
@@ -1373,7 +1374,10 @@ export type DesktopApi = {
} }
speechModels?: { speechModels?: {
getSnapshot: () => Promise<SpeechModelSnapshot> getSnapshot: () => Promise<SpeechModelSnapshot>
install: (modelId: string) => Promise<SpeechModelSnapshot> install: (
modelId: string,
expectedDownloadSource: ModelDownloadSource
) => Promise<SpeechModelSnapshot>
cancel: (modelId: string) => Promise<boolean> cancel: (modelId: string) => Promise<boolean>
remove: (modelId: string) => Promise<SpeechModelSnapshot> remove: (modelId: string) => Promise<SpeechModelSnapshot>
select: (modelId: string | null) => Promise<SpeechModelSnapshot> select: (modelId: string | null) => Promise<SpeechModelSnapshot>
@@ -1405,7 +1409,8 @@ export type DesktopApi = {
purpose: DocumentParsingTestPurpose purpose: DocumentParsingTestPurpose
) => Promise<DocumentParsingDiagnostic | undefined> ) => Promise<DocumentParsingDiagnostic | undefined>
installOcrModel: ( installOcrModel: (
modelId: string modelId: string,
expectedDownloadSource: ModelDownloadSource
) => Promise<DocumentParsingSnapshot> ) => Promise<DocumentParsingSnapshot>
cancelOcrModelOperation: (modelId: string) => Promise<boolean> cancelOcrModelOperation: (modelId: string) => Promise<boolean>
removeOcrModel: ( removeOcrModel: (
+101 -14
View File
@@ -1,4 +1,10 @@
import { z } from 'zod' import { z } from 'zod'
import {
MODEL_DOWNLOAD_SOURCES,
modelArtifactIdentitySchema,
modelDownloadAvailabilitySchema,
modelDownloadSourceSchema
} from './model-download-contracts'
import { settingsWarningsSchema } from './settings-warning-contracts' import { settingsWarningsSchema } from './settings-warning-contracts'
export const maximumDocumentExtractedCharacters = 5_000_000 export const maximumDocumentExtractedCharacters = 5_000_000
@@ -36,9 +42,8 @@ export const localOcrModelIdSchema = z
.max(96) .max(96)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u) .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u)
const documentOcrSha256Schema = z const documentOcrSha256Schema =
.string() modelArtifactIdentitySchema.shape.sha256
.regex(/^[a-f0-9]{64}$/u)
export const documentOcrModelFileRoleSchema = z.enum([ export const documentOcrModelFileRoleSchema = z.enum([
'detection', 'detection',
@@ -46,13 +51,8 @@ export const documentOcrModelFileRoleSchema = z.enum([
'dictionary' 'dictionary'
]) ])
export const documentOcrModelDownloadSchema = z export const documentOcrModelArtifactSchema =
.object({ modelArtifactIdentitySchema
url: z.url().max(2_048),
size: z.number().int().positive().safe(),
sha256: documentOcrSha256Schema
})
.strict()
export const documentOcrModelFileSchema = z export const documentOcrModelFileSchema = z
.object({ .object({
@@ -62,7 +62,9 @@ export const documentOcrModelFileSchema = z
.max(255) .max(255)
.regex(/^[^/\\\0]+$/u), .regex(/^[^/\\\0]+$/u),
role: documentOcrModelFileRoleSchema, role: documentOcrModelFileRoleSchema,
download: documentOcrModelDownloadSchema size: documentOcrModelArtifactSchema.shape.size,
sha256: documentOcrModelArtifactSchema.shape.sha256,
targets: documentOcrModelArtifactSchema.shape.targets
}) })
.strict() .strict()
@@ -76,7 +78,12 @@ export const documentOcrModelCatalogEntrySchema = z
quality: z.enum(['basic', 'balanced', 'high']), quality: z.enum(['basic', 'balanced', 'high']),
speed: z.enum(['fast', 'balanced', 'slow']), speed: z.enum(['fast', 'balanced', 'slow']),
recommended: z.boolean(), 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 license: z
.object({ .object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
@@ -108,8 +115,64 @@ export const documentOcrModelCatalogEntrySchema = z
message: 'OCR 模型文件角色不能重复' 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 export const installedDocumentOcrModelSchema = z
.object({ .object({
id: localOcrModelIdSchema, id: localOcrModelIdSchema,
@@ -138,14 +201,28 @@ export const documentOcrModelOperationSchema = z
phase: z.enum(['preparing', 'transferring', 'installing']), phase: z.enum(['preparing', 'transferring', 'installing']),
currentFile: z.string().min(1).max(255).nullable(), currentFile: z.string().min(1).max(255).nullable(),
completedBytes: z.number().int().nonnegative().safe(), completedBytes: z.number().int().nonnegative().safe(),
totalBytes: z.number().int().nonnegative().safe().nullable() totalBytes: z.number().int().nonnegative().safe().nullable(),
downloadSource: modelDownloadSourceSchema.optional()
}) })
.strict() .strict()
.superRefine((operation, context) => {
if (
(operation.kind === 'download') !==
(operation.downloadSource !== undefined)
) {
context.addIssue({
code: 'custom',
path: ['downloadSource'],
message: 'OCR 下载操作必须且仅能包含下载源'
})
}
})
export const documentOcrModelSnapshotSchema = z export const documentOcrModelSnapshotSchema = z
.object({ .object({
rootDirectory: z.string().min(1).max(32_768), 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), installed: z.array(installedDocumentOcrModelSchema).max(16),
operations: z.array(documentOcrModelOperationSchema).max(8) operations: z.array(documentOcrModelOperationSchema).max(8)
}) })
@@ -157,6 +234,13 @@ export const documentOcrModelActionInputSchema = z
}) })
.strict() .strict()
export const documentOcrModelInstallInputSchema = z
.object({
modelId: localOcrModelIdSchema,
expectedDownloadSource: modelDownloadSourceSchema
})
.strict()
export const documentParsingSettingsSchema = z export const documentParsingSettingsSchema = z
.object({ .object({
chatWorkflow: chatDocumentWorkflowSchema, chatWorkflow: chatDocumentWorkflowSchema,
@@ -349,6 +433,9 @@ export type DocumentOcrModelFile = z.infer<
export type DocumentOcrModelCatalogEntry = z.infer< export type DocumentOcrModelCatalogEntry = z.infer<
typeof documentOcrModelCatalogEntrySchema typeof documentOcrModelCatalogEntrySchema
> >
export type DocumentOcrModelCatalogViewEntry = z.infer<
typeof documentOcrModelCatalogViewEntrySchema
>
export type InstalledDocumentOcrModel = z.infer< export type InstalledDocumentOcrModel = z.infer<
typeof installedDocumentOcrModelSchema typeof installedDocumentOcrModelSchema
> >
@@ -134,6 +134,7 @@ describe('GoodBuddy configuration contracts', () => {
application: { application: {
checkUpdatesOnStartup: true, checkUpdatesOnStartup: true,
updateSource: 'github', updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true, magicNotesEnabled: true,
magicNoteCommentMode: 'immediate', magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined' magicNoteCommentFormat: 'combined'
+249
View File
@@ -0,0 +1,249 @@
import { z } from 'zod'
const sha256Pattern = /^[a-f0-9]{64}$/u
const immutableRevisionPattern = /^[a-f0-9]{40,64}$/u
const hostNamePattern =
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$/u
export const modelDownloadSourceSchema = z.enum([
'modelscope',
'hugging-face'
])
export type ModelDownloadSource = z.infer<
typeof modelDownloadSourceSchema
>
export const MODEL_DOWNLOAD_SOURCES = modelDownloadSourceSchema.options
export const MODEL_DOWNLOAD_REDIRECT_HOSTS = {
modelscope: [],
'hugging-face': [
'cdn-lfs.hf.co',
'cdn-lfs-us-1.hf.co',
'cdn-lfs-eu-1.hf.co',
'cas-bridge.xethub.hf.co'
]
} as const satisfies Record<
ModelDownloadSource,
readonly string[]
>
function isSourceHost(
source: ModelDownloadSource,
hostname: string
): boolean {
return source === 'modelscope'
? hostname === 'modelscope.cn' || hostname === 'www.modelscope.cn'
: hostname === 'huggingface.co'
}
export const modelArtifactTargetSchema = z
.object({
url: z.url().max(2_048),
repositoryUrl: z.url().max(2_048),
revision: z.string().regex(immutableRevisionPattern),
redirectHosts: z
.array(z.string().max(253).regex(hostNamePattern))
.max(16)
.default([])
})
.strict()
.superRefine((target, context) => {
for (const [key, value] of [
['url', target.url],
['repositoryUrl', target.repositoryUrl]
] as const) {
const parsed = new URL(value)
if (
parsed.protocol !== 'https:' ||
(parsed.port !== '' && parsed.port !== '443') ||
parsed.username ||
parsed.password ||
parsed.hash
) {
context.addIssue({
code: 'custom',
path: [key],
message:
'模型地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL'
})
}
}
const encodedRevision = encodeURIComponent(target.revision)
const downloadUrl = new URL(target.url)
const repositoryUrl = new URL(target.repositoryUrl)
const repositoryPath = repositoryUrl.pathname.replace(/\/+$/u, '')
if (
downloadUrl.origin !== repositoryUrl.origin ||
!downloadUrl.pathname.startsWith(
`${repositoryPath}/resolve/${encodedRevision}/`
)
) {
context.addIssue({
code: 'custom',
path: ['url'],
message: '模型下载地址必须属于声明仓库并包含固定 Revision'
})
}
})
export const modelArtifactTargetsSchema = z
.object({
modelscope: modelArtifactTargetSchema.optional(),
'hugging-face': modelArtifactTargetSchema.optional()
})
.strict()
.superRefine((targets, context) => {
for (const source of MODEL_DOWNLOAD_SOURCES) {
const target = targets[source]
if (!target) {
continue
}
const downloadHost = new URL(target.url).hostname
const repositoryHost = new URL(target.repositoryUrl).hostname
if (
!isSourceHost(source, downloadHost) ||
!isSourceHost(source, repositoryHost)
) {
context.addIssue({
code: 'custom',
path: [source],
message: '模型地址与声明的下载源不匹配'
})
}
const allowedRedirectHosts: ReadonlySet<string> = new Set(
MODEL_DOWNLOAD_REDIRECT_HOSTS[source]
)
target.redirectHosts.forEach((hostname, index) => {
if (!allowedRedirectHosts.has(hostname)) {
context.addIssue({
code: 'custom',
path: [source, 'redirectHosts', index],
message: '模型重定向主机不属于声明的下载源'
})
}
})
}
})
export const modelDownloadAvailabilitySchema = z
.object({
source: modelDownloadSourceSchema,
available: z.boolean(),
totalBytes: z.number().int().positive().safe().optional(),
unavailableReason: z.string().trim().min(1).max(500).optional()
})
.strict()
.superRefine((availability, context) => {
if (availability.available && availability.totalBytes === undefined) {
context.addIssue({
code: 'custom',
path: ['totalBytes'],
message: '可下载模型必须提供总大小'
})
}
if (
!availability.available &&
availability.unavailableReason === undefined
) {
context.addIssue({
code: 'custom',
path: ['unavailableReason'],
message: '不可下载模型必须说明原因'
})
}
})
export type ModelArtifactTarget = z.infer<
typeof modelArtifactTargetSchema
>
export type ModelArtifactTargets = z.infer<
typeof modelArtifactTargetsSchema
>
export type ModelDownloadAvailability = z.infer<
typeof modelDownloadAvailabilitySchema
>
export type ResolvableModelArtifactFile<Role extends string = string> = {
name: string
role: Role
size: number
sha256: string
targets: ModelArtifactTargets
}
export type ResolvedModelArtifactFile<Role extends string = string> = {
name: string
role: Role
size: number
sha256: string
target: ModelArtifactTarget
}
export type ResolvedModelPackage<Role extends string = string> = {
source: ModelDownloadSource
totalBytes: number
files: ResolvedModelArtifactFile<Role>[]
}
export function getModelDownloadAvailability(
files: readonly ResolvableModelArtifactFile[],
source: ModelDownloadSource
): ModelDownloadAvailability {
const available =
files.length > 0 && files.every((file) => file.targets[source])
if (!available) {
return modelDownloadAvailabilitySchema.parse({
source,
available: false,
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
})
}
const totalBytes = files.reduce((total, file) => total + file.size, 0)
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
throw new RangeError('模型总大小超出安全范围')
}
return modelDownloadAvailabilitySchema.parse({
source,
available: true,
totalBytes
})
}
export function resolveModelDownloadPackage<Role extends string>(
files: readonly ResolvableModelArtifactFile<Role>[],
source: ModelDownloadSource
): ResolvedModelPackage<Role> {
const availability = getModelDownloadAvailability(files, source)
if (!availability.available || availability.totalBytes === undefined) {
throw new Error(
availability.unavailableReason ??
'当前下载源暂不提供此模型的完整已验证文件'
)
}
return {
source,
totalBytes: availability.totalBytes,
files: files.map((file) => {
const target = file.targets[source]
if (!target) {
throw new Error('模型下载元数据不完整')
}
return {
name: file.name,
role: file.role,
size: file.size,
sha256: file.sha256,
target
}
})
}
}
export const modelArtifactIdentitySchema = z
.object({
size: z.number().int().positive().safe(),
sha256: z.string().regex(sha256Pattern),
targets: modelArtifactTargetsSchema
})
.strict()
+198 -30
View File
@@ -5,6 +5,9 @@ import {
speechModelSnapshotSchema speechModelSnapshotSchema
} from './speech-model-contracts' } from './speech-model-contracts'
const revision = 'a'.repeat(40)
const repositoryUrl =
'https://huggingface.co/example/test-speech-model'
const downloadableEntry = { const downloadableEntry = {
id: 'test-speech-model', id: 'test-speech-model',
displayName: 'Test speech model', displayName: 'Test speech model',
@@ -15,7 +18,9 @@ const downloadableEntry = {
quality: 'balanced' as const, quality: 'balanced' as const,
speed: 'balanced' as const, speed: 'balanced' as const,
recommended: false, recommended: false,
repositoryUrl: 'https://huggingface.co/example/test-speech-model', repositoryUrls: {
'hugging-face': repositoryUrl
},
license: { license: {
name: 'MIT License', name: 'MIT License',
notice: 'Test license notice.', notice: 'Test license notice.',
@@ -26,36 +31,175 @@ const downloadableEntry = {
{ {
name: 'model.onnx', name: 'model.onnx',
role: 'model' as const, role: 'model' as const,
download: { size: 12,
url: 'https://huggingface.co/example/test/resolve/main/model.onnx', sha256: 'a'.repeat(64),
size: 12, targets: {
sha256: 'a'.repeat(64) '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', () => { 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( expect(speechModelCatalogEntrySchema.parse(downloadableEntry)).toEqual(
downloadableEntry downloadableEntry
) )
expect( expect(
speechModelCatalogEntrySchema.safeParse({ speechModelCatalogEntrySchema.safeParse({
...downloadableEntry, ...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 }).success
).toBe(false) ).toBe(false)
}) })
it('requires a reason for manual-only models and rejects duplicate files', () => { 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( expect(
speechModelCatalogEntrySchema.safeParse({ speechModelCatalogEntrySchema.safeParse({
...downloadableEntry, ...downloadableEntry,
manualOnly: true, manualOnly: true,
files: [ files: [
{ name: 'model.onnx', role: 'model' }, manualFile,
{ name: 'model.onnx', role: 'tokens' } { ...manualFile, role: 'tokens' }
] ]
}).success }).success
).toBe(false) ).toBe(false)
@@ -63,13 +207,55 @@ describe('speech model contracts', () => {
speechModelCatalogEntrySchema.safeParse({ speechModelCatalogEntrySchema.safeParse({
...downloadableEntry, ...downloadableEntry,
manualOnly: true, manualOnly: true,
manualReason: '上游没有可核验的大小和摘要。', manualReason: '上游没有可核验的下载目标。',
files: [{ name: 'model.onnx', role: 'model' }] files: [manualFile]
}).success }).success
).toBe(true) ).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( expect(
speechModelCatalogEntrySchema.safeParse({ speechModelCatalogEntrySchema.safeParse({
...downloadableEntry, ...downloadableEntry,
@@ -88,23 +274,5 @@ describe('speech model contracts', () => {
copyEverything: true copyEverything: true
}).success }).success
).toBe(false) ).toBe(false)
expect(
speechModelSnapshotSchema.safeParse({
rootDirectory: 'C:\\models\\speech',
catalog: [downloadableEntry],
installed: [],
operations: [
{
modelId: 'test-speech-model',
kind: 'download',
phase: 'transferring',
currentFile: 'model.onnx',
completedBytes: -1,
totalBytes: 12
}
],
selectedModelId: null
}).success
).toBe(false)
}) })
}) })
+94 -16
View File
@@ -1,9 +1,14 @@
import { z } from 'zod' import { z } from 'zod'
import {
MODEL_DOWNLOAD_SOURCES,
modelArtifactIdentitySchema,
modelDownloadAvailabilitySchema,
modelDownloadSourceSchema
} from './model-download-contracts'
const safeIdentifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u const safeIdentifierPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
const safeFileNamePattern = const safeFileNamePattern =
/^(?!\.{1,2}$)(?!.*(?:^|[\\/])\.{1,2}(?:[\\/]|$))[^/\\\0]+$/u /^(?!\.{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_SAMPLE_RATE = 16_000
export const SPEECH_TRANSCRIPTION_MAX_SECONDS = 20 export const SPEECH_TRANSCRIPTION_MAX_SECONDS = 20
export const SPEECH_TRANSCRIPTION_MAX_SAMPLES = export const SPEECH_TRANSCRIPTION_MAX_SAMPLES =
@@ -29,19 +34,15 @@ export const speechModelFileRoleSchema = z.enum([
'configuration' 'configuration'
]) ])
export const speechModelDownloadSchema = z export const speechModelArtifactSchema = modelArtifactIdentitySchema
.object({
url: z.url().max(2_048),
size: z.number().int().positive().safe(),
sha256: z.string().regex(sha256Pattern)
})
.strict()
export const speechModelFileSpecSchema = z export const speechModelFileSpecSchema = z
.object({ .object({
name: speechModelFileNameSchema, name: speechModelFileNameSchema,
role: speechModelFileRoleSchema, role: speechModelFileRoleSchema,
download: speechModelDownloadSchema.optional() size: speechModelArtifactSchema.shape.size,
sha256: speechModelArtifactSchema.shape.sha256,
targets: speechModelArtifactSchema.shape.targets
}) })
.strict() .strict()
@@ -64,7 +65,12 @@ export const speechModelCatalogEntrySchema = z
quality: z.enum(['basic', 'balanced', 'high']), quality: z.enum(['basic', 'balanced', 'high']),
speed: z.enum(['fast', 'balanced', 'slow']), speed: z.enum(['fast', 'balanced', 'slow']),
recommended: z.boolean(), 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, license: speechModelLicenseSchema,
manualOnly: z.boolean(), manualOnly: z.boolean(),
manualReason: z.string().trim().min(1).max(500).optional(), manualReason: z.string().trim().min(1).max(500).optional(),
@@ -86,24 +92,72 @@ export const speechModelCatalogEntrySchema = z
message: '仅手动导入的模型必须说明原因' 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 ( if (
!entry.manualOnly && !MODEL_DOWNLOAD_SOURCES.some((source) =>
entry.files.some((file) => file.download === undefined) entry.files.every((file) => file.targets[source])
)
) { ) {
context.addIssue({ context.addIssue({
code: 'custom', code: 'custom',
path: ['files'], 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 export const speechModelInstalledFileSchema = z
.object({ .object({
name: speechModelFileNameSchema, name: speechModelFileNameSchema,
role: speechModelFileRoleSchema, role: speechModelFileRoleSchema,
size: z.number().int().nonnegative().safe(), size: z.number().int().nonnegative().safe(),
sha256: z.string().regex(sha256Pattern) sha256: speechModelArtifactSchema.shape.sha256
}) })
.strict() .strict()
@@ -124,14 +178,28 @@ export const speechModelOperationSchema = z
phase: z.enum(['preparing', 'transferring', 'installing']), phase: z.enum(['preparing', 'transferring', 'installing']),
currentFile: speechModelFileNameSchema.nullable(), currentFile: speechModelFileNameSchema.nullable(),
completedBytes: z.number().int().nonnegative().safe(), completedBytes: z.number().int().nonnegative().safe(),
totalBytes: z.number().int().nonnegative().safe().nullable() totalBytes: z.number().int().nonnegative().safe().nullable(),
downloadSource: modelDownloadSourceSchema.optional()
}) })
.strict() .strict()
.superRefine((operation, context) => {
if (
(operation.kind === 'download') !==
(operation.downloadSource !== undefined)
) {
context.addIssue({
code: 'custom',
path: ['downloadSource'],
message: '下载操作必须且仅能包含下载源'
})
}
})
export const speechModelSnapshotSchema = z export const speechModelSnapshotSchema = z
.object({ .object({
rootDirectory: z.string().min(1).max(32_768), 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), installed: z.array(installedSpeechModelSchema).max(64),
operations: z.array(speechModelOperationSchema).max(16), operations: z.array(speechModelOperationSchema).max(16),
selectedModelId: speechModelIdSchema.nullable() selectedModelId: speechModelIdSchema.nullable()
@@ -144,6 +212,13 @@ export const speechModelActionInputSchema = z
}) })
.strict() .strict()
export const speechModelInstallInputSchema = z
.object({
modelId: speechModelIdSchema,
expectedDownloadSource: modelDownloadSourceSchema
})
.strict()
export const speechModelSelectionInputSchema = z export const speechModelSelectionInputSchema = z
.object({ .object({
modelId: speechModelIdSchema.nullable() modelId: speechModelIdSchema.nullable()
@@ -185,6 +260,9 @@ export type SpeechModelFileSpec = z.infer<
export type SpeechModelCatalogEntry = z.infer< export type SpeechModelCatalogEntry = z.infer<
typeof speechModelCatalogEntrySchema typeof speechModelCatalogEntrySchema
> >
export type SpeechModelCatalogViewEntry = z.infer<
typeof speechModelCatalogViewEntrySchema
>
export type InstalledSpeechModel = z.infer< export type InstalledSpeechModel = z.infer<
typeof installedSpeechModelSchema typeof installedSpeechModelSchema
> >