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