From 43e1d162dcb83e133ba279e5917dcc83bbb28779 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 19 Aug 2026 13:43:16 +0800 Subject: [PATCH] feat: add explicit managed model download sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 模型下载源;缺少完整已验证文件或下载失败时不会静默换源。 --- FEATURES.md | 3 +- UI-DESIGN.md | 1 + docs/README.md | 3 + docs/architecture/full-duplex-voice-design.md | 944 +++++++++++++ .../local-text-embedding-model-design.md | 1188 +++++++++++++++++ .../model-download-source-design.md | 1009 ++++++++++++++ .../document-extraction-and-local-ocr.md | 24 +- .../knowledge-rag-enhancement-prd.md | 16 +- src/main/application-settings-store.test.ts | 53 +- src/main/application-settings-store.ts | 34 +- src/main/document-ocr-model-catalog.ts | 206 ++- src/main/document-ocr-model-manager.test.ts | 181 ++- src/main/document-ocr-model-manager.ts | 173 ++- src/main/index.ts | 8 +- src/main/ipc.test.ts | 173 +++ src/main/ipc.ts | 67 +- src/main/model-download-targets.ts | 34 + src/main/model-download-transport.ts | 71 + src/main/speech/speech-model-catalog.ts | 369 ++--- src/main/speech/speech-model-manager.test.ts | 295 +++- src/main/speech/speech-model-manager.ts | 184 +-- src/preload/index.ts | 15 +- src/renderer/src/App.test.tsx | 13 + .../DocumentParsingSettingsSection.test.tsx | 55 +- .../src/DocumentParsingSettingsSection.tsx | 161 ++- src/renderer/src/MagicNotesWorkspace.test.tsx | 5 + .../src/PlatformFeaturesSettingsSection.tsx | 345 +++-- src/renderer/src/SettingsPanel.test.tsx | 144 +- src/renderer/src/SettingsPanel.tsx | 11 +- .../src/SpeechModelSettingsSection.test.tsx | 117 +- .../src/SpeechModelSettingsSection.tsx | 101 +- .../src/UpdateSettingsSection.test.tsx | 1 + .../src/i18n/locales/en-US/settings.ts | 19 +- .../i18n/locales/en-US/settingsSections.ts | 43 +- .../src/i18n/locales/zh-CN/settings.ts | 20 +- .../i18n/locales/zh-CN/settingsSections.ts | 39 +- src/renderer/src/styles.css | 77 ++ src/shared/application-settings-contracts.ts | 8 + src/shared/contracts.ts | 9 +- src/shared/document-parsing-contracts.ts | 115 +- src/shared/goodbuddy-config-contracts.test.ts | 1 + src/shared/model-download-contracts.ts | 249 ++++ src/shared/speech-model-contracts.test.ts | 228 +++- src/shared/speech-model-contracts.ts | 110 +- 44 files changed, 6083 insertions(+), 839 deletions(-) create mode 100644 docs/architecture/full-duplex-voice-design.md create mode 100644 docs/architecture/local-text-embedding-model-design.md create mode 100644 docs/architecture/model-download-source-design.md create mode 100644 src/main/model-download-targets.ts create mode 100644 src/main/model-download-transport.ts create mode 100644 src/shared/model-download-contracts.ts diff --git a/FEATURES.md b/FEATURES.md index 49683d7..5c24fc6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 证书,关闭后恢复严格地址和证书校验。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 763ef23..74687da 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -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 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 diff --git a/docs/README.md b/docs/README.md index 9c98d5a..b276fec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/architecture/full-duplex-voice-design.md b/docs/architecture/full-duplex-voice-design.md new file mode 100644 index 0000000..ccc5632 --- /dev/null +++ b/docs/architecture/full-duplex-voice-design.md @@ -0,0 +1,944 @@ +# GoodBuddy 全双工实时语音交互设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档类型 | 跨功能技术与产品架构 | +| 状态 | 设计中 | +| 版本 | 0.1 | +| 日期 | 2026-08-19 | +| 适用产品 | GoodBuddy 桌面端 | +| 目标平台 | Windows、macOS、Linux,x64 与 arm64 | +| 相关基线 | [跨平台助手产品设计](../design/cross-platform-assistant-product-design.md)、[长期助手路线图](../roadmap/long-term-assistant-roadmap.md)、[统一界面设计系统](../../UI-DESIGN.md) | + +本文定义 GoodBuddy 中类似自然通话的全双工实时语音能力,包括本地与云端语音引擎、 +音频平面、会话状态、打断语义、工具审批、数据留存、失败恢复、跨平台交付和验收指标。 + +本文所称“支持本地与云端”是指用户可以显式配置并选择不同语音引擎,不代表系统可以在 +它们之间自动切换。**GoodBuddy 不设计静默降级。** + +--- + +## 1. 摘要与核心决策 + +1. 实时语音是独立的 `VoiceSession`,不把现有一次性语音听写改名后直接复用。 +2. 系统支持三种显式引擎: + - 本地模块化全双工:本地流式 ASR、所选 Agent Runtime、本地流式 TTS。 + - 本地原生全双工:一个本地端到端语音模型同时听、想和说。 + - 云端原生全双工:通过供应商 Realtime/Live API 进行双向流式音频交互。 +3. 用户开始会话时冻结引擎、Provider、模型、版本、地域、数据位置、声音、能力和 + Turn Detection 配置。会话过程中不得静默替换。 +4. 同一目标内允许有界重试、网络抖动恢复和语义等价的内部执行优化;任何会改变 + Provider、模型、数据位置、成本、隐私、能力、质量或可感知延迟的替代路径都必须显式。 +5. 所选引擎不可用时,会话明确进入 `blocked` 或 `failed`,保留可恢复上下文,并提供 + “重试当前引擎”或“结束后选择其他引擎”。不自动切换本地/云端,不退回听写、纯文本或 + 非全双工模式。 +6. Renderer 负责麦克风采集、回声消除、低延迟播放和即时打断;Main 负责凭据、会话 + 控制、Provider Adapter、工具权限、持久化和资源回收。 +7. 音频帧不进入普通 `AgentEvent` 和聊天消息持久化通道。默认只保存最终文本、会话状态和 + 有界诊断,不保存原始录音。 +8. 语音不能成为新的授权通道。Ask 继续只读,Execute 的工具调用继续经过现有审批控件。 + +--- + +## 2. 背景与当前基础 + +GoodBuddy 当前已经具备: + +- Renderer 中的麦克风入口、录音状态和取消操作。 +- `getUserMedia` 的单声道采集、回声消除和噪声抑制。 +- 将完整录音重采样为 16 kHz PCM 的能力。 +- 基于 `sherpa-onnx` 的本地离线识别、模型下载、ZIP 迁移、选择和删除。 +- Main 中受信任发送者校验、Zod IPC 输入校验、超时、取消和应用关闭回收。 +- `AgentRuntime.run()` 的流式文本、工具事件、审批、取消和会话持久化。 +- Renderer 中的流式聊天时间线、全局通知和可访问的输入控件。 + +当前链路仍是: + +```text +点击麦克风 +→ 最多录音 20 秒 +→ 停止并一次性发送完整 PCM +→ 本地离线转写 +→ 把文本插入输入框 +→ 用户再次确认发送 +``` + +该链路适合听写,但不具备: + +- 连续流式识别和临时转写。 +- 同时采集与播放。 +- 自动轮次检测。 +- 助手语音输出。 +- 用户抢话和响应截断。 +- 音频队列、背压和时钟同步。 +- 实时语音 Provider 抽象。 +- 语音会话快照和诊断。 + +因此实时语音必须新增会话层,而不是在现有 `SpeechTranscriptionService` 后面简单追加 TTS。 + +--- + +## 3. 目标 + +### 3.1 用户目标 + +- 用户可以像通话一样持续说话,不需要每轮点击开始和停止。 +- 助手可以边生成边说,并显示与实际播放进度一致的文本。 +- 用户开口时可以自然打断,助手在很短时间内停止出声并开始听取新内容。 +- 用户始终知道当前使用本地还是云端、具体引擎是什么、音频或转写文本会去哪里。 +- 本地或云端引擎失败时,用户能看到准确状态并决定下一步,不被系统暗中换模型。 +- 语音对话继续拥有文本聊天中的项目、知识库、角色、Ask/Execute、工具审批和历史能力。 + +### 3.2 产品目标 + +- 在六个平台/架构目标上提供统一的上层会话契约。 +- 先以现有 `sherpa-onnx` 和 Agent Runtime 构建可跨平台交付的本地模块化引擎。 +- 允许云端 Provider 使用 WebRTC 或 WebSocket,但不把供应商协议泄漏到通用 UI。 +- 允许高性能设备安装本地原生全双工模型,但按真实能力检测决定是否可选。 +- 保持 Main-only 凭据、上下文隔离、沙箱、取消、超时、有界输出和关机回收。 +- 为延迟、打断、回声、音频中断、Provider 错误和成本提供可诊断指标。 + +--- + +## 4. 非目标 + +首期不包含: + +- 唤醒词、后台常驻监听或应用退出后的麦克风采集。 +- 根据网络、负载、价格或“智能判断”自动选择语音引擎。 +- 在一个会话内自动从云端切到本地,或从本地切到云端。 +- 从原生全双工自动退到 ASR → LLM → TTS,或反向切换。 +- 在实时语音失败后自动改成一次性听写、纯文本发送或系统 TTS。 +- 默认保存、上传或训练用户原始音频。 +- 声音克隆、未成年人声音模仿、电话呼入或多人会议。 +- 使用口头“同意”替代工具审批按钮或键盘确认。 +- 绕过当前 Agent Runtime 和权限边界的 Provider 直连工具、MCP 或 Connector。 +- 保证所有本地原生语音模型都能在 CPU 或全部六个发布目标上运行。 + +--- + +## 5. 术语与全双工范围 + +| 术语 | 定义 | +| --- | --- | +| `VoiceEngineProfile` | 用户保存的语音引擎配置,包含类型、Provider、模型、地域、声音和能力 | +| `VoiceSession` | 一次从用户显式开始到结束的连续实时语音会话 | +| `VoiceTurn` | 用户输入和助手响应形成的一次可持久化对话轮次 | +| 系统级全双工 | 麦克风在助手播放期间继续采集,用户可以随时打断 | +| 原生模型全双工 | 同一个模型联合处理持续输入、轮次判断和持续语音输出 | +| 模块化全双工 | ASR、Agent Runtime 和 TTS 分离,但系统保持同时听说与可打断 | +| 临时文本 | 尚未确认的 ASR 或尚未实际播放的助手文本,不写入长期历史 | +| 已提交文本 | 用户轮次已确认,或助手对应音频已实际播放的文本 | +| Barge-in | 用户在助手说话时开口,触发立即静音、取消和上下文截断 | +| 引擎快照 | 会话开始时冻结的完整、无凭据配置及能力声明 | + +“模块化”不等同于“回退”。当用户明确选择模块化本地引擎时,它就是该会话的唯一正式 +执行路径。原生模型和模块化引擎之间没有隐式优先级。 + +--- + +## 6. 不静默降级产品契约 + +### 6.1 必须显式的变化 + +以下变化不得在活动会话中静默发生: + +- 本地与云端之间切换。 +- Provider、Endpoint、地域或账号切换。 +- 模型 ID、模型版本、量化档位或语音角色切换。 +- 原生全双工与模块化全双工之间切换。 +- ASR、LLM 或 TTS 组件切换。 +- 从音频输入改成仅文本输入,或从语音输出改成仅文本输出。 +- 禁用原本声明可用的工具、知识库、角色或 Execute 能力后继续运行。 +- 把原始音频改为上传,或改变云端数据地域和保留策略。 +- 采用明显更慢、更低质量或成本不同的路径。 + +### 6.2 可自动进行的恢复 + +以下操作可以自动执行,但必须保持同一引擎快照: + +- 同一连接内的丢包恢复、抖动缓冲和音频重排。 +- 同一 Provider、模型、地域和配置的有限重连。 +- 同一本地模型进程的有限重启。 +- 不改变语义、隐私、成本和已声明性能级别的算子或执行 Provider 优化。 + +恢复在用户可感知前完成时可不打断界面;持续超过 500 ms、导致音频停顿或创建新远端 +会话时,必须显示“正在重新连接当前引擎”。所有恢复都进入有界诊断记录。 + +### 6.3 失败后的用户决策 + +恢复预算耗尽后: + +1. 停止采集上传和音频播放。 +2. 将临时文本标记为未提交,不伪装成完整轮次。 +3. 保存已提交文本和脱敏错误。 +4. 显示当前失败的引擎、影响和建议。 +5. 提供“重试当前引擎”和“结束语音会话”。 +6. 用户结束后可以显式选择其他引擎并开始新会话。 + +首期不提供自动 Failover 列表。未来即使允许用户预先配置替代引擎,也必须在切换前获得 +明确确认,并在会话中持续显示新的活动引擎。 + +### 6.4 产品级适用范围 + +本契约不仅适用于语音。GoodBuddy 中 Provider、模型、Runtime、数据处理位置、工作模式、 +权限范围和质量档位等影响隐私、成本或能力的用户选择,都不得被静默替换。 + +用户明确选择名为“自动”的策略时,系统可以在该策略事先声明的范围内选择,但实际结果和 +任何能力退化必须可见、可诊断,不能把空结果或不完整结果表示为正常成功。 + +--- + +## 7. 用户体验 + +### 7.1 入口 + +现有麦克风入口继续表示“语音输入/听写”,转写进入可编辑输入框,不自动发送。 + +实时语音使用独立的“开始语音对话”入口,避免用户误以为点击一次听写会开启持续监听。 +入口只在以下条件满足时可用: + +- 已选择并验证一个全双工语音引擎。 +- 当前平台满足该引擎能力要求。 +- 麦克风权限可申请。 +- 当前 Conversation 没有冲突的活动请求。 +- 当前工作模式和引擎能力兼容。 + +### 7.2 会话界面 + +活动会话显示一个持续可见的语音控制区: + +- 当前状态:准备中、正在听、用户说话、正在思考、助手说话、正在打断、等待审批、 + 正在重连、失败。 +- 本地/云端徽标、引擎名称和数据去向。 +- 实时用户转写和与播放同步的助手文本。 +- 麦克风静音、结束会话和必要的设备入口。 +- 输入音量与助手播放状态,但不得只用颜色表达。 +- 云端会话的使用量或成本提示入口。 + +“结束语音会话”是活动状态下的唯一主操作。波形和头像动效遵守 +`prefers-reduced-motion`,关闭动效后仍使用文字和图标表达状态。 + +### 7.3 打断 + +助手说话期间检测到用户有效语音: + +1. 在 Renderer 立即对当前音频执行 20–40 ms 淡出。 +2. 清空尚未播放的音频队列。 +3. 向 Main 发送包含播放位置的 `interrupt`。 +4. Main 取消当前 Agent/TTS 响应或向 Provider 发送截断事件。 +5. 尚未播放的助手文本保持临时状态并从会话上下文中移除。 +6. 输入状态切到用户说话,继续采集,不重新建立会话。 + +键盘点击“停止说话”与语音 Barge-in 使用相同取消和提交语义。 + +### 7.4 工具和审批 + +- Ask 模式继续在 Runtime 边界保持只读。 +- Execute 模式的工具调用进入现有 Approval Broker。 +- 等待审批时暂停新的助手音频,可播放一次确定性的短提示,例如“需要你确认一个操作”。 +- 工具参数、风险、范围和确认操作使用现有可访问审批控件。 +- 麦克风中的“同意”“确认”或相似内容只作为普通用户文本,不构成授权。 +- 用户拒绝或取消后,结果作为结构化工具事件返回当前引擎,不私自换模型继续。 + +### 7.5 设置结构 + +长期设置结构使用一级“语音”分类,并以 `PageTabs` 组织: + +1. **实时对话**:语音引擎列表、默认引擎、能力、数据位置、地域、声音和真实连接测试。 +2. **语音输入**:现有本地 ASR 模型、一次性听写和麦克风设置。 +3. **语音输出**:本地 TTS 模型、声音、语速和试听。 + +当前“模型连接”中的“语音输入”可在迁移阶段保留,之后移动现有模型管理组件时必须保存 +已安装模型和选择,不创建第二份设置。 + +模型类型选择器当前已经包含四项,不增加第五个分段项来承载实时语音,以免违反 +`SegmentedControl` 的 2–4 项约束。 + +语音引擎卡片必须持续显示: + +- 本地或云端。 +- Provider、模型和版本。 +- 支持的语言。 +- 系统级或原生模型全双工。 +- 是否支持工具、图像和当前 Ask/Execute 模式。 +- 所需硬件或云端地域。 +- 音频和文本的数据去向。 +- 安装、已验证、不可用或需要凭据状态。 + +实际生成能力只能通过一次真实、有界、由用户触发的会话测试确认。配置保存成功或只完成 +握手不能证明麦克风输入、语音输出和打断均可工作。 + +--- + +## 8. 总体架构 + +```text +┌──────────────────────── Renderer ────────────────────────┐ +│ VoiceSession UI │ +│ getUserMedia → AudioWorklet Capture → Fast VAD │ +│ AudioWorklet Playback ← Jitter/Playback Queue │ +└────────────── control IPC ─────── media MessagePort ───────┘ + │ +┌────────────────────────── Main ────────────────────────────┐ +│ VoiceSessionController │ +│ ├─ Session snapshot and state │ +│ ├─ Turn coordinator and interruption │ +│ ├─ Tool/approval bridge │ +│ ├─ Transcript/message persistence │ +│ ├─ Credential and provider policy │ +│ └─ VoiceEngineAdapter │ +│ ├─ LocalModularAdapter │ +│ ├─ LocalNativeDuplexAdapter │ +│ └─ CloudRealtimeAdapter │ +└───────────────┬──────────────────────┬─────────────────────┘ + │ │ + Local managed sidecar Cloud Realtime API + or bounded worker WebRTC / WebSocket +``` + +### 8.1 Renderer 音频平面 + +Renderer 负责需要接近音频设备的低延迟操作: + +- 在用户操作后调用 `getUserMedia`。 +- 请求单声道、回声消除、噪声抑制和受支持时的自动增益。 +- 使用 `AudioWorklet`,不继续扩展 `ScriptProcessorNode`。 +- 将音频切成 10–20 ms 有序帧,并按引擎格式重采样。 +- 执行快速本地 VAD,用于 Barge-in,不独立提交最终轮次。 +- 维护有界播放和抖动缓冲,记录实际播放采样位置。 +- 在打断、设备变化、休眠或窗口销毁时立即静音和释放资源。 + +Renderer 不持有长期 API Key、不创建本地模型目录、不决定工具权限,也不持久化原始音频。 + +### 8.2 Preload 与 IPC + +控制面使用显式、类型化的 preload 方法: + +- `voice.getSnapshot()` +- `voice.startSession(input)` +- `voice.stopSession(sessionId)` +- `voice.setMuted(input)` +- `voice.interrupt(input)` +- `voice.respondApproval(...)` 继续复用现有审批接口 +- `voice.onEvent(listener)` + +音频帧不使用逐帧 `ipcRenderer.invoke`、JSON 或 Base64。Main 通过 +`MessageChannelMain` 向可信主 Frame 传递专用 `MessagePort`,使用可转移 +`ArrayBuffer` 和严格的帧头。控制事件和媒体帧分别限速、限长和验证。 + +### 8.3 Main 控制面 + +`VoiceSessionController` 负责: + +- 每个窗口最多一个活动语音会话。 +- 解析并冻结 `VoiceSessionSnapshot`。 +- 建立所选 Adapter,不执行自动 Adapter 选择。 +- 维护输入、输出和生命周期状态。 +- 将 Barge-in 传播到 Provider、Agent Runtime、TTS 和播放队列。 +- 桥接工具调用、审批、问题和取消。 +- 只提交已经确认或实际播放的文本。 +- 处理超时、重连预算、应用退出、系统休眠和窗口销毁。 +- 对错误和诊断执行脱敏与边界限制。 + +### 8.4 本地进程边界 + +轻量 ONNX 能力可以运行在受控 Worker。需要 Python、CUDA、Metal/MLX 或独立依赖树的 +原生模型运行在 GoodBuddy 管理的 Sidecar: + +- 只绑定 loopback,不监听外部网卡。 +- 使用随机端口和每次启动的短期认证值。 +- 环境变量使用最小 allowlist。 +- 不继承云端 Provider 密钥。 +- 模型路径由 Main 从受管目录解析,不接受任意相对路径。 +- 启动、健康检查、并发、输出、内存、超时和进程树有界。 +- 应用退出时终止完整进程树。 + +Sidecar 不因本地模型启动失败而自行连接云端。 + +### 8.5 云端连接边界 + +供应商支持 WebRTC 时优先使用其媒体传输、编解码和抖动能力: + +- Main 使用长期凭据创建受限、短时的会话描述或临时凭据。 +- Renderer 只接收当前会话需要的短期材料。 +- 工具和业务事件优先由 Main sideband 连接处理。 +- Provider 不支持 sideband 时,由 Main 拥有 WebSocket,并通过媒体 `MessagePort` + 与 Renderer 交换音频。 + +长期凭据永不进入 Renderer、日志、诊断或会话快照。云端 Profile 必须固定可信 Endpoint、 +地域和数据说明,不跟随重定向切换到未声明的主机。 + +--- + +## 9. 共享契约 + +建议新增 `src/shared/voice-contracts.ts`,核心结构如下: + +```ts +type VoiceEngineKind = + | 'local-modular' + | 'local-native-duplex' + | 'cloud-native-duplex' + +type VoiceComponentRef = { + providerId: string + modelId: string + modelVersion?: string + endpoint?: string + region?: string + accountRef?: string + credentialRef?: string +} + +type VoiceEngineProfile = { + id: string + name: string + kind: VoiceEngineKind + locality: 'local' | 'cloud' + voiceId: string + components: { + asr?: VoiceComponentRef + tts?: VoiceComponentRef + nativeDuplex?: VoiceComponentRef + } + dataPath: { + audioDestination: + | { kind: 'device' } + | { + kind: 'provider' + providerId: string + endpoint: string + region?: string + } + transcriptDestination: + | { kind: 'device' } + | { + kind: 'provider' + providerId: string + endpoint: string + region?: string + } + retentionPolicyId?: string + } + capabilities: { + nativeDuplex: boolean + supportsTools: boolean + supportsAsk: boolean + supportsExecute: boolean + inputLanguages: string[] + outputLanguages: string[] + } +} + +type VoiceRuntimeSnapshot = { + selection: Exclude + profileRevision?: string + configurationDigest: string + workspacePath: string +} + +type VoiceSessionSnapshot = { + sessionId: string + conversationId: string + profile: VoiceEngineProfile + profileRevision: string + engineConfigurationDigest: string + runtime?: VoiceRuntimeSnapshot + workMode: 'ask' | 'execute' + inputFormat: VoiceAudioFormat + outputFormat: VoiceAudioFormat + turnDetection: VoiceTurnDetectionConfig + startedAt: string +} +``` + +`credentialRef` 和 `accountRef` 是不含凭据正文的稳定引用。Endpoint 写入 Profile 或快照前 +必须规范化并删除用户名、密码、查询参数和 Fragment;供应商部署路径仍应保留,以便检测 +Endpoint 是否发生变化。Profile 持久化时只引用 Main 加密设置,快照不包含长期或临时 +Token。 + +模块化引擎分别记录 ASR 和 TTS 组件,原生引擎记录 `nativeDuplex` 组件;不能用一个 +`modelId` 代表多组件链路。文本 Agent 使用独立 `VoiceRuntimeSnapshot`,记录已解析的明确 +Runtime、模型 Profile 修订、配置摘要和工作区。`dataPath` 分别说明原始音频和转写文本 +留在设备还是发送到哪个供应商。 + +### 9.1 Runtime Lease + +语音会话不能在每轮请求时重新读取可变的全局 Runtime 设置。启动时必须: + +1. 将 `auto` 解析为明确的 Runtime 和模型 Profile,并在会话界面显示实际结果。 +2. 根据已解析配置创建或取得一个不可变的 `VoiceRuntimeLease`。 +3. Lease 在整个 Voice Session 内引用同一个 Runtime 实例和配置摘要。 +4. 全局设置变化只为新请求和新 Voice Session 创建 Runtime,不替换活动 Lease。 +5. 用户删除或修改活动 Profile 时,界面说明“下次语音会话生效”;当前 Lease 继续运行。 +6. 固定实例无法继续时,当前语音会话明确失败,不能取得新的全局 Runtime 继续。 + +现有 `AgentRuntimeController` 的可变 `current` Slot 会在 `replace()` 后中断活动请求,因此 +不能直接作为长期 Voice Session Lease。实现前必须增加引用计数式 Pin/Lease,或由 +`SelectedRuntimeManager` 为会话持有独立 Runtime Slot;会话结束后再 +`releaseConversation()` 并释放 Lease。 + +### 9.2 事件 + +控制事件至少包括: + +- `session-preparing` +- `session-ready` +- `session-reconnecting` +- `input-speech-started` +- `input-transcript-delta` +- `input-transcript-committed` +- `response-started` +- `response-transcript-delta` +- `response-audio-started` +- `response-interrupted` +- `response-completed` +- `approval-required` +- `tool-state` +- `usage` +- `error` +- `session-ended` + +音频帧使用独立二进制协议,包含: + +- `sessionId` +- `generationId` +- `sequence` +- `timestampSamples` +- `sampleRate` +- `channels` +- `encoding` +- `payload` + +帧乱序、重复、跨会话或超过大小上限时直接拒绝,不尝试解释为其他格式。 + +--- + +## 10. 状态模型 + +全双工不能只用一个“正在听/正在说”枚举描述。会话使用三个正交状态: + +```text +Lifecycle: +idle → preparing → active ↔ reconnecting → ended + └──────────────→ failed + +Input: +muted ↔ listening ↔ speech + +Output: +idle → generating → playing → interrupting → idle +``` + +用户可见状态由三个状态组合得出。合法示例: + +- `input=listening + output=playing`:助手说话,同时继续监听。 +- `input=speech + output=interrupting`:用户抢话,助手正在停止。 +- `lifecycle=reconnecting + input=muted + output=idle`:当前引擎重连,停止上传。 + +`awaiting-approval` 是运行阻塞原因,不关闭会话;此时输入可以继续听取取消或补充文本, +但不能把口头内容解释成授权。 + +--- + +## 11. Turn、文本与播放提交 + +### 11.1 用户输入 + +- 流式 ASR Delta 只用于界面。 +- Endpoint Detector 确认轮次后产生 committed transcript。 +- 空白、纯噪音和低置信度片段不创建用户消息。 +- 用户可在提交前通过键盘修正;修正结果而非原始猜测进入 Agent Runtime。 + +### 11.2 助手输出 + +模块化 TTS 可能落后于文本生成,因此助手文本分为: + +- `generated`:模型已生成,尚未安排播放。 +- `queued`:已生成音频,尚未播放。 +- `played`:对应音频已从播放时钟确认输出。 + +助手消息需要区分“用户可见历史”和“下一轮模型上下文”: + +- 所有已展示的有界文本和结构化内容都写入可见消息历史。 +- 可朗读文本记录 `generated`、`queued`、`played` 边界;中断后的消息标记为 + `interrupted`,并保留用户已经看见的内容及已播放边界。 +- 下一轮模型上下文只包含 `played` 可朗读文本,以及已经展示的 `visual-only` 内容。 +- 尚未播放的可朗读尾部即使曾临时显示,也不回送模型,并在历史中显示“未播完”状态。 + +代码块、表格、URL、引用和工具结果等不适合逐字朗读的内容使用 `visual-only` Block。它们 +一旦完整展示即可进入可见历史和下一轮上下文,不受语音播放边界裁切。这样既不会丢失用户 +已经看到的详细成果,也不会让模型误以为用户听到了被打断的语音尾部。 + +云端 Provider 支持会话截断时,Main 使用实际播放位置截断远端 Conversation Item; +不支持时由 GoodBuddy 在下一轮上下文中只组装 `played` 和已展示的 `visual-only` 部分。 +现有消息契约与上下文组装器需要增加对应 Block 状态,不能用删掉完整助手消息来模拟截断。 + +### 11.3 文本转语音规划 + +模块化引擎从流式文本中产生可取消的短语块: + +- 优先在中文标点、英文句界和自然从句边界提交。 +- 首个短语不等待完整回答,以降低首音频延迟。 +- URL、Markdown 标记、代码块、表格、引用编号和工具 JSON 不逐字符朗读。 +- 不能可靠口述的内容在界面展示,并使用确定性短提示说明“详细内容已显示在对话中”。 +- 不调用第二个未选择的模型生成“语音摘要”。 + +--- + +## 12. 引擎设计 + +### 12.1 本地模块化全双工 + +首个跨平台本地基线复用现有 `sherpa-onnx`: + +```text +AudioWorklet +→ Silero/TEN VAD +→ sherpa-onnx OnlineRecognizer +→ selected AgentRuntime +→ deterministic speech text planner +→ sherpa-onnx TTS callback +→ AudioWorklet playback +``` + +现有 `sherpa-onnx` Node Addon 已提供在线识别、VAD、本地 TTS 和 TTS 音频回调。当前已安装 +的 SenseVoice、Paraformer 和 Whisper 目录主要用于离线识别;实时模式需要独立的在线 +模型目录和能力声明,不能把离线模型误标成流式模型。 + +“本地模块化”只保证音频采集、ASR 和 TTS 在本机。中间 Agent Runtime 是否本地取决于 +用户明确选择的模型连接: + +- 连接到 loopback 本地模型时,完整链路可离线。 +- 连接到云端文本模型时,原始音频留在本地,但最终转写文本和 Agent 上下文会发送到 + 该模型。界面必须明确显示这一数据路径。 + +不得因当前文本 Runtime 不可用而替换为另一模型连接。 + +### 12.2 本地原生全双工 + +本地原生 Adapter 面向 MiniCPM-o、Moshi/PersonaPlex、BayLing-Duplex 等能够持续接收并 +生成音频的模型。具体模型接入前必须逐个验证: + +- 中文和目标语言质量。 +- 真正的持续输入、Barge-in 和 Backchannel,而不只是流式输出。 +- 首音频延迟和长期运行内存。 +- Windows、macOS、Linux 及 x64/arm64 Runtime 可用性。 +- NVIDIA CUDA、Apple Silicon 或 CPU 的真实硬件要求。 +- 工具调用、系统指令、上下文长度和取消支持。 +- 模型、声音、训练数据与商业分发许可。 +- 权重下载、ZIP 迁移、校验和、磁盘占用和卸载。 + +本地原生模型不作为六平台默认能力。只有能力检测和一次真实会话测试通过后才允许选择。 +缺少结构化工具能力的模型可以声明为 Ask-only;Execute 入口必须阻塞并说明原因,不能暗中 +调用另一个文本模型补齐工具。 + +### 12.3 云端原生全双工 + +云端 Adapter 可以面向 OpenAI Realtime、Gemini Live、Qwen Realtime、Azure Voice Live +等正式配置。每个 Adapter 必须显式声明: + +- WebRTC 或 WebSocket 传输。 +- 输入输出音频格式。 +- VAD、Semantic Turn Detection 和手动提交能力。 +- 响应取消、音频截断和实际播放对齐能力。 +- 输入与输出转写能力。 +- 工具调用和 sideband 控制能力。 +- 会话时长、上下文、速率限制和费用。 +- 可用地域、数据处理与保留说明。 + +Provider 配置不使用泛化“OpenAI compatible”推断 Realtime 能力。普通 Chat Completions +Endpoint 不能因为 URL 相似就被标记为实时语音。 + +--- + +## 13. 音频处理 + +### 13.1 采集 + +- 浏览器设备通常以 44.1 或 48 kHz 采集,不能假定请求值就是实际值。 +- 使用 `MediaStreamTrack.getSettings()` 记录实际声道、采样率和回声消除状态。 +- AudioWorklet 以原始设备时钟采集,再按引擎要求转换为 16/24/48 kHz。 +- 默认单声道 Float32 内部格式,边界处转换为 PCM16、Opus 或 Provider 指定格式。 +- 每帧 10–20 ms,带序号和采样时间,不使用墙钟猜测播放位置。 + +### 13.2 回声与抢话 + +回声处理使用两层信号: + +1. Chromium AEC/NS/AGC 处理后的麦克风流。 +2. GoodBuddy 已知的播放活动、播放能量和 VAD 结果。 + +只有满足最短语音持续时间、能量和回声相关性条件时才触发 Barge-in。阈值必须可测试, +不能仅依赖一个 Provider 的 `speech_started` 事件。Provider 事件作为权威轮次信号之一, +本地快速 VAD 负责先静音。 + +### 13.3 播放与背压 + +- 每个响应使用独立 `generationId`,旧响应帧不得进入新队列。 +- 播放队列按采样时钟排序,禁止无限积压。 +- 达到高水位时对上游施加背压;无法背压的 Provider 丢弃会话并报告协议错误,不能持续 + 增长内存。 +- 音频缺口使用短静音或 Provider 编解码恢复,不重复上一段语音。 +- 切换输出设备、设备丢失或系统休眠时暂停提交时钟,避免把未播放文本标记为已听到。 + +--- + +## 14. 数据与持久化 + +### 14.1 默认保存 + +- Voice Session ID、Conversation ID 和时间。 +- 无凭据的引擎快照及其摘要哈希。 +- 最终用户文本、已展示的助手消息、`visual-only` Block、实际播放边界和中断状态。 +- 中断、失败、取消和完成状态。 +- 有界延迟、音频中断和用量指标。 +- 工具与审批事件继续进入现有任务和活动记录。 + +### 14.2 默认不保存 + +- 原始麦克风音频。 +- Provider 返回但尚未播放的音频。 +- 临时 ASR Delta。 +- 长期或临时 API Key、Cookie、会话 Token。 +- Provider 原始错误正文和可能包含用户内容的网络帧。 +- 回声参考信号、设备唯一标识和完整声学特征。 + +未来若提供录音留存,必须是独立、默认关闭的功能,说明保存位置、期限、大小、导出和删除, +并与“改进模型”授权分离。 + +### 14.3 崩溃恢复 + +应用启动时将未结束的 Voice Session 标记为 `interrupted`。恢复文本 Conversation, +但不自动重新打开麦克风、不自动连接 Provider,也不重播未完成音频。 + +--- + +## 15. 错误、重连与资源回收 + +| 场景 | 行为 | +| --- | --- | +| 麦克风权限拒绝 | 阻塞启动,保留引擎选择,提供系统权限说明 | +| 输入/输出设备消失 | 立即静音或暂停,要求用户处理设备,不改用未选择设备 | +| 本地模型缺失或损坏 | 阻塞启动,进入模型管理,不连接云端 | +| 本地 Runtime 启动失败 | 在有界预算内重启同一 Runtime,之后明确失败 | +| 云端认证或地域错误 | 明确失败,保留配置,不尝试其他 Provider/地域 | +| 短暂断网 | 同一引擎有界重连,超过 500 ms 显示状态 | +| Provider 限流或余额不足 | 结束生成并显示原因,不切本地模型 | +| Agent Runtime 失败 | 终止当前轮次,允许重试同一 Runtime,不换连接 | +| TTS 失败 | 当前轮次失败,不静默改成系统 TTS 或仅文本成功 | +| 工具等待审批 | 暂停响应,保留会话;拒绝后把结果返回当前引擎 | +| 应用退出/窗口销毁 | 取消请求、停止 Track、关闭 Port/PeerConnection、终止 Sidecar | +| 系统休眠/锁屏 | 停止采集和上传;恢复后要求用户显式继续 | + +每个会话必须有最大时长、最大连续无声时间、最大媒体队列、最大临时文本、最大重连次数和 +最大诊断大小。取消优先于重连和重试。 + +--- + +## 16. 安全与隐私 + +1. 只允许可信主窗口主 Frame 创建和控制 Voice Session。 +2. 麦克风权限只放行音频,不因实时语音放开视频。 +3. 任何音频采集都需要用户操作;活动期间持续显示应用内状态和系统麦克风指示。 +4. 云端会话在开始前显示 Provider、地域、发送内容和可能费用。 +5. API Key 只在 Main 的加密设置或受控环境变量中使用。 +6. 临时 Provider 凭据具有最短可行期限、最小能力和单会话作用域。 +7. Provider 工具调用必须回到 Main 的白名单、Schema、Ask/Execute 和审批边界。 +8. 本地 Sidecar 只监听 loopback,使用短期认证,不开放外部端口。 +9. 模型权重按受信任目录、固定来源、大小和 SHA-256 校验,导入 ZIP 防止路径穿越和压缩炸弹。 +10. 日志只记录状态、耗时、错误分类和匿名引擎 ID,不记录语音正文和音频。 +11. 窗口隐藏时若会话仍活动,托盘必须持续显示麦克风状态和停止入口;首期可以选择隐藏即 + 暂停,但不能隐藏后无提示继续采集。 +12. Voice Session 不扩大项目、知识库、文件、浏览器或桌面控制范围。 + +--- + +## 17. 性能与质量指标 + +### 17.1 交互指标 + +| 指标 | 目标 | +| --- | --- | +| 用户开口到本地 VAD 检出 | P95 ≤ 100 ms | +| Barge-in 检出到扬声器静音 | P95 ≤ 150 ms | +| 播放队列常态深度 | 100–400 ms | +| 用户轮次结束到临时文本稳定 | P50 ≤ 300 ms | +| 用户轮次结束到首段助手音频 | 云端/原生引擎 P50 ≤ 800 ms;模块化引擎 P50 ≤ 1,200 ms | +| 已提交文本与实际播放偏差 | ≤ 100 ms 或一个最小短语块 | +| 连续 30 分钟会话 | 无未界定内存增长、重复播放或资源泄漏 | + +本地指标必须注明测试硬件,不能把高端 GPU 结果宣传为 CPU 基线。未达到所选引擎声明的 +实时系数时,能力检测应标记为不满足实时要求,而不是静默切到更小模型。 + +### 17.2 质量指标 + +- 中文普通话、英文和中英混合词的 ASR 错误率。 +- 长停顿、语气词、短回答和自我修正的轮次准确率。 +- 扬声器回声、键盘声、音乐和旁人说话下的误打断率。 +- 真正用户抢话的漏检率和停止延迟。 +- TTS 首段延迟、断句、数字、日期、英文缩写和代码术语可懂度。 +- 中断后下一轮上下文不包含未播放内容。 +- Provider、模型、数据位置和能力从不发生未声明变化。 + +--- + +## 18. 测试策略 + +### 18.1 自动化 + +- Voice Contract Schema、大小边界和迁移测试。 +- 三组正交状态及非法状态组合测试。 +- 有序、乱序、重复、迟到和跨 Session 音频帧测试。 +- Barge-in 对播放、Provider、Agent、TTS 和持久化的取消传播测试。 +- 临时文本、已提交文本和播放位置对齐测试。 +- 同一引擎重连预算与超时测试。 +- “禁止静默降级”矩阵测试:任何 Adapter、Provider、模型、地域或 Runtime 变化都必须失败。 +- Ask 只读和 Execute 审批测试。 +- 窗口销毁、应用退出、休眠和设备丢失的资源释放测试。 +- 不持久化音频、临时 Token 和 Provider 原始正文的数据库测试。 + +### 18.2 模拟与声学测试 + +建立确定性 Fake Voice Engine,能够注入: + +- 固定节奏的输入、文本和音频。 +- 网络抖动、丢包、重复和断开。 +- 超前文本、迟到音频和错误播放位置。 +- 用户抢话、回声、短噪音和长停顿。 +- 工具调用、审批、拒绝和取消。 + +真实声学测试使用预录双声道夹具,一路作为助手扬声器参考,一路作为用户麦克风输入。 +不能只通过静态单段 WAV 验证全双工。 + +### 18.3 手动与外部调用 + +- 六个发布目标分别验证麦克风权限、采集、播放、设备拔插和应用退出。 +- 本地模型在声明的最低硬件上完成 30 分钟稳定性和实时系数测试。 +- 云端 Provider 测试会产生外部调用和费用,只在明确授权的 gated 测试中运行。 +- 每个云端 Adapter 至少验证一次真实音频输入、真实音频输出、打断和工具审批。 +- 真实测试失败时不使用配置握手成功替代生成验证。 + +--- + +## 19. 跨平台交付 + +### 19.1 基线 + +- 本地模块化引擎作为 Windows、macOS、Linux x64/arm64 的统一功能基线。 +- 在线 ASR、VAD 和轻量 TTS 权重不内置,继续使用按需下载和 ZIP 离线迁移。 +- GoodBuddy 托管模型的下载遵守 + [平台功能页签与模型下载源设计](./model-download-source-design.md),使用用户显式选择的 + ModelScope 或 Hugging Face,失败时不切换来源。 +- 云端 Adapter 在六个平台复用同一契约,并分别验证 Electron WebRTC/WebSocket 行为。 +- 本地原生引擎按 Adapter 声明平台与硬件,不伪装成全平台能力。 + +### 19.2 硬件能力等级 + +| 等级 | 目标 | +| --- | --- | +| CPU 基线 | 本地模块化 ASR/TTS;文本 Runtime 可以本地或云端 | +| Apple Silicon | 可增加 MLX/Metal 本地原生 Adapter,必须单独验证 | +| NVIDIA GPU | 可增加 CUDA 本地原生 Adapter,按显存和驱动验证 | +| 不满足要求 | 引擎卡片显示不可用与原因,不自动选择其他引擎 | + +安装包继续保持轻量。大模型权重、CUDA Runtime 和独立 Python 环境不得无条件加入全部 +发布包。 + +--- + +## 20. 分阶段实施 + +### 阶段 0:契约与模拟器 + +- 新增 Voice Contracts、状态机和 Fake Voice Engine。 +- 建立禁止静默降级测试矩阵。 +- 建立会话快照、事件和诊断结构。 + +### 阶段 1:Renderer 音频平面 + +- AudioWorklet 采集与播放。 +- 媒体 `MessagePort`、背压和播放时钟。 +- 快速 VAD、回声关联、Barge-in 和设备生命周期。 +- 实时语音控制区和可访问状态。 + +### 阶段 2:本地模块化基线 + +- 在线 ASR 和 VAD 模型管理。 +- Agent Runtime 流式文本桥。 +- 本地 TTS 模型管理、短语规划、音频回调和取消。 +- 最终文本持久化与工具审批。 +- 六个平台/架构验证。 + +### 阶段 3:首个云端原生 Adapter + +- Main-only 凭据和引擎 Profile。 +- WebRTC 或 WebSocket 会话。 +- 转写、音频、截断、用量和 Provider 错误。 +- sideband 工具与审批。 +- 真实有费用的 gated 验证。 + +### 阶段 4:本地原生全双工 Adapter + +- 选择一个中文质量、许可和硬件要求已验证的模型。 +- 建立受管 Sidecar、能力检测和真实会话测试。 +- 验证原生 Barge-in、文本提交、工具能力和长期稳定性。 + +### 阶段 5:扩展与质量 + +- 增加经过验证的云端和本地 Adapter。 +- 输出设备选择和企业语音策略。 +- 声学基准、延迟仪表盘和成本诊断。 +- 评估是否允许用户预配置仍需确认的显式替代策略。 + +--- + +## 21. 首个垂直切片 + +首个可合并实现应使用 Fake Voice Engine,不立即绑定某个云端 Provider: + +1. 用户显式开始会话。 +2. AudioWorklet 持续采集和播放模拟流。 +3. Fake Engine 产生临时转写、助手文本和音频。 +4. 用户开口触发 150 ms 内静音和响应取消。 +5. 持久化已提交用户文本、已展示助手内容、`visual-only` Block 和实际播放边界;下一轮 + 模型上下文只使用已播放文本与完整显示的 `visual-only` Block。 +6. 模拟工具审批时暂停语音,拒绝口头授权。 +7. 注入 Adapter 失败后明确结束,不切换任何引擎。 +8. 关闭窗口后所有 Track、Port、计时器和模拟任务归零。 + +该切片先验证最难改变的会话、音频、提交和安全契约,再分别接入本地和云端实现。 + +--- + +## 22. 验收标准 + +- 用户可以明确选择本地模块化、本地原生或云端原生引擎,界面持续显示当前选择。 +- 会话快照冻结 Provider、模型、地域、声音、数据位置和能力。 +- 任何引擎、Provider、模型、地域、Runtime 或模式变化都不能在测试中静默发生。 +- 助手播放期间继续采集麦克风,用户可在 P95 150 ms 内打断。 +- 中断后未播放音频与文本不进入下一轮上下文。 +- Ask 和 Execute 在语音中与文本中使用同一权限和审批边界。 +- 语音口令不能批准工具。 +- 云端长期凭据不进入 Renderer,本地 Sidecar 不监听外部地址。 +- 默认数据库、日志和 Artifact 中没有原始音频。 +- 本地引擎失败不连接云端,云端引擎失败不启动本地模型。 +- 重连只针对同一引擎快照,并在可感知时显示状态。 +- 六个平台目标完成各自声明能力的真实采集、播放、取消和资源回收验证。 +- `npm test`、`npm run typecheck`、`npm run lint` 和生产构建全部通过。 + +--- + +## 23. 参考 + +- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx):本地在线/离线 ASR、VAD 与 TTS。 +- [OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime):云端实时音频会话与 WebRTC/WebSocket。 +- [Gemini Live API](https://ai.google.dev/gemini-api/docs/live-api):云端双向实时音频与多模态会话。 +- [Qwen Realtime](https://help.aliyun.com/zh/model-studio/realtime):云端实时音视频输入与音频/文本输出。 +- [MiniCPM-o](https://github.com/OpenBMB/MiniCPM-V):本地端到端多模态与全双工候选。 +- [Moshi](https://github.com/kyutai-labs/moshi):本地原生全双工语音模型框架。 +- [PersonaPlex](https://github.com/NVIDIA/personaplex):本地可控角色与声音的全双工候选。 +- [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet):Renderer 低延迟音频处理基础。 diff --git a/docs/architecture/local-text-embedding-model-design.md b/docs/architecture/local-text-embedding-model-design.md new file mode 100644 index 0000000..80ff421 --- /dev/null +++ b/docs/architecture/local-text-embedding-model-design.md @@ -0,0 +1,1188 @@ +# GoodBuddy 本地文本向量模型与连接设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档类型 | 跨功能技术与产品架构 | +| 状态 | 设计中 | +| 版本 | 0.1 | +| 日期 | 2026-08-19 | +| 适用产品 | GoodBuddy 桌面端 | +| 目标平台 | Windows、macOS、Linux,x64 与 arm64 | +| 相关基线 | [知识库检索与分块增强 PRD](../prd/knowledge/knowledge-rag-enhancement-prd.md)、[模型下载源设计](./model-download-source-design.md)、[知识检索评估](../quality/knowledge-retrieval-evaluation.md)、[跨平台助手产品设计](../design/cross-platform-assistant-product-design.md)、[统一界面设计系统](../../UI-DESIGN.md) | + +本文定义 GoodBuddy 的文本向量模型产品形态,包括应用托管的轻量本地模型、用户自行安装的 +Ollama 或其他自托管服务、云端 OpenAI 兼容服务、模型管理界面、进程边界、编码契约、 +索引兼容性、失败状态和验收方法。 + +本文所称“支持多种连接”是指用户可以显式选择不同向量执行路径,不代表应用可以在它们 +之间自动切换。**GoodBuddy 不设计静默替换向量 Provider、模型、精度、数据位置或编码 +方式。** + +--- + +## 1. 摘要与核心决策 + +1. GoodBuddy 提供三种明确的向量执行路径: + - **应用托管本地模型**:由 GoodBuddy 下载、校验、安装并在受控进程中运行。 + - **Ollama / 自托管服务**:由用户自行安装和管理服务及模型,GoodBuddy 只连接用户 + 明确填写的 Endpoint 和模型。 + - **云端兼容服务**:使用用户明确配置的 OpenAI 兼容 Embeddings Endpoint。 +2. 首个应用托管默认模型选择 + `ibm-granite/granite-embedding-97m-multilingual-r2` 的固定修订和官方 INT8 ONNX + 工件。它是目录中的“推荐”模型,不在未获用户操作时自动下载或启用。 +3. 高性能设备或更高准确性需求不通过应用内自动路由解决。用户可以自行安装 Ollama, + 显式选择 Qwen3-Embedding、BGE-M3、其他 BGE 或其认可的向量模型。 +4. Ollama 模型、云端模型和应用托管模型没有回退顺序。当前路径不可用时,向量通道明确 + 标记不可用;GoodBuddy 不自动换到另一路径或另一模型。 +5. 向量通道失败时,全文、中文 CJK 和图谱通道可以按已保存检索设置继续工作,但界面必须 + 显示“未使用向量检索”、失败原因和实际使用通道。这是可见的检索通道降级,不是向量 + Provider 回退。 +6. 模型权重不随安装包交付。下载使用固定修订、已知字节数和 SHA-256;内网环境支持 + ZIP 导入、导出。 +7. 应用托管推理不在 UI Renderer 或 Main 事件循环内执行。Main 通过受控推理进程调用 + ONNX CPU Runtime,Renderer 只展示状态和发起经过校验的操作。 +8. 文档和查询编码必须具有显式角色。Tokenizer、特殊 Token、Pooling、归一化、前缀、 + 最大序列长度、精度和输出维度共同构成可复现的 `EmbeddingEncodingRecipe`。 +9. Provider Fingerprint 必须覆盖完整编码配方和数据路径。任一兼容性字段变化后,旧向量 + 不参与新请求,用户需要显式重建。 +10. 保存模型切换不自动重建所有知识库。界面先说明受影响的知识库数量,再由用户逐库或 + 显式批量发起重建。 + +--- + +## 2. 背景与当前基础 + +### 2.1 当前已经具备的能力 + +GoodBuddy 当前已经具备: + +- `EmbeddingProvider`、诊断、取消、超时和批量 Embeddings 调用。 +- OpenAI 兼容 Embeddings Endpoint、模型名称和可选 API Key 设置。 +- Main-only 凭据读取和系统安全存储。 +- Provider、Model、维度和 Fingerprint 参与的索引隔离。 +- Float32 SQLite BLOB 存储、向量模长和有界分页余弦扫描。 +- 文档级暂存与原子替换、失败和取消后的旧索引保护。 +- 每个知识库的向量覆盖率、重建任务和检索通道诊断。 +- 本地 OCR 与本地语音模型已经使用的受管模型目录、固定下载、SHA-256、ZIP 迁移和 + 操作进度模式。 +- `onnxruntime-web` 及 Renderer OCR Worker 证明的 ONNX/WASM 打包基础。 + +当前实现仍有以下限制: + +- 只有一个全局 OpenAI 兼容向量连接,没有应用托管的本地向量模型。 +- 设置界面把“本地 loopback 服务”和“云端服务”表示为同一 Endpoint 表单,数据路径 + 不够直观。 +- `EmbeddingProvider.embed()` 没有区分 Query 和 Document 角色。 +- Fingerprint 主要由 Endpoint 和模型名称组成,尚未覆盖 Tokenizer、Pooling、归一化、 + 精度和角色格式。 +- 当前分块按字符计数,Provider 可能按 Token 截断,应用无法证明实际编码内容完整。 +- 模型变更后的受影响知识库数量和重建影响未在全局设置中集中显示。 +- “失败时自动回退”的设置文案没有准确表达实际使用通道,容易让用户误解为应用会自动 + 切换向量模型。 + +### 2.2 当前存储和搜索约束 + +向量以 Float32 保存,单条向量的纯数据成本为: + +| 维度 | 每条向量 | 10 万条向量,不含 SQLite 开销 | +| --- | ---: | ---: | +| 384 | 1,536 字节 | 约 146.5 MiB | +| 512 | 2,048 字节 | 约 195.3 MiB | +| 768 | 3,072 字节 | 约 293.0 MiB | +| 1,024 | 4,096 字节 | 约 390.6 MiB | + +当前向量召回采用 CPU 线性余弦扫描。默认模型不仅要考虑权重大小,还要考虑每个知识库的 +长期索引大小和每次查询的乘加量。因此 384 维比 768 或 1,024 维更适合作为通用桌面端 +默认值。 + +当前知识分块默认约 1,600 字符,父子模式的子块默认约 900 字符,允许的单块上限更高。 +只有 512 Token 上下文的模型可能截断常见中文分块,不能在不调整分块或明确报错的情况下 +作为默认模型。 + +--- + +## 3. 目标 + +### 3.1 用户目标 + +- 不安装其他服务即可按需获得可用的本地语义检索。 +- 清楚知道向量计算发生在本机、局域网自托管服务还是云端。 +- 在高性能硬件上自行选择 Ollama 中的 Qwen3-Embedding、BGE 等模型。 +- 在断网设备上通过 ZIP 导入应用托管模型。 +- 切换模型前知道哪些知识库会失去兼容向量,以及接下来需要做什么。 +- 查看模型下载、校验、加载、真实推理测试和索引重建的独立状态。 +- 失败时得到准确原因,不被应用暗中换模型、换精度或上传数据。 + +### 3.2 产品目标 + +- 提供一个六平台共同支持的轻量本地默认向量能力。 +- 复用现有知识索引、模型管理、安全存储、任务中心和通知体系。 +- 保持应用安装包不包含模型权重,避免无条件增加下载和安装体积。 +- 对本地、Ollama、自托管和云端连接使用同一上层 Provider 和索引兼容契约。 +- 让模型升级、量化变化和角色格式变化都可见、可诊断、可重建。 +- 保持 Renderer 无 Node 集成,不向 Renderer 暴露数据库、模型目录或长期凭据。 + +### 3.3 质量目标 + +- 应用托管模型对当前中文、中英文混合和跨语言检索样例具有稳定收益。 +- 同一模型包、编码配方和输入在同一 CPU Runtime 下产生可复现的向量维度和近似值。 +- UI 线程不执行 Tokenization、ONNX 推理或大向量序列化。 +- 取消、应用退出和模型切换不会留下半安装目录、孤立进程或半替换索引。 +- 任何向量不可用、索引不兼容或输入过长都进入有界诊断,不能表现为正常的空向量结果。 + +--- + +## 4. 非目标 + +首期不包含: + +- 把 Granite、Qwen、BGE 或其他向量权重直接打入 GoodBuddy 安装包。 +- 由 GoodBuddy 自动安装 Ollama、执行 `ollama pull` 或管理 Ollama 服务生命周期。 +- 根据硬件、网络、延迟或准确率自动选择应用托管模型、Ollama 或云端模型。 +- 在 Ollama 不可用时自动连接云端,或在云端失败时自动加载应用托管模型。 +- 在模型失败时自动改用另一个模型名称、量化档位、输出维度或 Query 前缀。 +- 为 Ollama 模型质量作统一保证。用户选择的具体模型需要通过真实诊断和知识评测验证。 +- 首期引入专用向量数据库、GPU 索引或平台原生 SQLite 向量扩展。 +- 在后台自动更新模型版本或覆盖当前正在使用的模型包。 +- 静默截断超过模型上下文的分块。 +- 把知识库文档、查询、向量或模型凭据用于训练、遥测或外部质量分析。 + +--- + +## 5. 术语与路径分类 + +| 术语 | 定义 | +| --- | --- | +| `managed-local` | GoodBuddy 管理模型包和推理进程,推理数据不离开设备 | +| `ollama` | 用户管理的 Ollama 服务;loopback Endpoint 才标记为本机 | +| `openai-compatible` | 用户配置的通用 OpenAI 兼容 Embeddings 服务 | +| 模型包 | 固定修订的 ONNX、Tokenizer、配置、许可和校验清单 | +| 编码配方 | Query/Document 格式、Tokenizer、Pooling、归一化、精度、维度和 Token 限制 | +| Provider Profile | 用户保存的执行路径和模型配置,不含凭据正文 | +| Runtime Snapshot | 一次诊断、索引任务或查询实际使用的冻结配置 | +| Provider Fingerprint | 对执行路径和编码配方的规范化摘要,用于索引隔离 | +| 兼容索引 | Provider Fingerprint、模型、维度和内容校验均匹配的向量集合 | +| 可见通道降级 | 向量不可用时继续使用已配置的其他检索通道,并明确显示差异 | + +Ollama Endpoint 为 `127.0.0.1`、`localhost` 或 `[::1]` 时,可以标记“本机 Ollama”。 +局域网地址、VPN 地址、域名或公网地址统一标记“自托管网络服务”,不得仅因 Provider 类型 +是 Ollama 就声称数据不离开本机。 + +--- + +## 6. 模型选择 + +### 6.1 应用托管默认模型 + +首个应用托管目录只包含一个推荐模型: + +| 字段 | 值 | +| --- | --- | +| 模型 | `ibm-granite/granite-embedding-97m-multilingual-r2` | +| 参数量 | 97M | +| 输出维度 | 384 | +| 上下文 | 32K Token | +| 语言 | 200+,重点增强 52 种语言的检索 | +| 权重 | 固定修订的官方 INT8 ONNX | +| 量化权重体积 | 约 91 MB,以目录清单中的实际固定字节数为准 | +| License | Apache 2.0 | +| UI 标签 | 推荐、轻量、多语言、本地、INT8 | + +选择理由: + +1. 384 维降低当前 SQLite 线性扫描和长期索引成本。 +2. 32K 上下文覆盖 GoodBuddy 当前常见分块,不要求为了默认模型全面缩短分块。 +3. 中文、英文和混合内容可以使用同一索引,不需要按语言拆分。 +4. 官方提供量化 ONNX,适合 CPU 路径和按需下载。 +5. Apache 2.0 适合应用内提供目录元数据和用户自行下载使用。 + +“推荐”表示 GoodBuddy 的默认集成目标,不表示模型已安装、已启用或适合所有资料。用户仍需 +执行下载或导入、保存选择和真实测试。 + +### 6.2 为什么首期不托管多个默认模型 + +`BAAI/bge-small-zh-v1.5` 体积更小,但其常见部署配置是 512 维、512 Token。它可能截断 +当前默认中文分块,并增加每条索引的维度成本。首期不把它设为第二个自动候选,也不建立 +Granite → BGE 的回退关系。 + +未来只有在以下条件全部满足后,才把其他模型加入应用托管目录: + +- 固定修订和全部文件 SHA-256 已独立验证。 +- 六个平台均能加载和运行。 +- Tokenizer、Pooling、Normalization 和角色格式可复现。 +- 当前分块上限不会被静默截断,或产品已经提供明确的 Token 感知分块迁移。 +- 固定检索样例通过质量和性能门。 +- License、NOTICE 和模型仓库说明已经纳入交付清单。 + +### 6.3 高硬件或准确性需求 + +高性能用户可以自行安装 Ollama,并在 GoodBuddy 中显式选择: + +- Qwen3-Embedding 的合适参数规模。 +- BGE-M3 或其他 BGE Embedding 模型。 +- Ollama 目录中其他声明支持 Embedding 的模型。 + +GoodBuddy 不代替用户判断模型的显存、内存、速度、语言和 License。设置界面必须显示 +Ollama 返回的实际模型标识、输出维度和真实诊断结果,不能仅根据名称宣称质量更高。 + +--- + +## 7. 信息架构 + +不新增一级设置页面。入口继续为: + +```text +设置 +└─ 模型连接 + └─ 向量模型 + ├─ 应用托管 + ├─ Ollama / 自托管 + └─ 云端服务 +``` + +“模型连接”中的模型类型继续使用现有 `SegmentedControl`: + +```text +[LLM 模型] [向量模型] [重排模型] [语音输入] +``` + +向量模型内部的“应用托管 / Ollama / 云端服务”也是当前面板内的互斥模式,使用共享 +`SegmentedControl`,不使用 `PageTabs`,不新增页面专属切换样式。 + +知识库设置继续显示当前知识库的向量覆盖率和重建入口,不复制全局模型下载、Endpoint 或 +凭据编辑表单。 + +--- + +## 8. 全局设置界面 + +### 8.1 页面层级 + +向量模型面板按以下顺序组织: + +1. 启用状态和当前生效摘要。 +2. 执行路径选择。 +3. 持续可见的数据路径说明。 +4. 当前路径的配置或模型目录。 +5. 待保存变化和索引影响。 +6. 真实诊断。 +7. 模型目录和低频维护操作。 + +示意: + +```text +向量模型 +用于知识库语义检索与 GraphRAG [保存设置] + +[启用向量模型 开关] +当前生效:应用托管 · Granite Embedding 97M R2 · INT8 · 384 维 + +执行路径 +[ 应用托管 ] [ Ollama / 自托管 ] [ 云端服务 ] + +┌ 数据路径 ───────────────────────────────────────────────┐ +│ 本机知识分块 → GoodBuddy 本地推理进程 → 本地 SQLite │ +│ 模型安装完成后,推理不需要联网,文档和查询不离开本机。 │ +└────────────────────────────────────────────────────────┘ + +当前模型 +┌────────────────────────────────────────────────────────┐ +│ Granite Embedding 97M Multilingual R2 │ +│ 推荐 · 多语言 · INT8 · 384 维 · 32K · Apache 2.0 │ +│ 状态:已安装 │ +│ [设为当前模型] [导出 ZIP] [打开模型仓库] [删除] │ +└────────────────────────────────────────────────────────┘ + +配置影响 +3 个知识库没有与待保存配置兼容的向量,需要显式重建。 +[查看受影响的知识库] + +连接诊断 +最近测试:可用 · 384 维 · 178 毫秒 · 2026-08-19 10:30 +[测试待保存配置] +``` + +### 8.2 当前生效与待保存 + +界面必须同时区分: + +- **当前生效**:Main 正在用于新查询和新索引任务的已保存 Profile。 +- **正在编辑**:表单中的草稿。 +- **已验证草稿**:最近一次诊断与草稿 Fingerprint 完全匹配。 +- **待保存**:草稿与当前生效 Profile 不同。 + +用户修改路径、Endpoint、模型、精度、维度或编码预设后,旧诊断立即显示: + +> 配置已变化,需要重新测试。 + +不能把旧模型的“测试成功”继续显示在新模型旁边。 + +### 8.3 应用托管模式 + +应用托管模式显示: + +- 模型目录根路径。 +- 当前目录中的模型选择器。 +- 单个模型详情卡,不同时铺开大量卡片。 +- 下载、取消、导入 ZIP、导出 ZIP、打开目录和删除。 +- 固定版本、量化、维度、上下文、语言、下载体积、预计内存、License 和仓库链接。 +- “已安装”“待保存”“正在使用”“下载中”“校验中”“损坏”等状态。 + +首期只有一个目录条目时仍保留选择器和目录契约,为后续经评测的模型预留兼容结构。 + +未安装时主操作是“下载”,次操作是“导入 ZIP”。下载完成后可以自动把该模型选入草稿, +但不能自动保存、启用或重建知识库。 + +### 8.4 Ollama / 自托管模式 + +Ollama 模式显示: + +- Endpoint,loopback 默认建议为 `http://127.0.0.1:11434/v1/embeddings`。 +- 模型名称,例如 `qwen3-embedding:0.6b` 或 `bge-m3`。 +- 可选 API Key,只在用户的 Ollama 网关需要认证时使用。 +- 编码预设和模型最大输入 Token。 +- 数据位置:“本机 Ollama”或“自托管网络服务:主机名”。 +- “检测服务”“测试向量生成”“打开 Ollama Embeddings 文档”。 + +GoodBuddy 不显示“安装 Ollama”主操作,也不自动执行 CLI。可以提供只读说明或复制命令, +但执行安装和拉取模型必须由用户在其环境中完成。 + +当服务支持模型摘要或 Digest 时,诊断结果显示并纳入 Fingerprint。无法读取稳定 Digest +时,界面显示: + +> 服务未提供可验证的模型修订。GoodBuddy 将按 Endpoint、模型名称和编码配置隔离索引; +> 服务端原地替换同名模型后,需要重新测试并重建。 + +### 8.5 云端服务模式 + +云端模式沿用 OpenAI 兼容字段: + +- 完整 Embeddings Endpoint。 +- 模型名称。 +- API Key 和凭据来源。 +- 编码预设。 +- Provider 或账号备注,不保存凭据正文到 Profile。 + +Endpoint 下方持续显示: + +> 建立索引时会向 `api.example.com` 发送已启用知识库的分块文本;检索时会发送用户查询。 +> GoodBuddy 不会自动切换到本地模型或其他云端模型。 + +设置页不把“已配置 API Key”表示为“服务可用”。只有真实 Embeddings 请求可以产生 +“测试成功”状态。 + +### 8.6 数据路径卡 + +数据路径卡不能折叠,也不能只用“本地”“云端”徽标表达: + +| 路径 | 文案 | +| --- | --- | +| 应用托管 | 本机知识分块 → GoodBuddy 本地推理进程 → 本地 SQLite | +| loopback Ollama | 本机知识分块 → 本机 Ollama → 本地 SQLite | +| 网络自托管 | 本机知识分块 → `host:port` → 本地 SQLite | +| 云端 | 本机知识分块 → `provider host` → 本地 SQLite | + +下载模型时访问模型仓库与推理时发送文档是不同数据行为。应用托管模式应说明“下载阶段需要 +访问固定模型地址;安装后推理不联网”,不能简单写“完全不联网”。 + +### 8.7 状态 + +| 状态 | 界面表达 | 可用操作 | +| --- | --- | --- | +| 未安装 | 需要下载或导入后使用 | 下载、导入 ZIP | +| 下载中 | 文件、已下载字节、总字节、百分比 | 取消 | +| 校验中 | 正在校验大小和 SHA-256 | 取消 | +| 已安装 | 已安装,尚未使用 | 设为当前模型、导出、删除 | +| 待保存 | 选择尚未生效 | 保存设置 | +| 可用 | 路径、模型、维度、最近测试时间 | 重新测试 | +| 加载失败 | 脱敏原因和处理建议 | 重试当前配置 | +| 输入过长 | 实际 Token、模型上限和受影响文档 | 调整分块或更换模型 | +| 索引不兼容 | 索引模型与当前模型摘要 | 查看知识库、重建 | +| 有新版本 | 当前版本、新版本、下载体积 | 显式下载新版本 | +| 模型损坏 | 失败文件,不显示任意本机路径 | 重新导入或删除 | + +状态必须同时使用图标和文字,不只依赖颜色。 + +### 8.8 真实诊断 + +诊断使用固定、不含用户数据的测试文本,至少验证: + +1. Provider 可以实际产生向量。 +2. 返回数量正确。 +3. 所有向量维度一致、数值有限且范数非零。 +4. 实际维度与 Profile 声明一致。 +5. Query 和 Document 两种角色都能编码。 +6. 同一固定输入重复编码的余弦相似度满足确定性阈值。 +7. 已知最大输入范围不会被 GoodBuddy 客户端静默截断。 + +诊断结果绑定完整 Fingerprint,并显示: + +- 路径和数据位置。 +- Provider、模型、模型修订或 Digest。 +- 编码预设。 +- 实际维度。 +- 冷启动或加载耗时。 +- 单查询热路径耗时。 +- 测试时间。 +- 脱敏错误和处理建议。 + +诊断不读取用户知识库、不改变索引,也不证明真实资料的召回质量。 + +### 8.9 保存和切换确认 + +保存会改变 Fingerprint 的配置前显示确认对话框: + +> **切换向量模型?** +> +> 当前使用 `nomic-embed-text / 768 维`,将切换为 +> `Granite Embedding 97M R2 INT8 / 384 维`。 +> +> 现有向量不会参与新配置下的检索。3 个知识库需要显式重建。保存不会自动重建,也不会 +> 自动切换到其他向量模型。 + +按钮: + +- 取消。 +- 切换向量模型。 + +保存前存在运行中的向量重建任务时,界面必须说明任务将被取消并丢弃未发布的暂存结果。 +用户确认后先完成取消,再替换活动 Profile。 + +### 8.10 删除模型 + +- 当前生效的应用托管模型不能直接删除。 +- 用户必须先停用向量模型或显式选择并保存其他 Profile。 +- 删除只移除模型文件,不自动删除旧向量索引。 +- 删除确认显示模型名称、版本和磁盘空间。 +- 删除失败保留模型状态和可重试错误,不能自动改选其他模型。 + +--- + +## 9. 知识库界面 + +每个知识库的“向量索引”区块继续位于知识库设置中,显示: + +```text +向量索引 + +当前模型:Granite Embedding 97M R2 · INT8 · 384 维 +索引模型:nomic-embed-text · 768 维 + +状态:模型不兼容 +当前检索不会使用这些旧向量,也不会切换到其他向量模型。 + +已索引 42 缺失 3 错误 0 总计 45 + +[重建向量索引] +``` + +规则: + +- 当前 Profile 和索引 Fingerprint 完全匹配时才显示“兼容”。 +- 重建始终使用启动任务时冻结的 Runtime Snapshot。 +- 重建期间全局 Profile 变化时取消旧任务,不让任务后半段切到新模型。 +- 同一 Fingerprint 的重建失败时,可以继续保留上一次完整就绪索引。 +- Fingerprint 已变化时,旧索引可以保留在数据库中等待清理,但不参与当前向量召回。 +- 重建完成前,检索诊断持续显示向量通道不可用或部分可用,不把旧索引描述为当前索引。 + +全局设置中的“查看受影响的知识库”打开有界列表,显示知识库名称、兼容状态、文档数、 +预计待向量化分块数和进入该知识库设置的操作。 + +--- + +## 10. 不静默替换契约 + +### 10.1 禁止自动发生的变化 + +以下变化不得在诊断、索引任务或查询中静默发生: + +- 应用托管、Ollama、自托管网络服务和云端之间切换。 +- Endpoint、Provider、账号或数据位置切换。 +- 模型名称、模型修订、Ollama Digest 或模型文件变化。 +- INT8、FP16、FP32 或其他精度变化。 +- 输出维度变化或 Matryoshka 截断维度变化。 +- Query/Document 前缀、指令或角色格式变化。 +- Tokenizer、特殊 Token、Pooling 或归一化变化。 +- 超出模型上下文时截断、换模型或跳过部分输入后继续表示成功。 +- GPU、CPU 或执行后端变化导致可感知质量差异时继续沿用旧 Fingerprint。 + +### 10.2 允许的内部恢复 + +以下操作可以自动执行,但必须保持同一 Runtime Snapshot 和语义: + +- 同一应用托管模型进程的有限重启。 +- 同一 Endpoint、模型、Digest 和编码配方的有限网络重试。 +- 动态批大小、线程调度、缓存和空闲卸载等不改变向量语义的资源优化。 +- 同一索引任务内对可重试批次进行有界重试。 + +恢复预算耗尽后当前操作失败。诊断提供“重试当前配置”,不提供默认选中的替代模型。 + +### 10.3 与其他检索通道的关系 + +FTS、CJK、向量和图谱是用户已配置的并列检索通道。向量失败时继续使用 FTS、CJK 或图谱 +不等同于切换向量 Provider,但必须满足: + +- 请求通道和实际使用通道都可见。 +- 向量失败原因可见。 +- 结果不得标记为完整混合检索成功。 +- 不自动修改保存的向量权重。 +- 不自动扩大知识库范围。 +- 不自动生成或使用其他模型的查询向量。 + +推荐设置文案: + +> 向量模型不可用时,本次检索将明确标记“未使用向量检索”,并显示实际使用的全文、中文 +> 或图谱通道。GoodBuddy 不会自动切换到其他向量模型或数据路径。 + +--- + +## 11. 总体架构 + +```text +┌──────────────────────── Renderer ────────────────────────┐ +│ Settings / Model catalog / Diagnostics / Index status │ +│ No credentials, no model paths, no database access │ +└──────────────── typed preload + validated IPC ───────────┘ + │ +┌────────────────────────── Main ───────────────────────────┐ +│ EmbeddingProfileStore │ +│ EmbeddingModelManager │ +│ EmbeddingProviderFactory │ +│ KnowledgeService / EmbeddingIndexCoordinator │ +│ Credentials / fingerprint / task lifecycle │ +└───────────────┬──────────────────────┬────────────────────┘ + │ │ + Managed utility process HTTP(S) provider + Tokenizer + ONNX CPU Ollama / self-hosted / cloud + │ + Managed model directory + manifest + fixed artifacts +``` + +### 11.1 Renderer + +Renderer 负责: + +- 显示目录、下载进度、当前/草稿 Profile 和数据路径。 +- 发起下载、导入、导出、选择、删除、诊断和重建。 +- 展示知识库影响、真实维度和脱敏错误。 +- 使用应用通知显示短期成功和非字段异步错误。 + +Renderer 不负责: + +- 读取模型目录。 +- 下载或解压模型。 +- 读取 API Key。 +- 为知识库分块执行 Tokenization 或推理。 +- 决定 Provider 回退。 +- 直接访问 Ollama、云端 Endpoint 或 SQLite。 + +### 11.2 Main + +Main 负责: + +- 校验 Renderer sender 和共享 Zod 输入。 +- 管理模型目录、固定下载、ZIP 和文件摘要。 +- 保存不含凭据正文的 Profile,并在系统安全存储中绑定凭据。 +- 规范化 Endpoint 和计算 Fingerprint。 +- 创建冻结的 Provider Runtime Snapshot。 +- 选择唯一的 Adapter,不执行自动 Adapter 路由。 +- 将查询、索引、取消、超时和应用关闭传播到推理进程或 HTTP Provider。 +- 对模型切换、任务取消和知识索引状态执行一致的生命周期管理。 + +### 11.3 应用托管推理进程 + +应用托管模型运行在 GoodBuddy 管理的独立 Utility Process: + +- 不监听端口。 +- 不继承云端 API Key、Runtime 凭据或无关环境变量。 +- 只接受 Main 通过受控消息通道发送的有界请求。 +- 模型路径由 Main 从已校验 Manifest 解析,不接受 Renderer 或请求中的任意路径。 +- Tokenization、ONNX 推理、Pooling 和归一化都在该进程完成。 +- 每个请求包含 UUID、角色、文本、Fingerprint 和取消标识。 +- 返回有界 Float32 向量和诊断,不返回内部文件路径或原始日志。 +- 崩溃后可以在同一模型和同一配置下有限重启;超过预算后明确失败。 +- 应用退出时终止完整进程,并等待有界时间释放模型句柄。 + +初始推理后端固定为 ONNX CPU。实现阶段必须验证所选 ONNX JavaScript/Native Runtime 在 +六个平台的打包、许可、算子、线程和退出行为;某个平台未通过时,该平台显示“不支持此 +本地模型”,不能改连 Ollama 或云端继续。 + +现有 Renderer OCR Worker 不直接复用,因为知识分块不应为了推理被批量发送到 UI +Renderer。可以复用其 ONNX 资源固定、Worker 消息边界和有界错误经验。 + +### 11.4 HTTP Provider + +Ollama、自托管和云端首期复用 Main 中的 OpenAI 兼容 Embeddings Client,并保持: + +- HTTP(S) 协议限制。 +- Endpoint 长度和规范化。 +- 禁止重定向。 +- 批量数量、单项字符、总字符、超时和响应字节上限。 +- 返回数量、索引顺序、维度、有限值和非零范数校验。 +- 取消传播和脱敏错误。 + +Ollama 的服务检测、模型 Digest 和上下文元数据可以使用独立的 Ollama Adapter,但最终 +Embedding 调用仍遵守统一 `EmbeddingProvider` 契约。 + +--- + +## 12. 编码配方 + +### 12.1 Query 与 Document 角色 + +现有 `EmbeddingProvider.embed(string[])` 需要改为角色感知接口: + +```ts +type EmbeddingInputRole = 'query' | 'document' + +type EmbeddingInput = { + text: string + role: EmbeddingInputRole +} + +interface EmbeddingProvider { + readonly identity: EmbeddingProviderIdentity + embed( + input: readonly EmbeddingInput[], + signal?: AbortSignal + ): Promise +} +``` + +索引分块始终使用 `document`,检索问题始终使用 `query`。诊断分别发送固定的 Query 和 +Document 测试输入,不能偷用文档角色代替查询测试,也不创建第三种模型角色。 + +应用托管 Granite 使用模型包内固定的官方编码配置。Ollama 或通用服务通过用户显式选择的 +编码预设决定是否添加前缀或指令。不能只根据模型名称在后台猜测并改变输入。 + +### 12.2 编码预设 + +Profile 至少支持: + +- `symmetric`:Query 和 Document 不添加不同前缀。 +- `query-passage`:固定 Query 与 Passage 前缀。 +- `instruction-query`:Query 使用固定检索指令,Document 使用固定文档格式。 +- `managed-manifest`:完全由受信任模型包 Manifest 决定,用户不可局部修改。 + +已知 Ollama 模型可以提供推荐预设,但 UI 必须在保存前显示实际选择。自定义预设中的前缀 +有长度上限,不允许插入凭据或读取知识内容。 + +### 12.3 Tokenizer、Pooling 和归一化 + +应用托管模型必须从固定模型包读取: + +- `tokenizer.json` 及其摘要。 +- 特殊 Token ID。 +- 最大序列长度。 +- Pooling 策略。 +- 是否进行 L2 归一化。 +- 输出维度。 +- ONNX 输入和输出名称。 + +实现不得手写一个“近似 Tokenizer”,也不得只取任意输出 Tensor。运行时启动后使用固定 +公开测试向量验证配方,配置不匹配则阻止服务就绪。 + +SQLite 当前会保存模长并计算余弦相似度。即使模型输出已经归一化,Provider 仍需验证 +范数有限且非零;是否归一化继续写入 Fingerprint。 + +### 12.4 输入长度 + +- 每个 Profile 声明 `maximumSequenceTokens`。 +- 应用托管路径在发送 ONNX 前完成 Token 计数。 +- 批处理同时受最大项目数和最大总 Token 数限制。 +- 单条输入超过上限时返回 `input_too_long`,不截断后继续表示成功。 +- 已知模型上下文小于当前知识库分块时,设置和知识库页面持续显示不兼容提示。 +- 用户可以调整分块并显式重建,或显式选择上下文更长的模型。 + +通用远端服务无法提供 Tokenizer 或上下文上限时,Profile 显示“输入上限由服务端决定”。 +GoodBuddy 继续执行客户端字符上限,但不声称已经证明服务端不会截断。真实诊断只能证明 +请求成功,不能证明服务端完整使用了全部文本。 + +--- + +## 13. 共享契约 + +建议扩展 `src/shared/embedding-contracts.ts`: + +```ts +type EmbeddingProviderKind = + | 'managed-local' + | 'ollama' + | 'openai-compatible' + +type EmbeddingDataLocation = + | { kind: 'device' } + | { kind: 'self-hosted'; endpointHost: string } + | { kind: 'cloud'; endpointHost: string; region?: string } + +type EmbeddingEncodingRecipe = { + recipeId: string + tokenizerDigest?: string + pooling: 'cls' | 'mean' | 'last-token' | 'provider-managed' + normalization: 'l2' | 'none' | 'provider-managed' + queryTemplate: string + documentTemplate: string + maximumSequenceTokens?: number + dimensions?: number +} + +type EmbeddingModelReference = { + modelId: string + revision?: string + artifactDigest?: string + serviceDigest?: string + precision?: 'int8' | 'fp16' | 'fp32' | 'provider-managed' +} + +type EmbeddingProviderProfile = { + id: string + name: string + kind: EmbeddingProviderKind + endpoint?: string + accountRef?: string + credentialRef?: string + model: EmbeddingModelReference + encoding: EmbeddingEncodingRecipe + dataLocation: EmbeddingDataLocation +} + +type EmbeddingRuntimeSnapshot = { + profile: EmbeddingProviderProfile + fingerprint: string + actualDimensions: number + createdAt: string +} +``` + +`accountRef` 和 `credentialRef` 只引用 Main 加密设置,不包含密钥正文。Endpoint 进入 +Profile 前删除用户名、密码和 Fragment;查询参数只有在 Provider API 明确需要且经过 +脱敏策略验证时才允许保留。 + +模型目录契约复用语音模型管理模式,并增加适合文本向量的文件角色: + +- `model` +- `tokenizer` +- `tokenizer-configuration` +- `model-configuration` +- `sentence-transformers-configuration` +- `license` +- `notice` + +--- + +## 14. Provider Fingerprint 与索引兼容 + +### 14.1 Fingerprint 内容 + +Fingerprint 使用规范化 JSON 计算 SHA-256,至少覆盖: + +- Provider Kind。 +- 规范化 Endpoint 和数据位置。 +- 模型 ID。 +- 固定 Revision、模型文件摘要或 Ollama Digest。 +- 精度。 +- 输出维度。 +- Tokenizer 摘要。 +- Pooling 和归一化。 +- Query/Document 模板。 +- 最大序列长度。 +- 影响向量数值的 Runtime 配置版本。 + +Fingerprint 不包含: + +- API Key。 +- 本机用户名或绝对模型目录。 +- 下载临时路径。 +- 诊断时间和延迟。 +- 不影响向量数值的 UI 名称。 + +现有 `embeddingStorageProvider()` 可以继续对 Fingerprint 做有界 SHA-256 派生,但 +Fingerprint 本身必须来自上述完整规范化结构。 + +### 14.2 兼容判断 + +一个向量只有同时满足以下条件才参与召回: + +1. Knowledge Base 匹配。 +2. Provider Storage Key 匹配。 +3. 模型标识匹配。 +4. 维度匹配。 +5. 分块内容校验和匹配。 +6. 文档级索引状态为 Ready。 + +不能因为两个模型都返回 384 维就视为兼容,也不能因为 Ollama 中模型名称相同就忽略 +Digest 变化。 + +### 14.3 模型切换 + +模型切换流程: + +1. 用户编辑候选 Profile。 +2. 使用固定测试文本真实诊断候选。 +3. 计算候选 Fingerprint 和受影响知识库。 +4. 用户确认数据路径、模型和重建影响。 +5. 取消并收尾使用旧 Profile 的活动向量任务。 +6. 原子保存新 Profile。 +7. 新查询只使用新 Fingerprint 的兼容向量。 +8. 用户显式发起知识库重建。 + +保存失败时旧 Profile 保持生效。不能出现设置显示新模型而 Main 仍使用旧模型的部分提交。 + +--- + +## 15. 模型包和目录 + +### 15.1 目录 + +建议受管目录: + +```text +/models/embedding/ +├─ granite-embedding-97m-multilingual-r2-int8/ +│ ├─ manifest.json +│ ├─ model.int8.onnx +│ ├─ tokenizer.json +│ ├─ tokenizer_config.json +│ ├─ config.json +│ ├─ modules.json +│ ├─ LICENSE +│ └─ NOTICE +└─ .staging-/ +``` + +文件名、数量、单文件大小、总大小和压缩包展开大小均有上限。Manifest 不允许绝对路径、 +父目录跳转、符号链接或可执行文件。 + +### 15.2 下载 + +- 目录只包含元数据,不包含模型权重。 +- 每个文件固定不可变 Revision、URL、字节数和 SHA-256。 +- 实际下载使用“平台功能 → 通用设置”中显式选择的 ModelScope 或 Hugging Face,完整 + 来源契约以[平台功能页签与模型下载源设计](./model-download-source-design.md)为准。 +- 下载写入随机暂存目录和 `.partial` 文件。 +- 禁止未声明重定向到其他主机。 +- 每个文件边下载边计算摘要。 +- 全部校验成功后原子重命名为最终目录。 +- 取消或失败清理本次暂存,不删除已安装版本。 +- 所选来源不可用或缺少模型时显示失败,不尝试另一个来源。 +- 两个来源必须提供相同的预期文件名、大小和 SHA-256,不在一次模型包中混合来源。 + +### 15.3 ZIP 导入与导出 + +- 导入先在暂存目录展开并验证 Manifest 和全部文件摘要。 +- 不接受 Manifest 之外的可执行文件。 +- 不接受路径穿越、符号链接、超大文件和压缩炸弹。 +- 导出只包含已验证模型包和许可文件,不包含设置、API Key、知识向量或用户数据。 +- 导入的模型 ID、Revision 和摘要必须与目录条目一致。 + +--- + +## 16. Ollama 与自托管服务 + +### 16.1 产品边界 + +GoodBuddy 对 Ollama 负责: + +- 保存 Endpoint、模型名称和编码预设。 +- 可选检测 Ollama 服务和已安装模型元数据。 +- 发送有界 Embeddings 请求。 +- 校验向量并展示真实诊断。 +- 根据 Endpoint、模型、Digest、维度和编码配置隔离索引。 + +GoodBuddy 不负责: + +- 安装、启动、停止或升级 Ollama。 +- 下载、删除或更新 Ollama 模型。 +- 修改 Ollama 的监听地址、认证、代理或 GPU 设置。 +- 判断第三方模型 License 是否适合用户用途。 + +### 16.2 显式高性能路径 + +设置页可以提供以下说明: + +> 如果设备具有更多内存或显存,或你希望自行评估更高准确性的模型,可以安装 Ollama, +> 拉取 Qwen3-Embedding、BGE-M3 等 Embedding 模型,然后在这里填写模型名称。 +> GoodBuddy 不会自动安装、更新或切换这些模型。 + +这段说明不能把任意 Qwen 或 BGE 模型标记为必然优于应用托管默认模型。真实质量以用户 +知识库评测为准。 + +### 16.3 服务检测 + +用户点击“检测 Ollama”后,Main 可以读取: + +- 服务版本。 +- 已安装模型列表。 +- 所选模型的 Digest。 +- 模型能力和可用时的上下文元数据。 + +检测结果只用于当前草稿,不自动保存或改变模型。服务返回的自由文本经过长度限制和 +脱敏,不直接写入日志或 DOM。 + +### 16.4 Endpoint 安全 + +- 默认只建议 loopback。 +- 用户填写非 loopback 地址时显示将发送文档内容的目标主机。 +- HTTPS 自托管地址按 TLS 校验,不忽略证书错误。 +- HTTP 非 loopback 地址显示明文传输警告,但不自动改写为其他地址。 +- 不跟随重定向。 +- 不把 Ollama API 响应中的模型路径或环境信息暴露给 Renderer。 + +--- + +## 17. 安全与隐私 + +### 17.1 数据处理 + +| 路径 | 文档分块 | 查询 | 向量 | 凭据 | +| --- | --- | --- | --- | --- | +| 应用托管 | Main → 本地 Utility Process | Main → 本地 Utility Process | 返回 Main,保存 SQLite | 不需要 | +| loopback Ollama | Main → 本机 Ollama | Main → 本机 Ollama | 返回 Main,保存 SQLite | 可选,Main-only | +| 网络自托管 | Main → 指定主机 | Main → 指定主机 | 返回 Main,保存 SQLite | Main-only | +| 云端 | Main → 指定云端 | Main → 指定云端 | 返回 Main,保存 SQLite | Main-only | + +默认不记录原始分块、查询正文、返回向量或 Authorization Header。诊断只保存有界统计和 +脱敏错误。 + +### 17.2 Electron 边界 + +- 保持 Context Isolation 和 Sandbox。 +- Renderer 不启用 Node Integration。 +- 所有 IPC 输入使用共享 Zod Schema。 +- Main 验证可信发送者。 +- Preload 只暴露具体模型管理和诊断方法。 +- 不向 Renderer 传递任意本机路径、Electron API、文件句柄或长期 Token。 +- Utility Process 只接受 Main 创建的私有通道。 + +### 17.3 模型供应链 + +- 模型目录元数据进入源码审查。 +- Revision、大小和 SHA-256 必须固定。 +- License、NOTICE 和上游来源随模型包导出。 +- 下载工件不执行脚本。 +- ONNX 外部数据文件必须显式列入 Manifest。 +- 模型加载前再次验证已安装 Manifest,不能只在首次下载时验证。 + +--- + +## 18. 失败与恢复 + +| 场景 | 行为 | +| --- | --- | +| 应用托管模型未安装 | 阻止启用,提供下载或 ZIP 导入 | +| 模型文件损坏 | 阻止加载,显示重新导入或删除,不连接 Ollama/云端 | +| Utility Process 崩溃 | 同一 Snapshot 有限重启,耗尽后当前操作失败 | +| Ollama 未运行 | 显示服务不可用,保留配置,不启动或改连其他服务 | +| Ollama 模型不存在 | 显示准确模型名和用户处理建议,不自动选择已安装模型 | +| 云端认证失败 | 显示认证错误,保留配置,不尝试本地模型 | +| Provider 限流或超时 | 同一 Provider 有界重试,之后明确失败 | +| 返回维度变化 | 当前请求失败,索引标记不兼容,不截断或填充向量 | +| 输入超过上下文 | 标记文档或查询失败,提示调整分块,不静默截断 | +| 部分文档向量失败 | 保留成功文档并显示覆盖率和错误数 | +| 模型切换时有重建任务 | 经用户确认后取消旧任务并丢弃未发布暂存 | +| 新 Profile 保存失败 | 旧 Profile 继续生效,草稿和错误保留 | +| 重建失败 | 同 Fingerprint 的上一版就绪索引保留;不同 Fingerprint 的旧索引不参与 | +| 应用退出 | 取消请求、停止新批次、关闭进程和文件句柄 | + +错误不得包含 API Key、完整私人文本、模型内部绝对路径或未经限制的 Provider 响应。 + +--- + +## 19. 性能与资源 + +### 19.1 推理生命周期 + +- 本地推理进程按首次诊断、查询或索引任务惰性启动。 +- 同一时间只加载当前应用托管模型。 +- 空闲达到有界时间后可以卸载同一模型,下一次使用重新加载。 +- 卸载和重载不改变 Fingerprint。 +- 查询优先于后台索引批次,避免知识重建长期阻塞交互。 +- 索引批次在查询到达时可以在批次边界让出。 + +### 19.2 批处理 + +- 使用最大项目数和最大总 Token 数双重上限。 +- 长输入单独成批。 +- 不为追求吞吐超过配置的内存预算。 +- 返回向量直接转为 Float32,避免长期保留重复 Number Array。 +- Main 与 Utility Process 使用有界可转移 Buffer 或等价二进制消息,不使用 Base64。 + +### 19.3 基线指标 + +实施评测至少记录: + +- 模型包磁盘大小。 +- 冷启动时间。 +- 单条中文 Query 热路径 P50/P95。 +- 默认 1,600 字符分块的吞吐。 +- 进程峰值 RSS。 +- 10 万条 384 维向量的数据库增量和扫描时间。 +- 查询到达时后台索引的让出延迟。 +- 取消响应时间和退出时间。 + +这些指标在 Windows、macOS、Linux 的 x64 与 arm64 原生 Runner 上记录。未达到产品门时 +显示模型在该平台不可用或继续优化同一路径,不能把云端作为隐藏的性能补偿。 + +--- + +## 20. 评测 + +### 20.1 评测层次 + +1. **契约测试**:文件、Profile、Fingerprint、IPC 和向量校验。 +2. **推理一致性测试**:固定模型包、固定输入和固定编码配方。 +3. **检索集成测试**:真实 `KnowledgeService`、SQLite 和混合检索路径。 +4. **质量评测**:中文、中英文混合、跨语言和代码标识固定样例。 +5. **平台评测**:六个平台的安装、加载、取消、退出和资源。 + +现有[知识检索评估](../quality/knowledge-retrieval-evaluation.md)中的确定性内存 Provider +用于验证生产检索管道,不代表真实模型质量。应用托管模型需要单独的可选本地评测命令, +该命令不读取用户数据库、不联网、不进入默认 CI。 + +### 20.2 固定质量样例 + +至少覆盖: + +- 中文自然语言改写和同义词。 +- 中文 Query 检索英文资料。 +- 英文 Query 检索中文资料。 +- 中英文混合产品名、缩写和版本号。 +- 精确代码标识、路径和错误码。 +- 长文档标题与正文分离。 +- 无答案问题。 +- 语义相近但事实冲突的负例。 + +指标: + +- Recall@5 和 Recall@10。 +- nDCG@10。 +- MRR。 +- 无答案错误召回。 +- 端到端延迟。 +- 索引体积。 + +### 20.3 对比组 + +- FTS + CJK。 +- 应用托管 Granite 97M R2 INT8。 +- 用户显式配置时的 Ollama Qwen3-Embedding。 +- 用户显式配置时的 Ollama BGE。 +- 当前 OpenAI 兼容 Provider。 + +Ollama 和云端组只在用户明确安装、配置并授权评测时运行。评测报告必须记录完整 +Fingerprint,不能只写模型显示名。 + +--- + +## 21. 实施阶段 + +### 阶段 1:契约和模型目录 + +- 扩展角色感知 `EmbeddingProvider`。 +- 定义 Profile、Encoding Recipe、Runtime Snapshot 和完整 Fingerprint。 +- 新增应用托管模型目录、Manifest、下载、取消、ZIP 和删除。 +- 固定 Granite 97M R2 INT8 的 Revision、字节数、SHA-256、Tokenizer 和许可清单。 + +### 阶段 2:本地推理 + +- 新增 Utility Process 和私有消息协议。 +- 实现 Tokenizer、ONNX CPU、Pooling、归一化和动态 Token 批处理。 +- 实现加载、诊断、取消、崩溃恢复和退出。 +- 完成六平台推理与打包门。 + +### 阶段 3:设置界面 + +- 把现有向量表单重组为应用托管、Ollama/自托管、云端三种显式路径。 +- 新增模型详情、下载进度、数据路径、当前/草稿状态和真实诊断。 +- 新增模型切换确认和受影响知识库入口。 +- 更正“自动回退”文案。 + +### 阶段 4:索引兼容与知识库 + +- 将完整 Fingerprint 接入索引存储和查询。 +- 冻结每个诊断、查询和重建任务的 Runtime Snapshot。 +- 显示当前模型、索引模型、兼容性和重建影响。 +- 验证模型切换、任务取消和文档级原子替换。 + +### 阶段 5:Ollama 增强 + +- 为 Ollama 增加显式服务检测、模型列表和 Digest。 +- 提供 Qwen3-Embedding、BGE 等已知模型的可见编码预设建议。 +- 保持安装、拉取、更新和硬件管理由用户负责。 +- 增加用户授权的 Ollama 质量评测入口。 + +--- + +## 22. 验收标准 + +### 22.1 产品 + +- 用户可以不安装外部服务,下载或导入应用托管 Granite 模型并完成真实诊断。 +- 未经用户操作不会下载、启用、切换或更新模型。 +- 用户可以自行安装 Ollama,并明确配置 Qwen3-Embedding、BGE 或其他 Embedding 模型。 +- 三种路径持续显示数据去向,非 loopback Ollama 不标记为“本机”。 +- 保存模型切换前显示当前模型、新模型和受影响知识库。 +- 模型切换不会自动重建知识库。 +- 向量失败时显示请求通道、实际通道和原因,不自动换模型。 +- 旧诊断不会显示在已变化的草稿旁边。 + +### 22.2 推理与索引 + +- Query 和 Document 使用显式角色。 +- 应用托管模型的 Tokenizer、Pooling、归一化、精度和维度全部来自固定 Manifest。 +- 超长输入不会被 GoodBuddy 静默截断。 +- Fingerprint 任一兼容字段变化后,旧向量不参与当前召回。 +- 384 维向量继续以 Float32 和有效模长保存。 +- 模型切换期间的活动任务按确认语义取消,不会混合两个 Provider。 +- 新重建失败不破坏同一 Fingerprint 的上一版完整索引。 + +### 22.3 安全与交付 + +- Renderer 不读取模型目录、知识数据库或长期凭据。 +- 模型下载、导入和启动都验证 Manifest、大小和 SHA-256。 +- ZIP 导入拒绝路径穿越、符号链接、可执行文件和超限内容。 +- Utility Process 不监听端口、不继承无关密钥,应用退出时正常终止。 +- 六个发布目标分别完成原生安装、模型加载、推理、取消和退出验证。 +- 模型权重不出现在 GoodBuddy 安装包或仓库中。 + +### 22.4 工程验证 + +源代码实施后必须通过: + +```text +npm test +npm run typecheck +npm run lint +npm run build +``` + +真实本地模型评测、Ollama 和云端调用使用独立门控命令,只有在模型已安装且用户明确授权时 +运行。 + +--- + +## 23. 参考资料 + +- IBM Granite 97M Multilingual R2 模型卡: + +- IBM Granite Embedding Multilingual R2 说明: + +- IBM Granite Embedding 模型仓库: + +- BGE v1/v1.5 文档: + +- BGE Small Chinese v1.5 模型卡: + +- Ollama Embeddings: + +- Ollama Embed API: + +- Ollama OpenAI 兼容 API: + +- Ollama Qwen3-Embedding: + +- Ollama BGE-M3: + diff --git a/docs/architecture/model-download-source-design.md b/docs/architecture/model-download-source-design.md new file mode 100644 index 0000000..f67c6dd --- /dev/null +++ b/docs/architecture/model-download-source-design.md @@ -0,0 +1,1009 @@ +# GoodBuddy 平台功能页签与模型下载源设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档类型 | 跨功能技术与产品架构 | +| 状态 | 已实现(语音输入与 OCR) | +| 版本 | 1.0 | +| 日期 | 2026-08-19 | +| 适用产品 | GoodBuddy 桌面端 | +| 目标平台 | Windows、macOS、Linux,x64 与 arm64 | +| 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[文档解析与本地 OCR](../prd/document-processing/document-extraction-and-local-ocr.md)、[本地文本向量模型与连接设计](./local-text-embedding-model-design.md)、[全双工实时语音交互设计](./full-duplex-voice-design.md) | + +本文定义“设置 → 平台功能”的二级页签结构,以及所有 GoodBuddy 托管本地模型共同使用的 +“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。 + +模型下载源是用户明确选择的供应链路径。当前来源不可用或没有所选模型时,GoodBuddy +必须明确失败并提供设置入口,**不能静默切换到另一个下载源。** + +--- + +## 1. 摘要与核心决策 + +1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`: + - **通用设置** + - **魔法笔记** +2. “通用设置”首个功能为“模型下载源”。 +3. 首期提供两个互斥选项: + - `modelscope`,用户文案为 **ModelScope**,默认值。 + - `hugging-face`,用户文案为 **Hugging Face**。 +4. 设置影响 GoodBuddy 管理的所有本地模型下载: + - 当前本地语音输入模型。 + - 当前本地 OCR 模型。 + - 规划中的应用托管文本向量模型。 + - 后续接入统一模型目录的本地 ASR、TTS、VAD 或其他本地模型。 +5. 设置不影响: + - 已安装模型及其当前选择。 + - ZIP 导入和导出。 + - Ollama 自行安装或拉取的模型。 + - 云端模型调用。 + - LLM Provider、Runtime、Skills、MCP 或扩展下载。 + - GoodBuddy 应用自身的“检查更新源”。 +6. 每个模型文件继续固定 Revision、字节数和 SHA-256。两个来源只有提供完全相同的预期 + 文件时,才表示同一个模型工件。 +7. 一个模型在所选来源缺少任何必需文件时,整个模型在该来源标记“暂不可下载”。不能把 + 同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。 +8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的 + 下载使用新来源。 +9. 当前来源失败时,不自动重试另一个来源。错误可以提供“前往通用设置更换下载源”,由 + 用户明确选择。 +10. 现有魔法笔记设置只移动到“魔法笔记”页签,不改变保存、启用或评论行为。 + +--- + +## 2. 实现基线 + +### 2.1 平台功能界面 + +“平台功能”现已使用两个二级页签: + +- “通用设置”承载全局模型下载源。 +- “魔法笔记”承载显示入口、AI 评论方式和 AI 评论形式。 + +页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。 + +### 2.2 受管模型下载 + +- `SpeechModelManager` 管理语音输入模型。 +- `DocumentOcrModelManager` 管理 OCR 模型。 + +两者均已接入全局模型下载源,并具备: + +- Main 进程下载。 +- 来源无关的固定文件名、字节数和 SHA-256。 +- ModelScope 或 Hugging Face 的固定 Revision 与下载 Target。 +- 随机暂存目录。 +- 下载进度和取消。 +- 完整校验后原子安装。 +- ZIP 导入、导出和删除。 +- 受管模型目录。 +- 来源冻结、无静默回退和无跨来源文件混合。 +- 只向 Renderer 暴露来源可用性,不暴露下载 URL 或重定向 Host。 + +### 2.3 当前应用设置 + +平台功能与检查更新共用版本化 `ApplicationSettingsStore`。`modelDownloadSource` 已加入 +版本 7 设置,通过共享 Zod Schema、可信 IPC sender 校验和原子 JSON 写入持久化。版本 +1 至 6 惰性迁移为 ModelScope;未知未来版本继续拒绝读取且不会覆盖用户文件。 + +--- + +## 3. 目标 + +### 3.1 用户目标 + +- 在一个固定入口选择后续本地模型从 ModelScope 还是 Hugging Face 下载。 +- 清楚知道当前选择、默认值和影响范围。 +- 切换来源时不影响已安装、已选择或正在使用的模型。 +- 下载失败时知道实际使用了哪个来源。 +- 所选来源没有模型时得到明确说明,并可以主动前往设置切换。 +- 在“平台功能”中快速区分通用设置与魔法笔记。 + +### 3.2 产品目标 + +- 为语音、OCR、向量和后续本地模型建立一个共同的下载来源契约。 +- 保持模型目录中固定 Revision、大小、SHA-256 和许可信息不变。 +- 禁止模型管理器各自实现不同的来源回退和设置读取。 +- 为未来平台功能保留可扩展的二级页签结构。 +- 保持现有安装、ZIP、取消、原子替换和安全边界。 + +### 3.3 质量目标 + +- 同一模型从两个来源下载后,最终安装文件的名称、大小和 SHA-256 完全一致。 +- 来源切换不会产生混合来源模型包。 +- 活动下载可以准确显示其冻结来源。 +- 设置迁移后所有现有用户默认获得 ModelScope,不丢失其他应用设置。 +- ModelScope 或 Hugging Face 单独故障时,测试能证明应用不会请求另一个来源。 +- 页签和来源选择均可通过键盘、屏幕阅读器、浅色、深色和窄窗口使用。 + +--- + +## 4. 非目标 + +首期不包含: + +- 自定义模型下载源 URL。 +- 根据网络、地域、速度、HTTP 状态或模型可用性自动选择来源。 +- ModelScope 与 Hugging Face 的自动测速或自动排序。 +- 同一下载任务内跨来源续传。 +- 在来源失败后弹窗默认勾选另一个来源并继续。 +- 修改 GoodBuddy 应用更新源。 +- 安装、配置或管理 Ollama。 +- 为云端 Provider 下载模型。 +- 把模型权重打入 GoodBuddy 安装包。 +- 将“通用设置”做成所有设置的重复入口。 +- 创建空的“未来功能”页签。 +- 改变现有魔法笔记的产品行为。 + +--- + +## 5. 术语 + +| 术语 | 定义 | +| --- | --- | +| 模型下载源 | 用户选择的 ModelScope 或 Hugging Face | +| 模型目录 | GoodBuddy 源码中经过验证的模型元数据集合 | +| 模型包 | 一个模型运行所需的全部必需文件和安装清单 | +| 下载目标 | 某个来源中一个固定模型文件的 URL 和仓库信息 | +| 规范工件 | 由文件名、字节数和 SHA-256 定义的来源无关文件 | +| 来源覆盖 | 一个来源是否为模型包的全部必需文件提供下载目标 | +| 冻结来源 | 下载操作启动时记录且在操作期间不变化的来源 | +| 来源回退 | 一个来源失败后由应用自动请求另一个来源 | + +“模型来源”容易被理解为模型作者或 License 来源,因此用户界面统一使用“模型下载源”。 +模型卡中的 License 和上游模型作者不随下载源改变。 + +--- + +## 6. 平台功能信息架构 + +### 6.1 页面结构 + +```text +设置 +└─ 平台功能 + ├─ 通用设置 + │ └─ 模型下载源 + └─ 魔法笔记 + ├─ 显示魔法笔记入口 + ├─ AI 评论方式 + └─ AI 评论形式 +``` + +分类页只有一个一级标题“平台功能”。页签标题不重复渲染为第二个页面标题;每个面板内部 +使用区块标题说明设置内容。 + +### 6.2 PageTabs + +使用共享 `PageTabs`,建议使用 `segmented` 视觉变体: + +```tsx + +``` + +语义要求: + +- 容器使用 `tablist`。 +- 每项使用 `tab` 和 `aria-selected`。 +- 面板使用 `tabpanel`,并由对应 Tab 控制。 +- 左、右方向键切换,支持 Home、End。 +- Tab 键离开页签组进入当前面板。 +- 切换后焦点和可见面板保持一致。 +- 不使用普通按钮组或 `SegmentedControl` 替代页签语义。 + +### 6.3 默认页签和持久化 + +- 每次打开“平台功能”默认进入“通用设置”。 +- 当前页签只在本次设置页面生命周期内保留。 +- 不把当前页签写入 `ApplicationSettingsStore`。 +- 后续支持设置深链接时,可以通过明确的导航参数打开某个页签,不改变默认设置值。 + +### 6.4 未来扩展 + +未来新增页签时: + +- 必须与“通用设置”和“魔法笔记”处于同级。 +- 页签表示完整、独立的功能设置域。 +- 通用设置只放跨功能的全局行为,不成为任意设置的杂物区。 +- 页签超过可读数量时重新组织信息架构,不允许多行堆叠。 +- 尚未提供的功能不显示空页签或“敬请期待”占位。 + +--- + +## 7. 通用设置界面 + +### 7.1 布局 + +```text +平台功能 +管理通用平台行为与可选工作区能力 + +[ 通用设置 ] [ 魔法笔记 ] + +┌ 本地模型 ──────────────────────────────────────────────┐ +│ 模型下载源 │ +│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │ +│ 已安装模型和 ZIP 导入不受影响。 │ +│ │ +│ ◉ ModelScope │ +│ 默认,适合优先访问 ModelScope 的网络环境 │ +│ │ +│ ○ Hugging Face │ +│ 适合可以稳定访问 Hugging Face 的网络环境 │ +│ │ +│ 当前选择:ModelScope │ +└────────────────────────────────────────────────────────┘ +``` + +页面只保留一个视觉上最突出的任务。模型下载源保存属于即时设置,不增加与来源选项竞争的 +大型“保存全部”按钮。 + +### 7.2 控件 + +来源选项包含说明和未来可能的不可用原因,因此使用语义化 Radio Group,而不是原生 +`select` 或 `SegmentedControl`: + +- `fieldset` + `legend` 表达“模型下载源”。 +- 每个选项使用 Radio 和整行可点击卡片。 +- 当前项同时显示选中 Radio、强调边框和选中背景。 +- 不能只用站点 Logo 或颜色表达选择。 +- Logo 可作为辅助图形,但站点名称必须始终显示为文字。 + +用户文案固定为: + +- `ModelScope` +- `Hugging Face` + +内部标识不直接显示。 + +### 7.3 说明文案 + +区块说明: + +> 选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama +> 模型和应用更新不受影响。 + +来源下方说明: + +- ModelScope:`默认,适合优先访问 ModelScope 的网络环境。` +- Hugging Face:`适合可以稳定访问 Hugging Face 的网络环境。` + +不使用“国内源”“国外源”等绝对地理描述,也不保证任一来源在用户网络中一定更快。 + +### 7.4 保存交互 + +- 用户选择另一个 Radio 后立即调用应用设置更新。 +- 保存期间禁用两个选项,保留最后确认值。 +- 成功后更新当前选择,并通过应用通知提示: + `模型下载源已切换为 Hugging Face。` +- 失败时恢复最后确认值,在 Radio Group 附近显示可重试错误。 +- 同一个失败不再同时显示页面横幅和全局错误通知。 + +来源变化不触发: + +- 模型下载。 +- 已安装模型验证。 +- 模型删除。 +- 当前模型切换。 +- 活动下载取消。 + +### 7.5 活动下载说明 + +来源选项下方持续说明: + +> 正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。 + +首期不为这段说明新增跨语音、OCR 和向量管理器的活动任务聚合 IPC。具体活动操作继续在 +对应模型卡显示冻结来源、进度和取消入口。 + +--- + +## 8. 魔法笔记页签 + +现有魔法笔记卡完整移动到“魔法笔记”面板: + +- 显示魔法笔记入口。 +- AI 评论方式。 +- AI 评论形式。 + +保持以下行为不变: + +- 开关继续使用共享 Switch 和 `role="switch"`。 +- 评论方式和形式继续使用 `SegmentedControl`。 +- 每项继续即时保存。 +- 保存失败保留最后确认设置。 +- `onMagicNotesEnabledChange` 继续更新应用导航入口。 + +页签重构不能: + +- 重置现有设置。 +- 在切换页签时保存或改变值。 +- 因魔法笔记入口关闭而隐藏“魔法笔记”设置页签。 +- 把页签选择误当成启用开关。 + +--- + +## 9. 设置契约与迁移 + +### 9.1 共享契约 + +扩展 `src/shared/application-settings-contracts.ts`: + +```ts +export const modelDownloadSourceSchema = z.enum([ + 'modelscope', + 'hugging-face' +]) + +export type ModelDownloadSource = z.infer< + typeof modelDownloadSourceSchema +> + +type ApplicationPreferences = { + checkUpdatesOnStartup: boolean + updateSource: 'github' | 'mirror' + modelDownloadSource: ModelDownloadSource + magicNotesEnabled: boolean + magicNoteCommentMode: MagicNoteCommentMode + magicNoteCommentFormat: MagicNoteCommentFormat +} +``` + +`applicationSettingsUpdateSchema` 继续允许有界的 Partial 更新,不允许未知字段或空更新。 + +### 9.2 默认值 + +```ts +modelDownloadSource: 'modelscope' +``` + +默认值适用于: + +- 首次安装。 +- 没有应用设置文件。 +- 从旧设置版本迁移。 +- 设置文件损坏并完成现有隔离恢复流程。 + +恢复损坏设置时继续显示现有应用设置恢复警告,不把来源恢复伪装成用户选择。 + +### 9.3 设置版本 + +`ApplicationSettingsStore` 增加新版本,例如从当前版本 6 升级到 7: + +```ts +type StoredApplicationSettingsV7 = { + version: 7 + checkUpdatesOnStartup: boolean + updateSource: 'github' | 'mirror' + modelDownloadSource: 'modelscope' | 'hugging-face' + magicNotesEnabled: boolean + magicNoteCommentMode: MagicNoteCommentMode + magicNoteCommentFormat: MagicNoteCommentFormat + lastSeenReleaseNotesVersion: string | null +} +``` + +迁移要求: + +- 版本 1 至 6 全部迁移为 `modelDownloadSource: 'modelscope'`。 +- 保留更新源、魔法笔记和已读发布说明版本。 +- 读取不立即写盘,继续沿用设置存储现有的惰性迁移语义。 +- 下一次设置更新或发布说明确认时按新版本原子写入。 +- 更高未知版本继续拒绝读取,不覆盖用户文件。 + +### 9.4 设置服务 + +首期继续复用现有: + +- `settings:application:get` +- `settings:application:update` +- `window.goodbuddy.updates.getSettings()` +- `window.goodbuddy.updates.updateSettings()` + +虽然 Preload Namespace 名称为 `updates`,底层契约已经承载应用设置。此功能不要求为了一个 +字段进行无关的桥接重命名。未来如果拆分 `applicationSettings` Namespace,必须保持迁移期 +兼容并避免两套设置源。 + +--- + +## 10. 模型目录契约 + +### 10.1 来源无关的规范工件 + +当前每个文件把 URL、大小和 SHA-256 放在同一个 `download` 对象中。新契约把工件身份与 +下载目标拆开: + +```ts +type ModelArtifactTarget = { + url: string + repositoryUrl: string + revision: string + redirectHosts?: string[] +} + +type ModelArtifactFile = { + name: string + role: string + size: number + sha256: string + targets: Partial< + Record + > +} + +type ManagedModelCatalogEntry = { + id: string + displayName: string + files: ModelArtifactFile[] +} +``` + +`size` 和 `sha256` 位于来源外层,表示两个来源必须提供同一规范工件。 + +如果两个站点提供的文件不是完全相同的字节: + +- 不能把它们放入同一文件的两个 Target。 +- 不能为了“兼容”使用两个 SHA-256。 +- 应创建不同模型工件版本、Variant 或不同模型 ID。 +- 每个 Variant 分别经过运行时和质量验证。 + +Renderer 使用的目录快照不暴露下载 URL、重定向 Host 或文件存储地址,只返回来源可用性: + +```ts +type ModelDownloadAvailability = { + source: ModelDownloadSource + available: boolean + totalBytes?: number + unavailableReason?: string +} + +type ManagedModelCatalogView = { + id: string + displayName: string + downloadAvailability: ModelDownloadAvailability[] +} + +type ManagedModelSnapshot = { + selectedDownloadSource: ModelDownloadSource + catalog: ManagedModelCatalogView[] + installed: InstalledManagedModel[] + operations: ManagedModelOperation[] +} +``` + +“打开模型仓库”继续通过 Main 中按模型 ID 和当前来源解析的专用 IPC 完成,不让 Renderer +提交或接收任意仓库 URL。 + +### 10.2 模型来源覆盖 + +一个模型在某个来源可下载,当且仅当: + +1. 每个必需文件都有该来源的 Target。 +2. 每个 Target 使用固定 Revision,不使用 `main`、`latest` 或可变 Tag。 +3. URL、仓库 URL 和重定向 Host 通过 Schema 和来源策略校验。 +4. 文件大小和 SHA-256 已独立验证。 + +只要缺少一个文件,该模型在该来源整体不可下载。 + +禁止: + +```text +detection.onnx ← ModelScope +recognition.onnx ← Hugging Face +dictionary.yml ← ModelScope +``` + +即使最终 SHA-256 正确,也不能在一次任务中混合来源,因为用户选择和审计语义将不再准确。 + +### 10.3 仓库入口 + +“打开模型仓库”使用当前所选下载源对应的 `repositoryUrl`: + +- 当前来源有完整覆盖时,打开对应来源仓库。 +- 当前来源无覆盖但另一个来源有覆盖时,按钮保持可读但禁用,并说明原因。 +- 不自动打开另一个来源的仓库。 +- 已安装模型可以显示“安装来源”,但打开仓库仍遵守当前选择,避免把历史来源当成全局值。 + +### 10.4 目录校验 + +启动时对整个模型目录执行静态校验: + +- 模型 ID 唯一。 +- 文件名和角色唯一。 +- 每个来源 Target 的 URL 和 Revision 有效。 +- 至少一个来源完整覆盖可下载模型。 +- 非手动模型不得在两个来源都缺失。 +- `repositoryUrl` 与下载 Target 属于同一声明来源。 +- 重定向 Host Allowlist 有界且不包含通配公网域名。 + +目录错误应在开发和测试阶段阻止启动或测试,不在运行时猜测修复。 + +### 10.5 当前已验证覆盖 + +当前目录只声明已经逐文件核对字节数和 SHA-256 的来源: + +| 模型 | ModelScope | Hugging Face | +| --- | --- | --- | +| PP-OCRv6 Tiny / Small / Medium | 可下载 | 可下载 | +| SenseVoiceSmall INT8 | 可下载 | 可下载 | +| Whisper Tiny | 可下载 | 可下载 | +| Paraformer 中英双语 / 中英日三语 | 暂不可下载 | 可下载 | +| Whisper Small / Medium | 暂不可下载 | 可下载 | + +OCR 模型包的检测模型与识别模型来自同一下载源中的不同上游仓库。因此每个 Target 都必须 +符合所声明来源的 Host 策略,而模型卡的主仓库入口必须对应到该来源至少一个实际文件 +Target。语音模型当前每个模型包的 Target 与主仓库入口保持一致。 + +未找到可独立验证且字节完全一致的 Target 时,目录保留部分覆盖,不通过替换模型、 +改写摘要或请求另一个来源来伪造双来源支持。 + +--- + +## 11. 下载任务 + +### 11.1 启动 + +Renderer 发起安装时提交: + +```ts +type ManagedModelInstallInput = { + modelId: string + expectedDownloadSource: ModelDownloadSource +} +``` + +Main 必须: + +1. 从 `ApplicationSettingsStore` 重新读取已保存来源。 +2. 验证其与 `expectedDownloadSource` 一致。 +3. 在模型目录中解析该来源的完整模型包。 +4. 创建记录冻结来源的操作。 +5. 只使用该解析结果完成全部文件下载。 + +`expectedDownloadSource` 只用于检测 Renderer 快照过期,不能覆盖 Main 中的已保存设置。 + +如果两个值不一致: + +- 不开始下载。 +- 返回“模型下载源已变化,请刷新后重试”。 +- 不使用 Renderer 提交的来源。 + +### 11.2 操作快照 + +扩展模型操作: + +```ts +type ManagedModelOperation = { + modelId: string + kind: 'download' | 'import' + downloadSource?: ModelDownloadSource + phase: 'preparing' | 'transferring' | 'installing' + currentFile: string | null + completedBytes: number + totalBytes: number | null +} +``` + +- 下载操作必须包含 `downloadSource`。 +- ZIP 导入不包含 `downloadSource`。 +- UI 使用操作快照显示“正在从 ModelScope 下载”。 +- 全局设置变化不修改现有操作对象的来源。 + +### 11.3 来源解析 + +语音与 OCR 管理器复用共享的 `resolveModelDownloadPackage` 契约函数: + +```ts +resolveModelDownloadPackage(files, source) +``` + +解析结果是不可变快照,包含: + +- 来源。 +- 全部文件的 URL、大小、SHA-256。 +- 允许的重定向 Host。 + +模型管理器不直接拼接 ModelScope 或 Hugging Face URL,也不自行尝试第二来源。 + +### 11.4 设置切换期间 + +来源设置更新和模型下载任务相互独立: + +```text +10:00 语音模型下载从 ModelScope 启动 +10:01 用户把全局来源切换为 Hugging Face +10:01 当前语音下载继续使用 ModelScope +10:02 新 OCR 下载使用 Hugging Face +``` + +不能: + +- 取消旧任务后从新来源重新开始。 +- 让当前文件继续从旧来源、下一文件切到新来源。 +- 把进度归零但不告诉用户。 +- 在设置更新完成前启动使用草稿来源的下载。 + +--- + +## 12. 模型管理界面 + +### 12.1 来源状态 + +语音、OCR、向量和后续模型卡统一显示: + +- 当前全局下载源。 +- 当前模型在该来源是否可下载。 +- 活动操作的冻结来源。 +- 已安装模型的安装和校验状态。 + +未安装且来源可用: + +```text +可从 Hugging Face 下载 · 约 91 MB +[下载] +``` + +未安装且来源不可用: + +```text +Hugging Face 暂不提供此模型的完整已验证文件。 +[前往通用设置] +``` + +不能显示可点击“下载”后才告诉用户缺少某个文件。 + +### 12.2 已安装模型 + +已安装模型不依赖当前下载源: + +- 切换来源后继续可用。 +- 不重新验证网络地址。 +- 继续按安装 Manifest 中的大小和 SHA-256 验证。 +- 不自动重新下载。 +- 不改变当前模型选择。 + +当前安装 Manifest 保持来源无关,只记录运行和离线迁移需要的模型 ID、文件名、大小与 +SHA-256。下载来源记录在活动操作快照中,尚未持久化到安装 Manifest。未来如需持久化 +审计来源,可以新增: + +```ts +type InstalledModelProvenance = { + installKind: 'download' | 'archive' | 'local-directory' + downloadSource?: ModelDownloadSource + catalogDigest: string +} +``` + +该扩展必须向后兼容;不能根据文件路径、模型 ID 或当前全局设置猜测旧安装的来源。 + +### 12.3 ZIP 导入与导出 + +ZIP 是来源无关的离线迁移方式: + +- 导入始终按模型 ID、文件名、大小和 SHA-256 验证。 +- 当前下载源不参与导入兼容判断。 +- 从 ModelScope 下载的模型可以在选择 Hugging Face 的设备上导入。 +- 导出可以保留原安装来源作为审计元数据,但不能限制另一设备导入。 +- 导入失败不触发网络下载。 + +### 12.4 文案 + +所有硬编码文案改为来源感知: + +- `正在从 {{source}} 下载` +- `打开 {{source}} 模型仓库` +- `当前下载源暂不提供此模型` +- `前往通用设置更换下载源` + +不再写死: + +- `请从 ModelScope 下载` +- `打开 ModelScope` +- `正在从 ModelScope 下载` + +--- + +## 13. 不静默切换契约 + +### 13.1 禁止行为 + +以下行为全部禁止: + +- ModelScope 网络失败后请求 Hugging Face。 +- Hugging Face 返回 404 后请求 ModelScope。 +- 当前来源缺少一个文件时从另一个来源补齐。 +- 当前来源速度慢时自动测速并切换。 +- 使用环境变量覆盖 UI 已保存来源。 +- 为某一种模型单独保留隐藏的“优先来源”。 +- 同一模型管理器使用与全局设置不同的默认值。 +- 把 CDN 重定向误表示为切换到另一个模型下载源。 + +### 13.2 允许的同来源恢复 + +以下恢复可以自动执行: + +- 同一固定 Target 的有界网络重试。 +- 同一来源声明的固定镜像 Endpoint。 +- 同一来源明确允许的 CDN 重定向。 +- `.partial` 暂存文件内的安全续传,前提是服务支持且完整文件最终通过 SHA-256。 + +这些恢复必须保持: + +- 同一用户选择来源。 +- 同一模型 ID 和 Revision。 +- 同一预期大小和 SHA-256。 +- 同一冻结下载任务。 + +### 13.3 用户主动切换 + +失败状态提供: + +1. 重试当前来源。 +2. 前往“平台功能 → 通用设置”。 +3. 取消。 + +用户切换来源后需要重新点击下载。不能在设置保存后自动恢复上一失败任务。 + +--- + +## 14. 网络与供应链安全 + +### 14.1 URL + +- 只允许 HTTPS 下载;开发测试 Fixture 可以注入受控 Transport。 +- URL 必须来自源码内置目录,不接受 Renderer 自由输入。 +- 禁止 URL 中的用户名和密码。 +- Fragment 在目录校验时拒绝。 +- 查询参数不写入日志和错误文案。 +- 模型卡只显示来源名称和仓库,不显示带签名的最终 CDN URL。 + +### 14.2 重定向 + +ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源 Adapter 维护明确的 +允许 Host,或者由固定 Target 提供有界 `redirectHosts`: + +- 重定向次数有上限。 +- 每一步重新校验 HTTPS 和 Host。 +- 不允许跳转到另一下载源的站点。 +- 不允许通配任意公网 Host。 +- 最终内容仍按固定大小和 SHA-256 验证。 + +重定向到已声明 CDN 属于同一下载源内部传输,不构成下载源切换。UI 继续显示用户选择的 +逻辑来源。 + +### 14.3 凭据和隐私 + +- 两个公开模型源首期不要求用户凭据。 +- 不把 LLM、Embedding、Runtime 或渠道 API Key 附加到模型下载请求。 +- 不继承浏览器 Cookie。 +- 不上传已安装模型清单、知识库、对话或设备文件。 +- 常规 User-Agent 可以包含应用名称和版本,不包含用户 ID 或机器标识。 +- 下载错误只记录来源、模型 ID、文件角色、HTTP 状态和有界网络分类。 + +### 14.4 校验 + +无论来源如何,安装成功必须满足: + +- Content-Length 未超过上限。 +- 实际完整字节数匹配目录。 +- SHA-256 匹配目录。 +- 所有必需文件存在且角色匹配。 +- 安装 Manifest 完整。 +- 正式目录通过原子替换创建。 + +来源可信不替代文件校验,HTTPS 成功也不证明模型工件正确。 + +--- + +## 15. 与其他设置和能力的关系 + +### 15.1 检查更新源 + +“模型下载源”与“检查更新源”是两个独立设置: + +| 设置 | 位置 | 作用 | +| --- | --- | --- | +| 模型下载源 | 平台功能 → 通用设置 | GoodBuddy 托管本地模型 | +| 检查更新源 | 关于与更新 | GoodBuddy 版本检查和下载页 | + +切换任一设置不得修改另一个设置。用户选择 ModelScope 不表示应用更新使用镜像节点;选择 +Hugging Face 也不表示应用更新使用 GitHub。 + +### 15.2 Ollama + +Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源不: + +- 改写 Ollama Registry。 +- 影响 `ollama pull`。 +- 安装或更新 Ollama 模型。 +- 改变 Ollama Endpoint。 + +### 15.3 云端模型 + +云端 LLM、Embedding、Rerank、语音和图像 Provider 不下载本地权重,因此不受该设置 +影响。 + +### 15.4 模型运行 + +模型下载源只决定获取规范工件的位置,不进入: + +- 推理配置。 +- 向量 Provider Fingerprint。 +- 语音识别模型选择。 +- OCR 模型选择。 +- 模型质量或速度标签。 + +只要最终模型文件 SHA-256 相同,从 ModelScope 或 Hugging Face 安装后运行语义必须完全 +一致。 + +--- + +## 16. 失败与恢复 + +| 场景 | 行为 | +| --- | --- | +| 读取设置失败 | 显示平台功能阻塞错误,不猜测或写入来源 | +| 保存来源失败 | 保留上一个确认值,显示就地错误 | +| 当前来源无完整模型包 | 下载按钮禁用,显示前往通用设置 | +| 当前来源网络不可用 | 当前下载失败或允许同来源重试,不请求另一来源 | +| 当前来源返回 404 | 显示来源缺少固定文件,不请求另一来源 | +| 重定向 Host 不受信任 | 立即失败并删除本次暂存 | +| 文件大小不匹配 | 立即失败并删除本次暂存 | +| SHA-256 不匹配 | 立即失败并删除本次暂存 | +| 设置在下载期间变化 | 当前任务继续冻结来源,新任务使用新来源 | +| Renderer 来源快照过期 | 拒绝启动,要求刷新 | +| ZIP 导入期间切换来源 | 导入不受影响 | +| 已安装模型缺少来源元数据 | 继续可用,显示安装来源未知 | +| 应用关闭 | 取消下载并清理本次暂存 | + +错误必须包含可执行下一步,但不能默认执行来源切换。 + +--- + +## 17. 测试 + +### 17.1 设置契约 + +- 接受 `modelscope` 和 `hugging-face`。 +- 拒绝未知来源、空值和多余字段。 +- Partial 更新不覆盖魔法笔记和更新源。 +- 新安装默认 ModelScope。 +- 版本 1 至 6 全部迁移为 ModelScope。 +- 更高未知版本拒绝,不覆盖文件。 +- 并发设置更新保持完整 JSON。 + +### 17.2 平台功能界面 + +- 默认选中“通用设置”Tab。 +- Tab 使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`。 +- 方向键、Home、End 和焦点恢复正确。 +- 切换到魔法笔记不改变任何设置。 +- Radio Group 具有持久 Label。 +- 选择来源时保存一次,保存期间防止重复提交。 +- 保存失败恢复原值并保留可重试错误。 +- 窄窗口不隐藏页签或来源名称。 + +### 17.3 目录 + +对语音、OCR 和向量目录分别验证: + +- 两个来源解析到相同文件名、大小和 SHA-256。 +- 缺少任一 Target 时模型在该来源不可下载。 +- 不允许一次任务混合来源。 +- 可变 Revision 被拒绝。 +- 仓库 URL、下载 URL 和重定向 Host 符合来源策略。 +- 重复模型、文件和角色被拒绝。 + +### 17.4 下载管理器 + +使用注入的受控 Fetch 验证: + +- ModelScope 选择只请求 ModelScope Target。 +- Hugging Face 选择只请求 Hugging Face Target。 +- 一个来源失败时另一个来源零请求。 +- 设置中途变化不改变活动任务。 +- 新任务使用最新已保存来源。 +- Renderer 提交过期来源时零网络请求。 +- 两个来源的安装 Manifest 文件摘要相同。 +- 取消、关闭、错误和摘要不匹配不留下正式模型目录。 + +### 17.5 回归 + +- 已安装语音模型继续可选择和转写。 +- 已安装 OCR 模型继续可解析。 +- ZIP 导入导出在任一来源设置下可用。 +- 魔法笔记入口、评论方式和评论形式保持一致。 +- 检查更新源行为不变。 +- 文档、向量和语音推理不因下载源变化而改变。 + +--- + +## 18. 实施阶段 + +### 阶段 1:应用设置和页签(已完成) + +- 新增 `ModelDownloadSource` Schema 和默认值。 +- 将应用设置版本升级并迁移旧版本。 +- 在“平台功能”中增加 `PageTabs`。 +- 增加“通用设置”与模型下载源 Radio Group。 +- 把现有魔法笔记卡移动到“魔法笔记”面板。 + +### 阶段 2:共享目录和来源解析(已完成) + +- 定义来源无关的规范工件与分来源 Target。 +- 实现共享 `ModelDownloadTargetResolver`。 +- 为语音和 OCR 目录补充 ModelScope 与 Hugging Face 固定 Target。 +- 静态验证两个来源的文件大小和 SHA-256。 +- 更正来源感知文案和仓库入口。 + +### 阶段 3:模型管理器(已完成) + +- 扩展安装输入和操作快照。 +- 让语音与 OCR 下载任务冻结应用设置来源。 +- 保持 ZIP、删除、校验和模型选择行为不变。 +- 接入来源 Host 和重定向策略。 + +### 阶段 4:后续模型 + +- 应用托管文本向量模型接入同一解析器。 +- 实时语音的 ASR、TTS、VAD 模型目录接入同一解析器。 +- 其他本地模型只有满足统一目录契约后才能使用全局下载源。 + +--- + +## 19. 验收标准 + +### 19.1 产品 + +- “平台功能”显示“通用设置”和“魔法笔记”两个可访问页签。 +- 默认打开“通用设置”。 +- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。 +- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。 +- 切换来源不会下载、删除、切换或重新验证任何已安装模型。 +- 当前来源没有模型时,在点击下载前即可看到不可用原因。 +- 下载失败不会自动请求另一来源。 +- 魔法笔记的现有设置和入口行为保持不变。 + +### 19.2 数据与下载 + +- 旧应用设置完整迁移,并得到 ModelScope 默认值。 +- 每个双来源模型的规范文件名、大小和 SHA-256 相同。 +- 单次下载任务只使用一个冻结来源。 +- 活动下载显示实际冻结来源。 +- 已安装 Manifest 和 ZIP 兼容性不依赖来源。 +- ModelScope 和 Hugging Face 分别通过固定 Target、重定向、取消和摘要校验测试。 + +### 19.3 安全和无障碍 + +- Renderer 不能提交任意下载 URL。 +- Main 重新读取设置并校验 Renderer 的预期来源。 +- 重定向不能跨到未声明来源或任意公网 Host。 +- 下载请求不携带模型 Provider、Runtime 或渠道密钥。 +- 页签、Radio、错误和进度可由键盘及屏幕阅读器操作。 +- 浅色、深色和窄窗口均持续显示当前来源和活动下载来源。 + +### 19.4 工程验证 + +源代码实施后必须通过: + +```text +npm test +npm run typecheck +npm run lint +npm run build +``` + +模型文件真实性和双来源一致性使用门控目录验证命令,不在普通测试中下载模型权重。只有 +显式授权的维护流程可以访问外部模型仓库并更新固定字节数和 SHA-256。 diff --git a/docs/prd/document-processing/document-extraction-and-local-ocr.md b/docs/prd/document-processing/document-extraction-and-local-ocr.md index d1ecf18..62ff5db 100644 --- a/docs/prd/document-processing/document-extraction-and-local-ocr.md +++ b/docs/prd/document-processing/document-extraction-and-local-ocr.md @@ -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//...` 地址读取文件; +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 均被拒绝; - 超页数、超时、取消和关闭不会留下运行任务; diff --git a/docs/prd/knowledge/knowledge-rag-enhancement-prd.md b/docs/prd/knowledge/knowledge-rag-enhancement-prd.md index bbfc762..5fa7b26 100644 --- a/docs/prd/knowledge/knowledge-rag-enhancement-prd.md +++ b/docs/prd/knowledge/knowledge-rag-enhancement-prd.md @@ -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 不匹配的旧向量。 - 重建失败时,上一版已就绪向量仍能继续召回。 diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index b12df2f..a29733f 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -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' diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index b3ce86a..2cdfdc1 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -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 diff --git a/src/main/document-ocr-model-catalog.ts b/src/main/document-ocr-model-catalog.ts index 51198c8..36c06a1 100644 --- a/src/main/document-ocr-model-catalog.ts +++ b/src/main/document-ocr-model-catalog.ts @@ -2,19 +2,66 @@ import { documentOcrModelCatalogEntrySchema, type DocumentOcrModelCatalogEntry } from '../shared/document-parsing-contracts' +import { + huggingFaceTarget, + modelScopeTarget +} from './model-download-targets' -const detectionRevision = - '7d7f5d128d9309ebf6de4f21f404dd583afdbae3' -const recognitionRevision = - 'afba04b618200c5f4824531c6e42c957c6439d9a' -const smallDetectionRevision = - '956a0b620a4017cc04056c692be1703b0025d028' -const smallRecognitionRevision = - '296d43bc0ebced0fd9c605174aa5962e49810ab6' -const mediumDetectionRevision = - 'c317b40325be40bfaaff58c8dcece2a075294f8a' -const mediumRecognitionRevision = - 'db5d610d492a14e3c34dc1fd4e9339bd369f79e6' +const repositories = { + tinyDetection: 'PaddlePaddle/PP-OCRv6_tiny_det_onnx', + tinyRecognition: 'PaddlePaddle/PP-OCRv6_tiny_rec_onnx', + smallDetection: 'PaddlePaddle/PP-OCRv6_small_det_onnx', + smallRecognition: 'PaddlePaddle/PP-OCRv6_small_rec_onnx', + mediumDetection: 'PaddlePaddle/PP-OCRv6_medium_det_onnx', + mediumRecognition: 'PaddlePaddle/PP-OCRv6_medium_rec_onnx' +} as const + +const modelScopeRevisions = { + tinyDetection: '7d7f5d128d9309ebf6de4f21f404dd583afdbae3', + tinyRecognition: 'afba04b618200c5f4824531c6e42c957c6439d9a', + smallDetection: '956a0b620a4017cc04056c692be1703b0025d028', + smallRecognition: '296d43bc0ebced0fd9c605174aa5962e49810ab6', + mediumDetection: 'c317b40325be40bfaaff58c8dcece2a075294f8a', + mediumRecognition: 'db5d610d492a14e3c34dc1fd4e9339bd369f79e6' +} as const + +const huggingFaceRevisions = { + tinyDetection: '2ba1506c0380b8f0b03dd142459aac66d4421f6c', + tinyRecognition: '2612ab37152ae0a677521bae4e1e3d4fb4cf7c30', + smallDetection: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62', + smallRecognition: 'b8f84f0b80c529de40b4fbb3544b84fa7233a513', + mediumDetection: '61323801669c338b7891481ec7bac61ce31b576a', + mediumRecognition: '50c7eacafc52fa7bcf4194e8cd08e46f8558504b' +} as const + +type RepositoryKey = keyof typeof repositories + +function targets( + repositoryKey: RepositoryKey, + file: string +) { + const repository = repositories[repositoryKey] + return { + modelscope: modelScopeTarget( + repository, + modelScopeRevisions[repositoryKey], + file + ), + 'hugging-face': huggingFaceTarget( + repository, + huggingFaceRevisions[repositoryKey], + file + ) + } +} + +function repositoryUrls(repositoryKey: RepositoryKey) { + const repository = repositories[repositoryKey] + return { + modelscope: `https://modelscope.cn/models/${repository}`, + 'hugging-face': `https://huggingface.co/${repository}` + } +} export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] = documentOcrModelCatalogEntrySchema.array().parse([ @@ -28,54 +75,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] quality: 'basic', speed: 'fast', recommended: false, - repositoryUrl: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_tiny_rec_onnx', + repositoryUrls: repositoryUrls('tinyRecognition'), license: { name: 'Apache License 2.0', notice: - '检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', + '检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE' }, files: [ { name: 'detection.onnx', role: 'detection', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_tiny_det_onnx/resolve/' + - `${detectionRevision}/inference.onnx`, - size: 1_780_590, - sha256: - '193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8' - } + size: 1_780_590, + sha256: + '193bab7a04fca699a6c82e6abb5b81bdb28177f0abd4062552b04908dafb19f8', + targets: targets('tinyDetection', 'inference.onnx') }, { name: 'recognition.onnx', role: 'recognition', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_tiny_rec_onnx/resolve/' + - `${recognitionRevision}/inference.onnx`, - size: 4_462_639, - sha256: - '9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6' - } + size: 4_462_639, + sha256: + '9ef676d6ed3c88256a2d92c640c44f25b0c40947e111b14b8be8f594091563e6', + targets: targets('tinyRecognition', 'inference.onnx') }, { name: 'dictionary.yml', role: 'dictionary', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_tiny_rec_onnx/resolve/' + - `${recognitionRevision}/inference.yml`, - size: 55_571, - sha256: - '66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1' - } + size: 55_571, + sha256: + '66170210bad538e83fff3c4a3867e547d6bf20b50d64b20347c4b913f3034ea1', + targets: targets('tinyRecognition', 'inference.yml') } ] }, @@ -89,54 +119,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] quality: 'balanced', speed: 'balanced', recommended: true, - repositoryUrl: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_small_rec_onnx', + repositoryUrls: repositoryUrls('smallRecognition'), license: { name: 'Apache License 2.0', notice: - '检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', + '检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE' }, files: [ { name: 'detection.onnx', role: 'detection', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_small_det_onnx/resolve/' + - `${smallDetectionRevision}/inference.onnx`, - size: 9_880_512, - sha256: - 'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e' - } + size: 9_880_512, + sha256: + 'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e', + targets: targets('smallDetection', 'inference.onnx') }, { name: 'recognition.onnx', role: 'recognition', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_small_rec_onnx/resolve/' + - `${smallRecognitionRevision}/inference.onnx`, - size: 21_159_378, - sha256: - '5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634' - } + size: 21_159_378, + sha256: + '5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634', + targets: targets('smallRecognition', 'inference.onnx') }, { name: 'dictionary.yml', role: 'dictionary', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_small_rec_onnx/resolve/' + - `${smallRecognitionRevision}/inference.yml`, - size: 150_579, - sha256: - 'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1' - } + size: 150_579, + sha256: + 'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1', + targets: targets('smallRecognition', 'inference.yml') } ] }, @@ -150,54 +163,37 @@ export const DOCUMENT_OCR_MODEL_CATALOG: readonly DocumentOcrModelCatalogEntry[] quality: 'high', speed: 'slow', recommended: false, - repositoryUrl: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_medium_rec_onnx', + repositoryUrls: repositoryUrls('mediumRecognition'), license: { name: 'Apache License 2.0', notice: - '检测与识别模型由 PaddlePaddle 在 ModelScope 发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', + '检测与识别模型由 PaddlePaddle 官方发布,使用前请阅读模型仓库及 PaddleOCR 的许可证说明。', url: 'https://github.com/PaddlePaddle/PaddleOCR/blob/main/LICENSE' }, files: [ { name: 'detection.onnx', role: 'detection', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_medium_det_onnx/resolve/' + - `${mediumDetectionRevision}/inference.onnx`, - size: 62_032_837, - sha256: - 'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1' - } + size: 62_032_837, + sha256: + 'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1', + targets: targets('mediumDetection', 'inference.onnx') }, { name: 'recognition.onnx', role: 'recognition', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_medium_rec_onnx/resolve/' + - `${mediumRecognitionRevision}/inference.onnx`, - size: 76_554_979, - sha256: - '9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba' - } + size: 76_554_979, + sha256: + '9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba', + targets: targets('mediumRecognition', 'inference.onnx') }, { name: 'dictionary.yml', role: 'dictionary', - download: { - url: - 'https://modelscope.cn/models/PaddlePaddle/' + - 'PP-OCRv6_medium_rec_onnx/resolve/' + - `${mediumRecognitionRevision}/inference.yml`, - size: 150_580, - sha256: - '991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129' - } + size: 150_580, + sha256: + '991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129', + targets: targets('mediumRecognition', 'inference.yml') } ] } diff --git a/src/main/document-ocr-model-manager.test.ts b/src/main/document-ocr-model-manager.test.ts index aca50dc..a1fde79 100644 --- a/src/main/document-ocr-model-manager.test.ts +++ b/src/main/document-ocr-model-manager.test.ts @@ -70,8 +70,12 @@ function catalog( quality: 'balanced', speed: 'fast', recommended: true, - repositoryUrl: - 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx', + repositoryUrls: { + modelscope: + 'https://modelscope.cn/models/example/test-model', + 'hugging-face': + 'https://huggingface.co/example/test-model' + }, license: { name: 'Apache License 2.0', notice: 'Test license notice.', @@ -80,10 +84,27 @@ function catalog( files: files.map((file) => ({ name: file.name, role: file.role, - download: { - url: `https://modelscope.cn/models/example/resolve/revision/${file.name}`, - size: file.bytes.byteLength, - sha256: sha256(file.bytes) + size: file.bytes.byteLength, + sha256: sha256(file.bytes), + targets: { + modelscope: { + url: + 'https://modelscope.cn/models/example/test-model/' + + `resolve/${'a'.repeat(40)}/${file.name}`, + repositoryUrl: + 'https://modelscope.cn/models/example/test-model', + revision: 'a'.repeat(40), + redirectHosts: [] + }, + 'hugging-face': { + url: + 'https://huggingface.co/example/test-model/' + + `resolve/${'b'.repeat(40)}/${file.name}`, + repositoryUrl: + 'https://huggingface.co/example/test-model', + revision: 'b'.repeat(40), + redirectHosts: [] + } } })) } @@ -124,10 +145,12 @@ async function createManager( throw new Error('Test OCR catalog is empty') } const files = new Map( - entry.files.map((file) => [ - file.download.url, - modelBytes[file.role] - ]) + entry.files.flatMap((file) => + Object.values(file.targets).map((target) => [ + target.url, + modelBytes[file.role] + ] as const) + ) ) const transport = vi.fn(async (input: string | URL | Request) => { const url = @@ -174,7 +197,7 @@ describe('DocumentOcrModelManager', () => { }) }) - it('uses immutable SHA-256 verified ModelScope catalog files', () => { + it('uses immutable byte-identical ModelScope and Hugging Face files', () => { expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3) expect( new Set(DOCUMENT_OCR_MODEL_CATALOG.map((entry) => entry.id)).size @@ -186,20 +209,28 @@ describe('DocumentOcrModelManager', () => { ).toEqual(['pp-ocrv6-small']) for (const entry of DOCUMENT_OCR_MODEL_CATALOG) { + expect(entry.repositoryUrls.modelscope).toMatch( + /^https:\/\/modelscope\.cn\/models\/PaddlePaddle\//u + ) + expect(entry.repositoryUrls['hugging-face']).toMatch( + /^https:\/\/huggingface\.co\/PaddlePaddle\//u + ) for (const file of entry.files) { - expect(file.download.url).toMatch( + expect(file.targets.modelscope?.url).toMatch( /^https:\/\/modelscope\.cn\/models\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u ) - expect(file.download.sha256).toMatch(/^[a-f0-9]{64}$/u) - expect(file.download.size).toBeGreaterThan(0) + expect(file.targets['hugging-face']?.url).toMatch( + /^https:\/\/huggingface\.co\/PaddlePaddle\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u + ) + expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u) + expect(file.size).toBeGreaterThan(0) } } - expect( - DOCUMENT_OCR_MODEL_CATALOG.find( - (entry) => entry.id === 'pp-ocrv6-small' - ) - ).toMatchObject({ + const small = DOCUMENT_OCR_MODEL_CATALOG.find( + (entry) => entry.id === 'pp-ocrv6-small' + ) + expect(small).toMatchObject({ languages: ['50 种语言'], quality: 'balanced', speed: 'balanced', @@ -207,30 +238,29 @@ describe('DocumentOcrModelManager', () => { files: [ { role: 'detection', - download: { - url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/956a0b620a4017cc04056c692be1703b0025d028/inference.onnx', - size: 9_880_512, - sha256: - 'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e' + size: 9_880_512, + sha256: + 'd73e0058b7a8086bbd57f3d10b8bcd4ff95363f67e06e2762b5e814fe9c9410e', + targets: { + modelscope: { + revision: '956a0b620a4017cc04056c692be1703b0025d028' + }, + 'hugging-face': { + revision: '28fe5895c24fd108c19eb3e8479f4ab385fbfc62' + } } }, { role: 'recognition', - download: { - url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.onnx', - size: 21_159_378, - sha256: - '5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634' - } + size: 21_159_378, + sha256: + '5435fd747c9e0efe15a96d0b378d5bd157e9492ed8fd80edf08f30d02fa24634' }, { role: 'dictionary', - download: { - url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/296d43bc0ebced0fd9c605174aa5962e49810ab6/inference.yml', - size: 150_579, - sha256: - 'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1' - } + size: 150_579, + sha256: + 'ab078671bb49f06228eadccd34f1bb501e157f7a047095ffb943ba81512c77d1' } ] }) @@ -246,30 +276,21 @@ describe('DocumentOcrModelManager', () => { files: [ { role: 'detection', - download: { - url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_det_onnx/resolve/c317b40325be40bfaaff58c8dcece2a075294f8a/inference.onnx', - size: 62_032_837, - sha256: - 'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1' - } + size: 62_032_837, + sha256: + 'eb13b44b25bb36f89528b68720af8a61d9cf381176107f465db1757b65d086e1' }, { role: 'recognition', - download: { - url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.onnx', - size: 76_554_979, - sha256: - '9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba' - } + size: 76_554_979, + sha256: + '9c09abf0957f7968c7586464b7397b84ad2387a0497a351af40e9acc71b673ba' }, { role: 'dictionary', - download: { - url: 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_medium_rec_onnx/resolve/db5d610d492a14e3c34dc1fd4e9339bd369f79e6/inference.yml', - size: 150_580, - sha256: - '991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129' - } + size: 150_580, + sha256: + '991b700facf5b50a7de193468207d5f4255b538dde0d312ae3b7c7a9b6873129' } ] }) @@ -296,6 +317,58 @@ describe('DocumentOcrModelManager', () => { expect(new TextDecoder().decode(assets.dictionary)).toContain( "!\n\"\n'\n" ) + const snapshot = await manager.getSnapshot() + expect(snapshot.selectedDownloadSource).toBe('modelscope') + expect(snapshot.catalog[0]?.files[0]).not.toHaveProperty('targets') + expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/') + }) + + it('downloads the same canonical package from Hugging Face', async () => { + const { manager } = await createManager() + + await expect( + manager.install('pp-ocrv6-tiny', 'hugging-face') + ).resolves.toMatchObject({ + id: 'pp-ocrv6-tiny', + source: 'download' + }) + }) + + it('does not request another source when selected coverage is missing', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-document-ocr-model-') + ) + temporaryDirectories.push(directory) + const detection = Buffer.from('detection') + const recognition = Buffer.from('recognition') + const dictionary = dictionaryYaml() + const sourceCatalog = catalog( + detection, + recognition, + dictionary + ).map((entry) => ({ + ...entry, + repositoryUrls: { + modelscope: entry.repositoryUrls.modelscope + }, + files: entry.files.map((file) => ({ + ...file, + targets: { + modelscope: file.targets.modelscope + } + })) + })) + const transport = vi.fn() + 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 () => { diff --git a/src/main/document-ocr-model-manager.ts b/src/main/document-ocr-model-manager.ts index 7ee4f84..85d712e 100644 --- a/src/main/document-ocr-model-manager.ts +++ b/src/main/document-ocr-model-manager.ts @@ -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 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 private readonly maxFileBytes: number private readonly operations = new Map() private readonly verifiedModels = new Map>() @@ -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 { 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 { 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 { 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 { - 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, destination: string, operation: ActiveOperation, signal: AbortSignal ): Promise { - 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 ) { diff --git a/src/main/index.ts b/src/main/index.ts index d1cefb7..b547bcd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index c282c52..f581502 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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'), diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 10bc696..f361f6d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 + ) + ) } ) diff --git a/src/main/model-download-targets.ts b/src/main/model-download-targets.ts new file mode 100644 index 0000000..be23924 --- /dev/null +++ b/src/main/model-download-targets.ts @@ -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'] + ] + } +} diff --git a/src/main/model-download-transport.ts b/src/main/model-download-transport.ts new file mode 100644 index 0000000..0a6e5e9 --- /dev/null +++ b/src/main/model-download-transport.ts @@ -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 { + 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 + } +} diff --git a/src/main/speech/speech-model-catalog.ts b/src/main/speech/speech-model-catalog.ts index 9c00a5f..8340e68 100644 --- a/src/main/speech/speech-model-catalog.ts +++ b/src/main/speech/speech-model-catalog.ts @@ -2,11 +2,33 @@ import { speechModelCatalogEntrySchema, type SpeechModelCatalogEntry } from '../../shared/speech-model-contracts' +import { + huggingFaceTarget, + modelScopeTarget +} from '../model-download-targets' + +const senseVoiceModelScopeRepository = + 'pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue' +const senseVoiceModelScopeRevision = + '73eca47697f980daa3d16112404174b6b950b514' +const senseVoiceHuggingFaceRepository = + 'csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17' +const senseVoiceHuggingFaceRevision = + '2365baeacb507f821a0c8120fcee3d484dba7a07' + +const whisperTinyModelScopeRepository = + 'pengzhendong/sherpa-onnx-whisper-tiny' +const whisperTinyModelScopeRevision = + '33a655645234f82ce833cf27b689d9c2212e693f' +const whisperTinyHuggingFaceRepository = + 'csukuangfj/sherpa-onnx-whisper-tiny' +const whisperTinyHuggingFaceRevision = + '65176e2deb88badc814a94058666cadccc29b61c' /** - * This catalog intentionally contains metadata only. Model weights are never - * bundled with GoodBuddy. Entries remain manual-only until every downloadable - * file has a pinned revision, byte size, and independently verified SHA-256. + * Model weights are never bundled with GoodBuddy. Canonical file identity is + * source-independent; a source target is included only after its bytes match + * the declared size and SHA-256. */ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = speechModelCatalogEntrySchema.array().parse([ @@ -21,13 +43,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = quality: 'high', speed: 'fast', recommended: true, - repositoryUrl: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue', + repositoryUrls: { + modelscope: + `https://modelscope.cn/models/${senseVoiceModelScopeRepository}`, + 'hugging-face': + `https://huggingface.co/${senseVoiceHuggingFaceRepository}` + }, license: { name: '模型仓库自定义许可(Model License)', notice: - 'SenseVoiceSmall 权重采用模型仓库声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。', + 'SenseVoiceSmall 权重采用上游声明的自定义 MODEL LICENSE,并非 Apache-2.0 或 MIT;导入和使用前请阅读完整许可条款。', url: 'https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE' }, manualOnly: false, @@ -35,29 +60,39 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = { name: 'model.int8.onnx', role: 'model', - download: { - url: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' + - 'resolve/73eca47697f980daa3d16112404174b6b950b514/' + - 'model.int8.onnx', - size: 239_233_841, - sha256: - 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51' + size: 239_233_841, + sha256: + 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51', + targets: { + modelscope: modelScopeTarget( + senseVoiceModelScopeRepository, + senseVoiceModelScopeRevision, + 'model.int8.onnx' + ), + 'hugging-face': huggingFaceTarget( + senseVoiceHuggingFaceRepository, + senseVoiceHuggingFaceRevision, + 'model.int8.onnx' + ) } }, { name: 'tokens.txt', role: 'tokens', - download: { - url: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue/' + - 'resolve/73eca47697f980daa3d16112404174b6b950b514/' + - 'tokens.txt', - size: 315_894, - sha256: - 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc' + size: 315_894, + sha256: + 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc', + targets: { + modelscope: modelScopeTarget( + senseVoiceModelScopeRepository, + senseVoiceModelScopeRevision, + 'tokens.txt' + ), + 'hugging-face': huggingFaceTarget( + senseVoiceHuggingFaceRepository, + senseVoiceHuggingFaceRevision, + 'tokens.txt' + ) } } ] @@ -73,9 +108,12 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = quality: 'basic', speed: 'fast', recommended: false, - repositoryUrl: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-whisper-tiny', + repositoryUrls: { + modelscope: + `https://modelscope.cn/models/${whisperTinyModelScopeRepository}`, + 'hugging-face': + `https://huggingface.co/${whisperTinyHuggingFaceRepository}` + }, license: { name: 'MIT License', notice: @@ -87,43 +125,58 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = { name: 'tiny-encoder.int8.onnx', role: 'encoder', - download: { - url: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-whisper-tiny/resolve/' + - '33a655645234f82ce833cf27b689d9c2212e693f/' + - 'tiny-encoder.int8.onnx', - size: 12_937_772, - sha256: - 'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434' + size: 12_937_772, + sha256: + 'd24fb083ae3b1041fc24e97971d60e280c9342201fbb67b0ab428a8b4a51a434', + targets: { + modelscope: modelScopeTarget( + whisperTinyModelScopeRepository, + whisperTinyModelScopeRevision, + 'tiny-encoder.int8.onnx' + ), + 'hugging-face': huggingFaceTarget( + whisperTinyHuggingFaceRepository, + whisperTinyHuggingFaceRevision, + 'tiny-encoder.int8.onnx' + ) } }, { name: 'tiny-decoder.int8.onnx', role: 'decoder', - download: { - url: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-whisper-tiny/resolve/' + - '33a655645234f82ce833cf27b689d9c2212e693f/' + - 'tiny-decoder.int8.onnx', - size: 89_855_401, - sha256: - 'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925' + size: 89_855_401, + sha256: + 'd2fece8dd42771f1df975c6c0445770d0c292bf7547c2cae04a6c0cc57540925', + targets: { + modelscope: modelScopeTarget( + whisperTinyModelScopeRepository, + whisperTinyModelScopeRevision, + 'tiny-decoder.int8.onnx' + ), + 'hugging-face': huggingFaceTarget( + whisperTinyHuggingFaceRepository, + whisperTinyHuggingFaceRevision, + 'tiny-decoder.int8.onnx' + ) } }, { name: 'tiny-tokens.txt', role: 'tokens', - download: { - url: - 'https://modelscope.cn/models/pengzhendong/' + - 'sherpa-onnx-whisper-tiny/resolve/' + - '33a655645234f82ce833cf27b689d9c2212e693f/' + - 'tiny-tokens.txt', - size: 816_730, - sha256: - 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126' + size: 816_730, + sha256: + 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126', + targets: { + modelscope: modelScopeTarget( + whisperTinyModelScopeRepository, + whisperTinyModelScopeRevision, + 'tiny-tokens.txt' + ), + 'hugging-face': huggingFaceTarget( + whisperTinyHuggingFaceRepository, + whisperTinyHuggingFaceRevision, + 'tiny-tokens.txt' + ) } } ] @@ -139,9 +192,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = quality: 'high', speed: 'fast', recommended: true, - repositoryUrl: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-bilingual-zh-en', + repositoryUrls: { + 'hugging-face': + 'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en' + }, license: { name: 'MIT License', notice: @@ -156,29 +210,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = { name: 'model.int8.onnx', role: 'model', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' + - '4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' + - 'model.int8.onnx', - size: 223_385_835, - sha256: - '9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7' + size: 223_385_835, + sha256: + '9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en', + '4b891f7b5c73d874e607797a4b0578fd4c35dd4b', + 'model.int8.onnx' + ) } }, { name: 'tokens.txt', role: 'tokens', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' + - '4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' + - 'tokens.txt', - size: 75_756, - sha256: - '59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6' + size: 75_756, + sha256: + '59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en', + '4b891f7b5c73d874e607797a4b0578fd4c35dd4b', + 'tokens.txt' + ) } } ] @@ -194,9 +248,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = quality: 'high', speed: 'balanced', recommended: false, - repositoryUrl: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-trilingual-zh-cantonese-en', + repositoryUrls: { + 'hugging-face': + 'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en' + }, license: { name: 'Apache License 2.0', notice: @@ -211,29 +266,29 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = { name: 'model.int8.onnx', role: 'model', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' + - 'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' + - 'model.int8.onnx', - size: 244_684_152, - sha256: - 'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144' + size: 244_684_152, + sha256: + 'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en', + '8d90151338178bb433354c9fb677bd3acb8023cd', + 'model.int8.onnx' + ) } }, { name: 'tokens.txt', role: 'tokens', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' + - 'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' + - 'tokens.txt', - size: 118_931, - sha256: - '8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492' + size: 118_931, + sha256: + '8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en', + '8d90151338178bb433354c9fb677bd3acb8023cd', + 'tokens.txt' + ) } } ] @@ -249,8 +304,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = quality: 'balanced', speed: 'balanced', recommended: false, - repositoryUrl: - 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small', + repositoryUrls: { + 'hugging-face': + 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small' + }, license: { name: 'MIT License', notice: @@ -262,43 +319,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = { name: 'small-encoder.int8.onnx', role: 'encoder', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-whisper-small/resolve/' + - '8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' + - 'small-encoder.int8.onnx', - size: 112_442_483, - sha256: - '4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9' + size: 112_442_483, + sha256: + '4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-whisper-small', + '8f3c18b358db4d1f2fc1eae49d75cd20989e4309', + 'small-encoder.int8.onnx' + ) } }, { name: 'small-decoder.int8.onnx', role: 'decoder', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-whisper-small/resolve/' + - '8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' + - 'small-decoder.int8.onnx', - size: 262_226_114, - sha256: - 'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee' + size: 262_226_114, + sha256: + 'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-whisper-small', + '8f3c18b358db4d1f2fc1eae49d75cd20989e4309', + 'small-decoder.int8.onnx' + ) } }, { name: 'small-tokens.txt', role: 'tokens', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-whisper-small/resolve/' + - '8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' + - 'small-tokens.txt', - size: 816_730, - sha256: - 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126' + size: 816_730, + sha256: + 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-whisper-small', + '8f3c18b358db4d1f2fc1eae49d75cd20989e4309', + 'small-tokens.txt' + ) } } ] @@ -314,8 +371,10 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = quality: 'high', speed: 'slow', recommended: false, - repositoryUrl: - 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium', + repositoryUrls: { + 'hugging-face': + 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium' + }, license: { name: 'MIT License', notice: @@ -327,43 +386,43 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] = { name: 'medium-encoder.int8.onnx', role: 'encoder', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-whisper-medium/resolve/' + - '8c31d28503847560985df21f90e14f0c736e075e/' + - 'medium-encoder.int8.onnx', - size: 374_196_283, - sha256: - '1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e' + size: 374_196_283, + sha256: + '1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-whisper-medium', + '8c31d28503847560985df21f90e14f0c736e075e', + 'medium-encoder.int8.onnx' + ) } }, { name: 'medium-decoder.int8.onnx', role: 'decoder', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-whisper-medium/resolve/' + - '8c31d28503847560985df21f90e14f0c736e075e/' + - 'medium-decoder.int8.onnx', - size: 571_059_257, - sha256: - '595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747' + size: 571_059_257, + sha256: + '595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-whisper-medium', + '8c31d28503847560985df21f90e14f0c736e075e', + 'medium-decoder.int8.onnx' + ) } }, { name: 'medium-tokens.txt', role: 'tokens', - download: { - url: - 'https://huggingface.co/csukuangfj/' + - 'sherpa-onnx-whisper-medium/resolve/' + - '8c31d28503847560985df21f90e14f0c736e075e/' + - 'medium-tokens.txt', - size: 816_730, - sha256: - 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126' + size: 816_730, + sha256: + 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126', + targets: { + 'hugging-face': huggingFaceTarget( + 'csukuangfj/sherpa-onnx-whisper-medium', + '8c31d28503847560985df21f90e14f0c736e075e', + 'medium-tokens.txt' + ) } } ] diff --git a/src/main/speech/speech-model-manager.test.ts b/src/main/speech/speech-model-manager.test.ts index f9d24af..424b8c6 100644 --- a/src/main/speech/speech-model-manager.test.ts +++ b/src/main/speech/speech-model-manager.test.ts @@ -35,12 +35,36 @@ function sha256(value: Uint8Array): string { } function manualCatalog(): SpeechModelCatalogEntry[] { - return SPEECH_MODEL_CATALOG.map((entry) => ({ + const entry = SPEECH_MODEL_CATALOG.find( + (candidate) => candidate.id === 'sensevoice-small-int8' + ) + if (!entry) { + throw new Error('SenseVoice test catalog entry is missing') + } + const modelBytes = new TextEncoder().encode('model') + const tokenBytes = new TextEncoder().encode('tokens') + return [{ ...entry, manualOnly: true, - manualReason: entry.manualReason ?? '测试使用本地目录导入。', - files: entry.files.map(({ name, role }) => ({ name, role })) - })) + manualReason: '测试使用本地目录导入。', + repositoryUrls: {}, + files: [ + { + name: 'model.int8.onnx', + role: 'model', + size: modelBytes.byteLength, + sha256: sha256(modelBytes), + targets: {} + }, + { + name: 'tokens.txt', + role: 'tokens', + size: tokenBytes.byteLength, + sha256: sha256(tokenBytes), + targets: {} + } + ] + }] } function downloadableCatalog( @@ -58,8 +82,12 @@ function downloadableCatalog( quality: 'balanced', speed: 'balanced', recommended: false, - repositoryUrl: - 'https://modelscope.cn/models/example/download-test-model', + repositoryUrls: { + modelscope: + 'https://modelscope.cn/models/example/download-test-model', + 'hugging-face': + 'https://huggingface.co/example/download-test-model' + }, license: { name: 'MIT License', notice: 'Test-only model metadata.', @@ -70,23 +98,53 @@ function downloadableCatalog( { name: 'model.onnx', role: 'model', - download: { - url: - 'https://modelscope.cn/models/example/download-test-model/' + - `resolve/${'a'.repeat(40)}/model.onnx`, - size: modelBytes.byteLength, - sha256: sha256(modelBytes) + size: modelBytes.byteLength, + sha256: sha256(modelBytes), + targets: { + modelscope: { + url: + 'https://modelscope.cn/models/example/download-test-model/' + + `resolve/${'a'.repeat(40)}/model.onnx`, + repositoryUrl: + 'https://modelscope.cn/models/example/download-test-model', + revision: 'a'.repeat(40), + redirectHosts: [] + }, + 'hugging-face': { + url: + 'https://huggingface.co/example/download-test-model/' + + `resolve/${'b'.repeat(40)}/model.onnx`, + repositoryUrl: + 'https://huggingface.co/example/download-test-model', + revision: 'b'.repeat(40), + redirectHosts: [] + } } }, { name: 'tokens.txt', role: 'tokens', - download: { - url: - 'https://modelscope.cn/models/example/download-test-model/' + - `resolve/${'a'.repeat(40)}/tokens.txt`, - size: tokenBytes.byteLength, - sha256: sha256(tokenBytes) + size: tokenBytes.byteLength, + sha256: sha256(tokenBytes), + targets: { + modelscope: { + url: + 'https://modelscope.cn/models/example/download-test-model/' + + `resolve/${'a'.repeat(40)}/tokens.txt`, + repositoryUrl: + 'https://modelscope.cn/models/example/download-test-model', + revision: 'a'.repeat(40), + redirectHosts: [] + }, + 'hugging-face': { + url: + 'https://huggingface.co/example/download-test-model/' + + `resolve/${'b'.repeat(40)}/tokens.txt`, + repositoryUrl: + 'https://huggingface.co/example/download-test-model', + revision: 'b'.repeat(40), + redirectHosts: [] + } } } ] @@ -131,10 +189,19 @@ describe('speech model catalog', () => { license: { name: 'MIT License' } }) expect( - senseVoice?.files.every((file) => file.download !== undefined) + senseVoice?.files.every( + (file) => + file.targets.modelscope !== undefined && + file.targets['hugging-face'] !== undefined + ) + ).toBe(true) + expect( + whisper?.files.every( + (file) => + file.targets.modelscope !== undefined && + file.targets['hugging-face'] !== undefined + ) ).toBe(true) - expect(whisper?.files.every((file) => file.download !== undefined)) - .toBe(true) expect(whisper?.files.map((file) => file.name)).toEqual([ 'tiny-encoder.int8.onnx', 'tiny-decoder.int8.onnx', @@ -163,15 +230,21 @@ describe('speech model catalog', () => { }) expect(SPEECH_MODEL_CATALOG).toHaveLength(6) for (const entry of SPEECH_MODEL_CATALOG) { - expect(entry.repositoryUrl).toMatch( - /^https:\/\/(?:modelscope\.cn\/models\/|huggingface\.co\/)/u - ) for (const file of entry.files) { - expect(file.download?.url).toMatch( - /^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u - ) + expect(file.sha256).toMatch(/^[a-f0-9]{64}$/u) + expect(file.size).toBeGreaterThan(0) + for (const target of Object.values(file.targets)) { + expect(target?.url).toMatch( + /^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u + ) + } } } + expect( + paraformerBilingual?.files.some( + (file) => file.targets.modelscope !== undefined + ) + ).toBe(false) }) }) @@ -242,10 +315,14 @@ describe('SpeechModelManager downloads', () => { ).toBe(false) await manager.select('download-test-model') - await expect(manager.snapshot()).resolves.toMatchObject({ + const snapshot = await manager.snapshot() + expect(snapshot).toMatchObject({ + selectedDownloadSource: 'modelscope', selectedModelId: 'download-test-model', operations: [] }) + expect(snapshot.catalog[0]?.files[0]).not.toHaveProperty('targets') + expect(JSON.stringify(snapshot.catalog)).not.toContain('/resolve/') await manager.remove('download-test-model') await expect(manager.snapshot()).resolves.toMatchObject({ selectedModelId: null, @@ -253,7 +330,63 @@ describe('SpeechModelManager downloads', () => { }) }) - it('accepts arbitrary HTTP hosts and cross-host redirects', async () => { + it('freezes the operation source when the global setting changes', async () => { + const userData = await temporaryDirectory() + const modelBytes = new TextEncoder().encode('expected') + const tokenBytes = new TextEncoder().encode('tokens') + let selectedSource: 'modelscope' | 'hugging-face' = 'modelscope' + let releaseFirstRequest: (() => void) | undefined + let markFirstRequestStarted: (() => void) | undefined + const firstRequestStarted = new Promise((resolveStarted) => { + markFirstRequestStarted = resolveStarted + }) + const firstRequestGate = new Promise((resolveRequest) => { + releaseFirstRequest = resolveRequest + }) + let requestCount = 0 + const transport = vi.fn(async (input) => { + requestCount += 1 + if (requestCount === 1) { + markFirstRequestStarted?.() + await firstRequestGate + } + const bytes = String(input).endsWith('model.onnx') + ? modelBytes + : tokenBytes + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) } + }) + }) + const manager = new SpeechModelManager({ + userDataDirectory: userData, + catalog: downloadableCatalog(modelBytes, tokenBytes), + fetch: transport, + getDownloadSource: () => selectedSource + }) + + const installing = manager.install('download-test-model') + await firstRequestStarted + selectedSource = 'hugging-face' + await expect(manager.snapshot()).resolves.toMatchObject({ + selectedDownloadSource: 'hugging-face', + operations: [ + { + modelId: 'download-test-model', + kind: 'download', + downloadSource: 'modelscope' + } + ] + }) + releaseFirstRequest?.() + await installing + expect( + transport.mock.calls.every( + ([input]) => new URL(String(input)).hostname === 'modelscope.cn' + ) + ).toBe(true) + }) + + it('follows only source-declared HTTPS redirect hosts', async () => { const userData = await temporaryDirectory() const modelBytes = new TextEncoder().encode('expected') const tokenBytes = new TextEncoder().encode('tokens') @@ -261,24 +394,24 @@ describe('SpeechModelManager downloads', () => { ...entry, files: entry.files.map((file) => ({ ...file, - download: file.download - ? { - ...file.download, - url: file.download.url.replace( - 'https://modelscope.cn', - 'http://models.internal.example' - ) - } - : undefined + targets: { + ...file.targets, + 'hugging-face': file.targets['hugging-face'] + ? { + ...file.targets['hugging-face'], + redirectHosts: ['cdn-lfs.hf.co'] + } + : undefined + } })) })) const transport = vi.fn(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(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() + 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( + 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') diff --git a/src/main/speech/speech-model-manager.ts b/src/main/speech/speech-model-manager.ts index 545777a..d54bb82 100644 --- a/src/main/speech/speech-model-manager.ts +++ b/src/main/speech/speech-model-manager.ts @@ -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 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 private readonly maxFileBytes: number private readonly operations = new Map() @@ -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 { 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 { 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 - } => 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 { 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 { - 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 - }, + file: ResolvedModelArtifactFile, destination: string, operation: ActiveOperation, signal: AbortSignal ): Promise { if ( - file.download.size > this.maxFileBytes || - file.download.size <= 0 + file.size > this.maxFileBytes || + file.size <= 0 ) { throw new RangeError(`模型文件大小超出限制:${file.name}`) } - const response = await this.fetchFollowingRedirects( - file.download.url, - signal - ) + const response = await fetchModelDownloadResponse({ + transport: this.transport, + initialUrl: file.target.url, + redirectHosts: file.target.redirectHosts, + signal, + modelLabel: '模型' + }) if (!response.ok) { await response.body?.cancel().catch(() => undefined) throw new Error(`模型下载失败:HTTP ${response.status}`) @@ -666,7 +669,7 @@ export class SpeechModelManager { const parsedLength = Number(declaredLength) if ( !Number.isSafeInteger(parsedLength) || - parsedLength !== file.download.size + parsedLength !== file.size ) { await response.body.cancel().catch(() => undefined) throw new Error(`模型文件大小不匹配:${file.name}`) @@ -687,7 +690,7 @@ export class SpeechModelManager { } written += result.value.byteLength if ( - written > file.download.size || + written > file.size || written > this.maxFileBytes ) { await reader.cancel() @@ -703,10 +706,10 @@ export class SpeechModelManager { } finally { await handle.close() } - if (written !== file.download.size) { + if (written !== file.size) { throw new Error(`模型文件大小不匹配:${file.name}`) } - if (hash.digest('hex') !== file.download.sha256) { + if (hash.digest('hex') !== file.sha256) { throw new Error(`模型文件校验失败:${file.name}`) } await rename(partialPath, destination) @@ -741,10 +744,9 @@ export class SpeechModelManager { throw new RangeError(`模型文件大小无效:${expectedFile.name}`) } if ( - expectedFile.download && - (sourceFileInfo.size !== expectedFile.download.size || - (await hashFile(sourceFile, signal)).sha256 !== - expectedFile.download.sha256) + sourceFileInfo.size !== expectedFile.size || + (await hashFile(sourceFile, signal)).sha256 !== + expectedFile.sha256 ) { throw new Error(`本地模型文件校验失败:${expectedFile.name}`) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 071f839..ba61c06 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, - install: (modelId: string) => + install: ( + modelId: string, + expectedDownloadSource: ModelDownloadSource + ) => ipcRenderer.invoke( ipcChannels.speechModelsInstall, - { modelId } + { modelId, expectedDownloadSource } ) as Promise, cancel: (modelId: string) => ipcRenderer.invoke( @@ -481,10 +485,13 @@ const desktopApi: DesktopApi = { ipcChannels.documentParsingTest, { purpose } ) as Promise, - installOcrModel: (modelId: string) => + installOcrModel: ( + modelId: string, + expectedDownloadSource: ModelDownloadSource + ) => ipcRenderer.invoke( ipcChannels.documentOcrModelsInstall, - { modelId } + { modelId, expectedDownloadSource } ) as Promise, cancelOcrModelOperation: (modelId: string) => ipcRenderer.invoke( diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 457c065..4c53cd4 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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: '显示魔法笔记入口' }) diff --git a/src/renderer/src/DocumentParsingSettingsSection.test.tsx b/src/renderer/src/DocumentParsingSettingsSection.test.tsx index d5de2ec..32b94ce 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.test.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.test.tsx @@ -31,8 +31,6 @@ const modelEntry = { quality: 'basic' as const, speed: 'fast' as const, recommended: false, - repositoryUrl: - 'https://modelscope.cn/models/PaddlePaddle/PP-OCRv6_tiny_rec_onnx', license: { name: 'Apache License 2.0', notice: '使用前请阅读模型许可证。', @@ -42,29 +40,32 @@ const modelEntry = { { name: 'detection.onnx', role: 'detection' as const, - download: { - url: 'https://modelscope.cn/models/example/detection.onnx', - size: 1_000, - sha256: 'a'.repeat(64) - } + size: 1_000, + sha256: 'a'.repeat(64) }, { name: 'recognition.onnx', role: 'recognition' as const, - download: { - url: 'https://modelscope.cn/models/example/recognition.onnx', - size: 2_000, - sha256: 'b'.repeat(64) - } + size: 2_000, + sha256: 'b'.repeat(64) }, { name: 'dictionary.yml', role: 'dictionary' as const, - download: { - url: 'https://modelscope.cn/models/example/dictionary.yml', - size: 500, - sha256: 'c'.repeat(64) - } + size: 500, + sha256: 'c'.repeat(64) + } + ], + downloadAvailability: [ + { + source: 'modelscope' as const, + available: true, + totalBytes: 3_500 + }, + { + source: 'hugging-face' as const, + available: true, + totalBytes: 3_500 } ] } @@ -101,6 +102,7 @@ const snapshot: DocumentParsingSnapshot = { }, ocrModels: { rootDirectory: 'C:\\Users\\test\\models\\document-ocr', + selectedDownloadSource: 'modelscope', catalog: [modelEntry, secondModelEntry, thirdModelEntry], installed: [ { @@ -111,8 +113,8 @@ const snapshot: DocumentParsingSnapshot = { files: secondModelEntry.files.map((file) => ({ name: file.name, role: file.role, - size: file.download.size, - sha256: file.download.sha256 + size: file.size, + sha256: file.sha256 })) } ], @@ -137,7 +139,7 @@ const test = vi.fn(async () => ({ warnings: [] })) const installOcrModel = - vi.fn<() => Promise>(async () => ({ + vi.fn(async (): Promise => ({ ...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 ] diff --git a/src/renderer/src/DocumentParsingSettingsSection.tsx b/src/renderer/src/DocumentParsingSettingsSection.tsx index ffdacff..695d168 100644 --- a/src/renderer/src/DocumentParsingSettingsSection.tsx +++ b/src/renderer/src/DocumentParsingSettingsSection.tsx @@ -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() @@ -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' + )} ) })} @@ -720,6 +739,13 @@ export function DocumentParsingSettingsSection({ {snapshot.ocrModels.rootDirectory} {t('documentParsing.ocr.storageSuffix')}

+

+ {t('documentParsing.ocr.downloadSource', { + source: t( + `modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}` + ) + })} +

{model ? (
@@ -735,7 +761,11 @@ export function DocumentParsingSettingsSection({

{modelDescription}

- ModelScope + + {t( + `modelDownloadSources.${snapshot.ocrModels.selectedDownloadSource}` + )} + {model.languages .map((language) => @@ -774,9 +804,15 @@ export function DocumentParsingSettingsSection({
@@ -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')} @@ -879,41 +924,61 @@ export function DocumentParsingSettingsSection({ ) : ( <> - + } + type="button" + > +
+ ) : ( + !error && ( +

+ {t('platformFeatures.loading')} +

+ ) + )} ) diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 3be79c0..bbde524 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -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( + {})} + 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( + {})} + 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( + {})} + 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: '显示魔法笔记入口' diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index f0ca4aa..5a8a184 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -1610,6 +1610,7 @@ export function SettingsPanel({ {activeTab === 'platform-features' && ( )} {activeTab === 'runtime' && ( @@ -2924,6 +2925,9 @@ export function SettingsPanel({ {modelType === 'speech' && ( + setActiveTab('platform-features') + } onSelectedModelIdChange={(modelId, changed) => { setSpeechModelDraftId(modelId) setSpeechModelSelectionDirty(changed) @@ -3119,7 +3123,12 @@ export function SettingsPanel({ )} {activeTab === 'document-parsing' && ( - + + setActiveTab('platform-features') + } + /> )} {activeTab === 'security' && ( diff --git a/src/renderer/src/SpeechModelSettingsSection.test.tsx b/src/renderer/src/SpeechModelSettingsSection.test.tsx index 75b910a..27ba40a 100644 --- a/src/renderer/src/SpeechModelSettingsSection.test.tsx +++ b/src/renderer/src/SpeechModelSettingsSection.test.tsx @@ -21,7 +21,6 @@ const entry = { quality: 'high' as const, speed: 'fast' as const, recommended: true, - repositoryUrl: 'https://huggingface.co/example/model', license: { name: '模型仓库自定义许可', notice: '使用前请阅读许可。', @@ -32,26 +31,33 @@ const entry = { { name: 'model.int8.onnx', role: 'model' as const, - download: { - url: 'https://huggingface.co/example/model/resolve/revision/model.int8.onnx', - size: 1_000, - sha256: 'a'.repeat(64) - } + size: 1_000, + sha256: 'a'.repeat(64) }, { name: 'tokens.txt', role: 'tokens' as const, - download: { - url: 'https://huggingface.co/example/model/resolve/revision/tokens.txt', - size: 100, - sha256: 'b'.repeat(64) - } + size: 100, + sha256: 'b'.repeat(64) + } + ], + downloadAvailability: [ + { + source: 'modelscope' as const, + available: true, + totalBytes: 1_100 + }, + { + source: 'hugging-face' as const, + available: true, + totalBytes: 1_100 } ] } const snapshot: SpeechModelSnapshot = { rootDirectory: 'C:\\Users\\test\\models\\speech', + selectedDownloadSource: 'modelscope', catalog: [entry], installed: [], operations: [], @@ -105,7 +111,7 @@ describe('SpeechModelSettingsSection', () => { expect(screen.queryByText('Model details')).not.toBeInTheDocument() fireEvent.click( screen.getByRole('button', { - name: 'Open the SenseVoiceSmall INT8 model repository' + name: 'Open the ModelScope repository for SenseVoiceSmall INT8' }) ) expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8') @@ -159,7 +165,10 @@ describe('SpeechModelSettingsSection', () => { })) await waitFor(() => - expect(install).toHaveBeenCalledWith('sensevoice-small-int8') + expect(install).toHaveBeenCalledWith( + 'sensevoice-small-int8', + 'modelscope' + ) ) expect(onNotify).toHaveBeenCalledWith({ tone: 'success', @@ -176,7 +185,7 @@ describe('SpeechModelSettingsSection', () => { family: 'whisper' as const, files: [ { - ...entry.files[0], + ...entry.files[0]!, name: 'tiny-encoder.int8.onnx', role: 'encoder' as const } @@ -212,10 +221,81 @@ describe('SpeechModelSettingsSection', () => { })) await waitFor(() => - expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual') + expect(install).toHaveBeenCalledWith( + 'whisper-tiny-multilingual', + 'modelscope' + ) ) }) + it('keeps an unavailable source explicit and offers General settings', async () => { + await changeUiLocale('zh-CN') + const onOpenModelDownloadSourceSettings = vi.fn() + const unavailableSnapshot: SpeechModelSnapshot = { + ...snapshot, + catalog: [ + { + ...entry, + downloadAvailability: [ + { + source: 'modelscope', + available: false, + unavailableReason: + '当前下载源暂不提供此模型的完整已验证文件' + }, + { + source: 'hugging-face', + available: true, + totalBytes: 1_100 + } + ] + } + ] + } + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { + speechModels: { + getSnapshot: vi.fn(async () => unavailableSnapshot), + install: vi.fn(), + cancel: vi.fn(async () => true), + remove: vi.fn(), + select: vi.fn(), + importArchive: vi.fn(), + exportArchive: vi.fn(), + openRepository: vi.fn(), + openModelsDirectory: vi.fn() + } + } as unknown as DesktopApi + }) + + render( + + ) + + expect( + await screen.findByText('当前来源不可下载') + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: '下载 SenseVoiceSmall INT8' + }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { + name: '打开 SenseVoiceSmall INT8 的 ModelScope 模型仓库' + }) + ).toBeDisabled() + fireEvent.click( + screen.getByRole('button', { name: '前往通用设置' }) + ) + expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce() + }) + it('imports and exports verified speech model ZIP archives', async () => { const installedSnapshot: SpeechModelSnapshot = { ...snapshot, @@ -306,7 +386,8 @@ describe('SpeechModelSettingsSection', () => { phase: 'transferring', currentFile: 'model.int8.onnx', completedBytes: 550, - totalBytes: 1_100 + totalBytes: 1_100, + downloadSource: 'modelscope' } ] } @@ -332,6 +413,7 @@ describe('SpeechModelSettingsSection', () => { expect(await screen.findByRole('progressbar', { name: 'SenseVoiceSmall INT8下载进度' })).toHaveValue(50) + expect(screen.getByText('正在从 ModelScope 下载')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '取消 SenseVoiceSmall INT8 操作' })) @@ -350,7 +432,8 @@ describe('SpeechModelSettingsSection', () => { phase: 'transferring', currentFile: 'model.int8.onnx', completedBytes: 550, - totalBytes: 1_100 + totalBytes: 1_100, + downloadSource: 'modelscope' } ] } diff --git a/src/renderer/src/SpeechModelSettingsSection.tsx b/src/renderer/src/SpeechModelSettingsSection.tsx index fcface4..db0308f 100644 --- a/src/renderer/src/SpeechModelSettingsSection.tsx +++ b/src/renderer/src/SpeechModelSettingsSection.tsx @@ -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() @@ -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 (
{snapshot.rootDirectory} {t('speech.storageSuffix')}

+

+ {t('speech.downloadSource', { + source: t( + `modelDownloadSources.${snapshot.selectedDownloadSource}` + ) + })} +

{error &&

{error}

}