From 62f9a3b1e84455c32971272c03163ca543e2f68f Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 03 Jun 2026 14:44:26 +0800
Subject: [PATCH] Implement Round 4 Parakeet model selection
---
02-P-NBL/round-4/code-review.md | 38 ++
02-P-NBL/round-4/logs/git-diff-check-20260603-round4.log | 0
02-P-NBL/round-4/qa-handoff.md | 73 ++++
privatevoice.src/internal/model/profile.go | 1
privatevoice.src/internal/modelselection/selection_test.go | 19 +
privatevoice.src/internal/engine/engine_darwin.go | 5
privatevoice.src/internal/engine/engine_darwin_test.go | 51 +++
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte | 229 ++++++++++++++
privatevoice.src/internal/model/registry.go | 56 +++
02-P-NBL/round-4/logs/npm-run-build-20260603-round4.log | 38 ++
02-P-NBL/round-4/logs/npm-ci-20260603-round4.log | 12
02-P-NBL/round-4/todo-list.md | 34 +-
privatevoice.src/internal/model/model_test.go | 69 ++++
privatevoice.src/internal/modelselection/selection.go | 35 +
02-P-NBL/round-4/dev-self-check.md | 82 +++++
privatevoice.src/app.go | 2
privatevoice.src/services/engine_service.go | 136 +++++++
privatevoice.src/internal/engine/engine.go | 2
02-P-NBL/round-4/logs/go-test-all-20260603-round4.log | 23 +
privatevoice.src/frontend/src/lib/i18n/zh.json | 17 +
privatevoice.src/frontend/src/lib/i18n/en.json | 17 +
privatevoice.src/services/config_service.go | 2
22 files changed, 891 insertions(+), 50 deletions(-)
diff --git a/02-P-NBL/round-4/code-review.md b/02-P-NBL/round-4/code-review.md
new file mode 100644
index 0000000..9153635
--- /dev/null
+++ b/02-P-NBL/round-4/code-review.md
@@ -0,0 +1,38 @@
+# Round 4 代码复审记录:Parakeet English 高级模型
+
+状态:未发现研发自测阻断问题,待 QA 真机验证
+日期:2026-06-03
+
+## 1. 复审结论
+
+本轮代码满足 Round 4 PRD 的研发交付边界,可以进入 QA。
+
+## 2. 已重点复核
+
+- English 默认模型保持为 `moonshine-en`。
+- `parakeet-en` 只挂在 English 的 `UpgradeModelIDs` 下。
+- 中文语言 profile 未加入 Parakeet。
+- 模型选择 API 会校验目标模型是否属于当前语言。
+- 手动选择 Parakeet 后,如果当前有效语言变为中文,resolver 会回落到中文默认 SenseVoice。
+- 未安装模型不能直接选择。
+- Parakeet 下载失败不会写入 `SelectedModelID`。
+- Parakeet required files 缺失时不会被判定为可用模型。
+- Nemo transducer config 设置了 encoder、decoder、joiner、tokens、model type。
+
+## 3. 残余风险
+
+- Parakeet 的真实 sherpa-onnx 初始化必须依赖真机下载后的模型文件验证,单元测试只能验证 config wiring。
+- 已安装但文件内容损坏的 Parakeet 仍可能在引擎初始化阶段失败;当前行为是显示引擎错误,QA 需要确认用户可以切回 Moonshine。
+- Parakeet 约 478 MiB 下载包,首次下载耗时和失败恢复需要真实网络测试。
+
+## 4. 代码范围
+
+- `privatevoice.src/internal/model/*`
+- `privatevoice.src/internal/modelselection/*`
+- `privatevoice.src/internal/engine/*`
+- `privatevoice.src/services/engine_service.go`
+- `privatevoice.src/services/config_service.go`
+- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+
diff --git a/02-P-NBL/round-4/dev-self-check.md b/02-P-NBL/round-4/dev-self-check.md
new file mode 100644
index 0000000..e801e3f
--- /dev/null
+++ b/02-P-NBL/round-4/dev-self-check.md
@@ -0,0 +1,82 @@
+# Round 4 研发自查报告:Parakeet English 高级模型
+
+状态:研发自测通过,待 QA 真机验证
+日期:2026-06-03
+分支:`codex/round4-parakeet-english`
+基线:Round 3 完成 commit `7bedb18`
+
+## 1. 本轮已实现
+
+- 新增 `parakeet-en` 模型 profile。
+- English 默认模型仍为 `moonshine-en`。
+- English 高级模型新增 `Parakeet English`。
+- 新增 `nemo_transducer` backend kind。
+- 新增 Parakeet required files 校验:
+ - `encoder.int8.onnx`
+ - `decoder.int8.onnx`
+ - `joiner.int8.onnx`
+ - `tokens.txt`
+- 新增 sherpa-onnx Nemo transducer 配置生成。
+- 新增模型选择服务 API:
+ - `ListModelOptions`
+ - `SelectModel`
+ - `DownloadModelByID`
+- 设置页新增“识别模型”区域。
+- English 下显示 Moonshine + Parakeet。
+- 中文下只显示 SenseVoice。
+- 语言切换会回到该语言默认模型,避免中文环境继续使用英文手动模型。
+- 下载 Parakeet 成功后才写入手动模型选择配置,下载失败不改写当前模型配置。
+
+## 2. 自动化验证
+
+已通过:
+
+```bash
+cd privatevoice.src
+go test ./... -count=1
+```
+
+日志:`logs/go-test-all-20260603-round4.log`
+
+已通过:
+
+```bash
+cd privatevoice.src/frontend
+npm run build
+```
+
+日志:`logs/npm-run-build-20260603-round4.log`
+
+已通过:
+
+```bash
+git diff --check
+```
+
+日志:`logs/git-diff-check-20260603-round4.log`
+
+依赖安装:
+
+```bash
+cd privatevoice.src/frontend
+npm ci
+```
+
+日志:`logs/npm-ci-20260603-round4.log`
+
+## 3. 观察到的非阻断项
+
+- `npm ci` 报告 6 个 npm audit vulnerability,来自现有前端依赖链;本轮未升级依赖,不作为 Round 4 阻断项。
+- `npm run build` 仍报告 3 个既有 Svelte a11y warning,位置在 `UpdateDialog.svelte` 和 `ToggleSwitch.svelte`;本轮新增设置页没有构建 warning。
+
+## 4. 待 QA 真机验证
+
+- Parakeet 官方包真实下载。
+- Parakeet 文件解压和安装状态识别。
+- Parakeet 引擎初始化。
+- Parakeet 英文识别。
+- Parakeet 断网英文识别。
+- Parakeet 切回 Moonshine。
+- 切回中文后 SenseVoice 正常。
+- 下载失败和中断下载不破坏 Moonshine/SenseVoice。
+
diff --git a/02-P-NBL/round-4/logs/git-diff-check-20260603-round4.log b/02-P-NBL/round-4/logs/git-diff-check-20260603-round4.log
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/02-P-NBL/round-4/logs/git-diff-check-20260603-round4.log
diff --git a/02-P-NBL/round-4/logs/go-test-all-20260603-round4.log b/02-P-NBL/round-4/logs/go-test-all-20260603-round4.log
new file mode 100644
index 0000000..aa1fecb
--- /dev/null
+++ b/02-P-NBL/round-4/logs/go-test-all-20260603-round4.log
@@ -0,0 +1,23 @@
+? voicesnap [no test files]
+? voicesnap/internal/audio [no test files]
+ok voicesnap/internal/config 0.809s
+ok voicesnap/internal/correctioncsv 1.301s
+? voicesnap/internal/dock [no test files]
+ok voicesnap/internal/engine 2.348s
+ok voicesnap/internal/history 0.964s
+? voicesnap/internal/hotkey [no test files]
+? voicesnap/internal/input [no test files]
+ok voicesnap/internal/language 1.888s
+? voicesnap/internal/logger [no test files]
+ok voicesnap/internal/model 1.562s
+ok voicesnap/internal/modelselection 2.767s
+? voicesnap/internal/overlay [no test files]
+? voicesnap/internal/paths [no test files]
+? voicesnap/internal/permissions [no test files]
+? voicesnap/internal/singleinstance [no test files]
+? voicesnap/internal/sound [no test files]
+? voicesnap/internal/startup [no test files]
+? voicesnap/internal/textproc [no test files]
+? voicesnap/internal/updater [no test files]
+ok voicesnap/internal/userdict 3.094s
+? voicesnap/services [no test files]
diff --git a/02-P-NBL/round-4/logs/npm-ci-20260603-round4.log b/02-P-NBL/round-4/logs/npm-ci-20260603-round4.log
new file mode 100644
index 0000000..7b1134f
--- /dev/null
+++ b/02-P-NBL/round-4/logs/npm-ci-20260603-round4.log
@@ -0,0 +1,12 @@
+
+added 48 packages, and audited 49 packages in 3s
+
+7 packages are looking for funding
+ run `npm fund` for details
+
+6 vulnerabilities (3 moderate, 3 high)
+
+To address all issues, run:
+ npm audit fix
+
+Run `npm audit` for details.
diff --git a/02-P-NBL/round-4/logs/npm-run-build-20260603-round4.log b/02-P-NBL/round-4/logs/npm-run-build-20260603-round4.log
new file mode 100644
index 0000000..72f459e
--- /dev/null
+++ b/02-P-NBL/round-4/logs/npm-run-build-20260603-round4.log
@@ -0,0 +1,38 @@
+
+> privatevoice-input-frontend@2.1.21 build
+> vite build
+
+vite v6.4.1 building for production...
+transforming...
+2:42:18 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:28:0 Elements with the 'dialog' interactive role must have a tabindex value
+https://svelte.dev/e/a11y_interactive_supports_focus
+26:
+27: <!-- svelte-ignore a11y_click_events_have_key_events -->
+28: <div class="overlay" onclick={onclose} role="dialog">
+ ^
+29: <!-- svelte-ignore a11y_click_events_have_key_events -->
+30: <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+2:42:18 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:30:2 Non-interactive element `<div>` should not be assigned mouse or keyboard event listeners
+https://svelte.dev/e/a11y_no_noninteractive_element_interactions
+28: <div class="overlay" onclick={onclose} role="dialog">
+29: <!-- svelte-ignore a11y_click_events_have_key_events -->
+30: <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+ ^
+31: <h2 class="title">{t('update.title')}</h2>
+32: <p class="version">{t('update.version', { version: info.version })}</p>
+2:42:18 PM [vite-plugin-svelte] src/components/shared/ToggleSwitch.svelte:17:0 Buttons and links should either contain text or have an `aria-label`, `aria-labelledby` or `title` attribute
+https://svelte.dev/e/a11y_consider_explicit_label
+15: </script>
+16:
+17: <button
+ ^
+18: class="toggle"
+19: class:active={checked}
+✓ 163 modules transformed.
+rendering chunks...
+computing gzip size...
+dist/index.html 0.41 kB │ gzip: 0.27 kB
+dist/assets/app-icon-Bs-pS5hx.png 400.98 kB
+dist/assets/index-Bm3gykhy.css 35.00 kB │ gzip: 5.92 kB
+dist/assets/index-DUZkgsrv.js 142.07 kB │ gzip: 47.42 kB
+✓ built in 1.30s
diff --git a/02-P-NBL/round-4/qa-handoff.md b/02-P-NBL/round-4/qa-handoff.md
new file mode 100644
index 0000000..8e06777
--- /dev/null
+++ b/02-P-NBL/round-4/qa-handoff.md
@@ -0,0 +1,73 @@
+# Round 4 QA Handoff:Parakeet English 高级模型
+
+状态:待 QA 真机验证
+日期:2026-06-03
+
+## 1. QA 目标
+
+验证 English 用户可以下载、启用、离线使用 Parakeet English,并且可以回退到 Moonshine;中文 SenseVoice 路径不受影响。
+
+## 2. 关键模型信息
+
+模型 ID:`parakeet-en`
+
+显示名:`Parakeet English`
+
+Backend:`nemo_transducer`
+
+官方下载 URL:
+
+```text
+https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-unified-en-0.6b-int8-non-streaming.tar.bz2
+```
+
+下载包大小:`501350460 bytes`,约 `478 MiB`
+
+必需文件:
+
+- `encoder.int8.onnx`
+- `decoder.int8.onnx`
+- `joiner.int8.onnx`
+- `tokens.txt`
+
+## 3. QA 前置条件
+
+- 保留现有用户数据备份。
+- 当前测试包基于 `codex/round4-parakeet-english`。
+- 确认 Round 3 的 Moonshine/SenseVoice 功能在测试机上可用。
+- 测试前记录当前模型目录和配置文件状态。
+
+## 4. 建议测试路径
+
+1. 启动 App,进入设置页。
+2. 切到 English。
+3. 确认“识别模型”区域显示 Moonshine 和 Parakeet。
+4. 确认 Moonshine 是默认/当前模型。
+5. 点击 Parakeet 下载。
+6. 下载期间确认按钮禁用、进度显示、无重复下载。
+7. 下载完成后确认当前模型变为 Parakeet。
+8. 进行英文识别。
+9. 断网后重启 App。
+10. 继续进行英文识别,确认离线可用。
+11. 切回 Moonshine,确认英文识别仍可用。
+12. 切到中文,确认 SenseVoice 成为当前模型。
+13. 进行中文识别,确认 SenseVoice 路径无回归。
+
+## 5. 失败保护测试
+
+- 下载中断后,重启 App,确认仍使用原模型。
+- 下载中断后,确认 Parakeet 未被判定为已安装。
+- 删除 Parakeet 任一必需文件,确认模型状态不应显示为可用。
+- 中文状态下不得显示或启用 Parakeet。
+- 非法 model ID 调用选择 API 不应写坏配置。
+
+## 6. 通过标准
+
+- English 默认仍是 Moonshine。
+- Parakeet 下载成功后可以被选中并完成英文识别。
+- 断网后 Parakeet 英文识别仍可用。
+- 可以从 Parakeet 切回 Moonshine。
+- 切回中文后 SenseVoice 正常识别。
+- 失败下载不会破坏原模型。
+- 用户历史、词库、权限、热键配置不丢失。
+
diff --git a/02-P-NBL/round-4/todo-list.md b/02-P-NBL/round-4/todo-list.md
index bc14992..9d796e4 100644
--- a/02-P-NBL/round-4/todo-list.md
+++ b/02-P-NBL/round-4/todo-list.md
@@ -1,6 +1,6 @@
# Round 4 Todo List:Parakeet English 高级模型
-状态:进行中
+状态:研发自测通过,待 QA 真机验证
所属子项目:`02-P-NBL`
Round:Round 4
日期:2026-06-03
@@ -17,25 +17,25 @@
## 2. 研发实现
-- [ ] 新增 `parakeet-en` profile
-- [ ] 新增 `BackendNemoTransducer`
-- [ ] 新增 Parakeet required files 校验
-- [ ] 新增 Nemo transducer backend builder
-- [ ] 新增模型选择服务 API
-- [ ] 设置页新增模型选择 UI
-- [ ] English 可从 Moonshine 切到 Parakeet
-- [ ] English 可从 Parakeet 切回 Moonshine
-- [ ] Parakeet 下载失败不破坏已有模型
-- [ ] 中文 SenseVoice 路径不退化
+- [x] 新增 `parakeet-en` profile
+- [x] 新增 `BackendNemoTransducer`
+- [x] 新增 Parakeet required files 校验
+- [x] 新增 Nemo transducer backend builder
+- [x] 新增模型选择服务 API
+- [x] 设置页新增模型选择 UI
+- [x] English 可从 Moonshine 切到 Parakeet
+- [x] English 可从 Parakeet 切回 Moonshine
+- [x] Parakeet 下载失败不破坏已有模型
+- [x] 中文 SenseVoice 路径不退化
## 3. 开发自查
-- [ ] `go test ./... -count=1`
-- [ ] `npm run build`
-- [ ] `git diff --check`
-- [ ] 研发自查报告
-- [ ] 代码复审
-- [ ] QA handoff
+- [x] `go test ./... -count=1`
+- [x] `npm run build`
+- [x] `git diff --check`
+- [x] 研发自查报告
+- [x] 代码复审
+- [x] QA handoff
## 4. QA
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 1bff5ab..ae058d4 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -112,7 +112,7 @@
// Create services for Wails bindings
appService := services.NewAppService(app.cfg, appDisplayVersion)
configService := services.NewConfigService(app.cfg)
- engineService := services.NewEngineService()
+ engineService := services.NewEngineService(app.cfg)
app.engineService = engineService
hotkeyService := services.NewHotkeyService(app.cfg)
permissionService := services.NewPermissionService()
diff --git a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index 50d099b..3d78976 100755
--- a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { Call } from '@wailsio/runtime'
+ import { Call, Events } from '@wailsio/runtime'
import ToggleSwitch from '../shared/ToggleSwitch.svelte'
import { t, setLocale } from '../../lib/i18n'
import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode, languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config'
@@ -9,6 +9,16 @@
interface InputDevice {
name: string
isDefault: boolean
+ }
+
+ interface ModelOption {
+ modelID: string
+ displayName: string
+ tier: string
+ installed: boolean
+ isCurrent: boolean
+ isDefault: boolean
+ downloadSize?: string
}
let autoHideVal = $state(true)
@@ -29,6 +39,10 @@
let hintText = $state('')
let devices = $state<InputDevice[]>([])
let showDeviceDropdown = $state(false)
+ let modelOptions = $state<ModelOption[]>([])
+ let modelBusyID = $state('')
+ let modelProgress = $state(0)
+ let modelError = $state('')
const unsub1 = autoHide.subscribe(v => { autoHideVal = v })
const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v })
@@ -42,6 +56,13 @@
const unsub10 = languageMode.subscribe(v => { languageModeVal = v })
const unsub11 = languageID.subscribe(v => { languageIDVal = v })
const unsub12 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v })
+
+ Events.On('model:download-progress', (ev: any) => {
+ const data = ev?.data
+ if (data?.modelID && data.modelID === modelBusyID && typeof data.percent === 'number') {
+ modelProgress = data.percent
+ }
+ })
// Load actual device name from backend on mount
async function loadDeviceName() {
@@ -90,6 +111,7 @@
const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
applyLanguageSettings(lang)
} catch {}
+ await loadModelOptions()
settingsLoaded = true
}
loadSettings()
@@ -120,6 +142,64 @@
}
await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel')
} catch {}
+ }
+
+ async function loadModelOptions() {
+ try {
+ const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptions')
+ modelOptions = Array.isArray(options) ? options : []
+ } catch {
+ modelOptions = []
+ }
+ }
+
+ function modelDescription(modelID: string): string {
+ switch (modelID) {
+ case 'sensevoice-zh': return t('models.sensevoiceDesc')
+ case 'moonshine-en': return t('models.moonshineDesc')
+ case 'parakeet-en': return t('models.parakeetDesc')
+ default: return ''
+ }
+ }
+
+ function modelTierLabel(option: ModelOption): string {
+ if (option.isDefault) return t('settings.modelDefault')
+ if (option.tier === 'advanced') return t('settings.modelAdvanced')
+ return option.tier || ''
+ }
+
+ function modelActionLabel(option: ModelOption): string {
+ if (modelBusyID === option.modelID) {
+ if (!option.installed && modelProgress > 0) {
+ return `${Math.min(100, Math.max(0, modelProgress)).toFixed(0)}%`
+ }
+ return t('settings.modelWorking')
+ }
+ if (option.isCurrent) return t('settings.modelCurrent')
+ if (option.installed) return t('settings.modelUse')
+ return t('settings.modelDownload')
+ }
+
+ async function onModelAction(option: ModelOption) {
+ if (option.isCurrent || modelBusyID) return
+ modelBusyID = option.modelID
+ modelProgress = 0
+ modelError = ''
+ engineStatus.set('loading')
+ try {
+ if (option.installed) {
+ await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID)
+ } else {
+ await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID)
+ }
+ await loadModelOptions()
+ } catch (err: any) {
+ modelError = err?.message || String(err || t('settings.modelActionFailed'))
+ await loadModelOptions()
+ } finally {
+ modelBusyID = ''
+ modelProgress = 0
+ }
}
async function onAutoHideChange(checked: boolean) {
@@ -169,6 +249,7 @@
: await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
applyLanguageSettings(settings)
await syncEngineForCurrentModel()
+ await loadModelOptions()
} catch {
languageModeVal = prevMode
languageIDVal = prevID
@@ -374,6 +455,53 @@
</button>
</div>
</div>
+ </div>
+ {/if}
+
+ {#if settingsLoaded && modelOptions.length > 0}
+ <div class="section">
+ <div class="setting-row models-header">
+ <div class="setting-info">
+ <span class="setting-label">{t('settings.recognitionModel')}</span>
+ <span class="setting-desc">
+ {effectiveLanguageIDVal === 'en' ? t('settings.recognitionModelEnglishDesc') : t('settings.recognitionModelChineseDesc')}
+ </span>
+ </div>
+ </div>
+
+ <div class="model-list">
+ {#each modelOptions as option}
+ <div class="model-card" class:current={option.isCurrent}>
+ <div class="model-main">
+ <div class="model-title-row">
+ <span class="model-title">{option.displayName}</span>
+ <span class="model-pill" class:current={option.isCurrent}>
+ {option.isCurrent ? t('settings.modelCurrent') : modelTierLabel(option)}
+ </span>
+ </div>
+ <span class="model-desc">{modelDescription(option.modelID)}</span>
+ <span class="model-meta">
+ {option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')}
+ {#if option.downloadSize}
+ · {option.downloadSize}
+ {/if}
+ </span>
+ </div>
+ <button
+ class="model-action"
+ class:primary={!option.isCurrent}
+ disabled={option.isCurrent || !!modelBusyID}
+ onclick={() => onModelAction(option)}
+ >
+ {modelActionLabel(option)}
+ </button>
+ </div>
+ {/each}
+ </div>
+
+ {#if modelError}
+ <p class="model-error">{modelError}</p>
+ {/if}
</div>
{/if}
@@ -663,6 +791,105 @@
margin: var(--spacing-sm) 0;
}
+ .models-header {
+ align-items: flex-start;
+ }
+
+ .model-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .model-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+ min-height: 78px;
+ padding: 10px 12px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-secondary);
+ }
+
+ .model-card.current {
+ border-color: rgba(0, 122, 255, 0.38);
+ }
+
+ .model-main {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+ }
+
+ .model-title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .model-title {
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ }
+
+ .model-pill {
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ padding: 0 7px;
+ border-radius: 999px;
+ background: var(--color-bg-tertiary);
+ color: var(--color-secondary-label);
+ font-size: 11px;
+ font-weight: 500;
+ white-space: nowrap;
+ }
+
+ .model-pill.current {
+ background: rgba(52, 199, 89, 0.14);
+ color: var(--color-green);
+ }
+
+ .model-desc,
+ .model-meta {
+ font-size: var(--font-size-xs);
+ color: var(--color-tertiary-label);
+ }
+
+ .model-action {
+ min-width: 76px;
+ height: 30px;
+ padding: 0 12px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-tertiary);
+ color: var(--color-secondary-label);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ }
+
+ .model-action.primary {
+ background: var(--color-blue);
+ color: white;
+ }
+
+ .model-action:disabled {
+ cursor: not-allowed;
+ opacity: 0.72;
+ }
+
+ .model-error {
+ margin-top: 8px;
+ font-size: var(--font-size-xs);
+ color: var(--color-red);
+ }
+
/* Device selector */
.device-selector {
position: relative;
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index e0c9ba9..722f4b5 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -51,6 +51,18 @@
"languageAutoDesc": "Follow the system language, currently choosing only between Chinese and English",
"languageChineseDesc": "Use the Chinese interface with SenseVoice as the default offline model",
"languageEnglishDesc": "Use the English interface with Moonshine English as the default offline model",
+ "recognitionModel": "Recognition Model",
+ "recognitionModelChineseDesc": "Use the default lightweight Chinese offline model",
+ "recognitionModelEnglishDesc": "Choose the default lightweight model or a larger high-quality English model",
+ "modelDefault": "Default",
+ "modelAdvanced": "Advanced",
+ "modelCurrent": "Current",
+ "modelUse": "Use",
+ "modelDownload": "Download",
+ "modelWorking": "Working",
+ "modelInstalled": "Installed",
+ "modelNotInstalled": "Not installed",
+ "modelActionFailed": "Model action failed",
"autoHide": "Auto-hide Indicator",
"autoHideDesc": "Automatically hide the floating indicator after completion",
"soundFeedback": "Sound Feedback",
@@ -62,6 +74,11 @@
"hideDockIcon": "Hide Dock Icon",
"hideDockIconDesc": "Show PrivateVoice Input only in the menu bar"
},
+ "models": {
+ "sensevoiceDesc": "Lightweight Chinese offline model for Chinese and mixed Chinese-English input.",
+ "moonshineDesc": "Lightweight English offline model, recommended default.",
+ "parakeetDesc": "Higher quality English offline model for longer sentences and technical terms."
+ },
"permissions": {
"title": "Permission Check",
"subtitle": "PrivateVoice Input needs these permissions for recording, global hotkeys, and automatic text input",
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 207bdd3..c87c5db 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -51,6 +51,18 @@
"languageAutoDesc": "跟随系统语言,当前只会在中文和 English 之间选择",
"languageChineseDesc": "使用中文界面,并默认使用 SenseVoice 离线模型",
"languageEnglishDesc": "使用 English 界面,并默认使用 Moonshine English 离线模型",
+ "recognitionModel": "识别模型",
+ "recognitionModelChineseDesc": "使用默认轻量中文离线模型",
+ "recognitionModelEnglishDesc": "可选择默认轻量英文模型,或下载更高质量的英文模型",
+ "modelDefault": "默认",
+ "modelAdvanced": "高级",
+ "modelCurrent": "当前",
+ "modelUse": "使用",
+ "modelDownload": "下载",
+ "modelWorking": "处理中",
+ "modelInstalled": "已安装",
+ "modelNotInstalled": "未安装",
+ "modelActionFailed": "模型操作失败",
"autoHide": "自动隐藏指示器",
"autoHideDesc": "操作完成后自动隐藏浮动指示器",
"soundFeedback": "提示音",
@@ -62,6 +74,11 @@
"hideDockIcon": "隐藏 Dock 图标",
"hideDockIconDesc": "只在菜单栏显示私语输入图标"
},
+ "models": {
+ "sensevoiceDesc": "轻量中文离线模型,适合中文和中英混输。",
+ "moonshineDesc": "轻量英文离线模型,默认推荐。",
+ "parakeetDesc": "更高质量的英文离线模型,适合长句和技术词。"
+ },
"permissions": {
"title": "权限检查",
"subtitle": "私语输入需要这些权限才能稳定完成录音、全局热键和自动上屏",
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
index 8fe031d..b529332 100755
--- a/privatevoice.src/internal/engine/engine.go
+++ b/privatevoice.src/internal/engine/engine.go
@@ -90,7 +90,7 @@
func isSupportedBackend(backend string) bool {
switch backend {
- case model.BackendSenseVoice, model.BackendMoonshine:
+ case model.BackendSenseVoice, model.BackendMoonshine, model.BackendNemoTransducer:
return true
default:
return false
diff --git a/privatevoice.src/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
index 7e5b70f..7ba1394 100755
--- a/privatevoice.src/internal/engine/engine_darwin.go
+++ b/privatevoice.src/internal/engine/engine_darwin.go
@@ -62,6 +62,11 @@
config.ModelConfig.Moonshine.Encoder = resolved.Files["encoder"]
config.ModelConfig.Moonshine.UncachedDecoder = resolved.Files["uncached_decoder"]
config.ModelConfig.Moonshine.CachedDecoder = resolved.Files["cached_decoder"]
+ case model.BackendNemoTransducer:
+ config.ModelConfig.Transducer.Encoder = resolved.Files["encoder"]
+ config.ModelConfig.Transducer.Decoder = resolved.Files["decoder"]
+ config.ModelConfig.Transducer.Joiner = resolved.Files["joiner"]
+ config.ModelConfig.ModelType = model.BackendNemoTransducer
default:
return sherpa.OfflineRecognizerConfig{}, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
}
diff --git a/privatevoice.src/internal/engine/engine_darwin_test.go b/privatevoice.src/internal/engine/engine_darwin_test.go
new file mode 100644
index 0000000..d4d00d1
--- /dev/null
+++ b/privatevoice.src/internal/engine/engine_darwin_test.go
@@ -0,0 +1,51 @@
+//go:build darwin
+
+package engine
+
+import (
+ "path/filepath"
+ "testing"
+
+ "voicesnap/internal/model"
+)
+
+func TestOfflineConfigForParakeetUsesNemoTransducer(t *testing.T) {
+ root := t.TempDir()
+ files := map[string]string{
+ "encoder": filepath.Join(root, "encoder.int8.onnx"),
+ "decoder": filepath.Join(root, "decoder.int8.onnx"),
+ "joiner": filepath.Join(root, "joiner.int8.onnx"),
+ "tokens": filepath.Join(root, "tokens.txt"),
+ }
+ resolved := model.ResolvedModel{
+ ModelID: model.ParakeetModelID,
+ BackendKind: model.BackendNemoTransducer,
+ Profile: model.ModelProfile{
+ DisplayName: "Parakeet English",
+ BackendKind: model.BackendNemoTransducer,
+ ProviderOrder: []string{"cpu"},
+ NumThreads: 4,
+ },
+ Files: files,
+ }
+
+ config, err := offlineConfigForResolvedModel(resolved, "cpu")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.ModelConfig.Transducer.Encoder != files["encoder"] {
+ t.Fatalf("encoder = %q, want %q", config.ModelConfig.Transducer.Encoder, files["encoder"])
+ }
+ if config.ModelConfig.Transducer.Decoder != files["decoder"] {
+ t.Fatalf("decoder = %q, want %q", config.ModelConfig.Transducer.Decoder, files["decoder"])
+ }
+ if config.ModelConfig.Transducer.Joiner != files["joiner"] {
+ t.Fatalf("joiner = %q, want %q", config.ModelConfig.Transducer.Joiner, files["joiner"])
+ }
+ if config.ModelConfig.Tokens != files["tokens"] {
+ t.Fatalf("tokens = %q, want %q", config.ModelConfig.Tokens, files["tokens"])
+ }
+ if config.ModelConfig.ModelType != model.BackendNemoTransducer {
+ t.Fatalf("model type = %q, want %q", config.ModelConfig.ModelType, model.BackendNemoTransducer)
+ }
+}
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index b5273c2..b85c5ad 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/privatevoice.src/internal/model/model_test.go
@@ -39,6 +39,25 @@
}
}
+func TestRegistryReturnsParakeetEnglish(t *testing.T) {
+ profile, err := GetModelProfile(ParakeetModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if profile.BackendKind != BackendNemoTransducer {
+ t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendNemoTransducer)
+ }
+ if profile.InstallDirName != "parakeet-en" {
+ t.Fatalf("install dir = %q, want parakeet-en", profile.InstallDirName)
+ }
+ if profile.Tier != "advanced" {
+ t.Fatalf("tier = %q, want advanced", profile.Tier)
+ }
+ if len(profile.DownloadURLs) == 0 {
+ t.Fatal("expected parakeet download URL")
+ }
+}
+
func TestEnglishLanguageDefaultsToMoonshine(t *testing.T) {
profile, err := GetLanguageProfile(EnglishLanguageID)
if err != nil {
@@ -46,6 +65,9 @@
}
if profile.DefaultModelID != MoonshineModelID {
t.Fatalf("English default model = %q, want %q", profile.DefaultModelID, MoonshineModelID)
+ }
+ if len(profile.UpgradeModelIDs) != 1 || profile.UpgradeModelIDs[0] != ParakeetModelID {
+ t.Fatalf("English upgrade models = %v, want [%s]", profile.UpgradeModelIDs, ParakeetModelID)
}
}
@@ -132,6 +154,42 @@
result := ValidateModelDir(profile, dir)
if result.Valid {
t.Fatal("expected missing cached decoder to fail validation")
+ }
+}
+
+func TestValidateParakeetRequiredFiles(t *testing.T) {
+ profile, err := GetModelProfile(ParakeetModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ createValidParakeet(t, dir)
+
+ result := ValidateModelDir(profile, dir)
+ if !result.Valid {
+ t.Fatalf("expected valid parakeet dir, missing=%v problems=%v", result.Missing, result.Problems)
+ }
+ for _, role := range []string{"encoder", "decoder", "joiner", "tokens"} {
+ if result.Files[role] == "" {
+ t.Fatalf("missing resolved file role %q", role)
+ }
+ }
+}
+
+func TestValidateParakeetRejectsMissingFile(t *testing.T) {
+ profile, err := GetModelProfile(ParakeetModelID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ createValidParakeet(t, dir)
+ if err := os.Remove(filepath.Join(dir, "joiner.int8.onnx")); err != nil {
+ t.Fatal(err)
+ }
+
+ result := ValidateModelDir(profile, dir)
+ if result.Valid {
+ t.Fatal("expected missing joiner to fail validation")
}
}
@@ -428,6 +486,17 @@
writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
}
+func createValidParakeet(t *testing.T, dir string) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(t, filepath.Join(dir, "encoder.int8.onnx"), "encoder")
+ writeTestFile(t, filepath.Join(dir, "decoder.int8.onnx"), "decoder")
+ writeTestFile(t, filepath.Join(dir, "joiner.int8.onnx"), "joiner")
+ writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+}
+
func tarArchive(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
index 2e0dc4e..b9048d2 100644
--- a/privatevoice.src/internal/model/profile.go
+++ b/privatevoice.src/internal/model/profile.go
@@ -3,6 +3,7 @@
const (
DefaultModelID = "sensevoice-zh"
MoonshineModelID = "moonshine-en"
+ ParakeetModelID = "parakeet-en"
DefaultLanguageID = "zh-CN"
EnglishLanguageID = "en"
)
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
index 8740133..3d305c3 100644
--- a/privatevoice.src/internal/model/registry.go
+++ b/privatevoice.src/internal/model/registry.go
@@ -6,8 +6,9 @@
)
const (
- BackendSenseVoice = "sensevoice"
- BackendMoonshine = "moonshine"
+ BackendSenseVoice = "sensevoice"
+ BackendMoonshine = "moonshine"
+ BackendNemoTransducer = "nemo_transducer"
)
var modelProfiles = map[string]ModelProfile{
@@ -91,6 +92,44 @@
ProviderOrder: []string{"cpu"},
NumThreads: 4,
},
+ ParakeetModelID: {
+ ID: ParakeetModelID,
+ DisplayName: "Parakeet English",
+ BackendKind: BackendNemoTransducer,
+ Tier: "advanced",
+ SupportedLanguageIDs: []string{EnglishLanguageID},
+ RecommendedFor: []string{EnglishLanguageID},
+ Description: "高质量英文离线模型,适合英文长句、技术词和更高准确率需求。",
+ ApproxSize: "约 478 MiB 下载包",
+ DownloadURLs: []string{
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-unified-en-0.6b-int8-non-streaming.tar.bz2",
+ },
+ InstallDirName: "parakeet-en",
+ RequiredFiles: []RequiredFileRule{
+ {
+ Role: "encoder",
+ AllOf: []string{"encoder.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "decoder",
+ AllOf: []string{"decoder.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "joiner",
+ AllOf: []string{"joiner.int8.onnx"},
+ Required: true,
+ },
+ {
+ Role: "tokens",
+ AllOf: []string{"tokens.txt"},
+ Required: true,
+ },
+ },
+ ProviderOrder: []string{"cpu"},
+ NumThreads: 4,
+ },
}
var languageProfiles = map[string]LanguageProfile{
@@ -103,12 +142,13 @@
DefaultModelID: DefaultModelID,
},
EnglishLanguageID: {
- ID: EnglishLanguageID,
- DisplayName: "English",
- NativeName: "English",
- UILocale: "en",
- SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"},
- DefaultModelID: MoonshineModelID,
+ ID: EnglishLanguageID,
+ DisplayName: "English",
+ NativeName: "English",
+ UILocale: "en",
+ SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"},
+ DefaultModelID: MoonshineModelID,
+ UpgradeModelIDs: []string{ParakeetModelID},
},
}
diff --git a/privatevoice.src/internal/modelselection/selection.go b/privatevoice.src/internal/modelselection/selection.go
index 0347f37..48f1a9a 100644
--- a/privatevoice.src/internal/modelselection/selection.go
+++ b/privatevoice.src/internal/modelselection/selection.go
@@ -27,15 +27,20 @@
if selectionMode == config.ModelSelectionModeManual {
if profile, err := model.GetModelProfile(cfg.SelectedModelID); err == nil {
- return CurrentModel{
- ModelID: profile.ID,
- Profile: profile,
- SelectionMode: selectionMode,
- LanguageSettings: settings,
+ if isModelAvailableForLanguage(profile.ID, settings.EffectiveLanguageID) {
+ return CurrentModel{
+ ModelID: profile.ID,
+ Profile: profile,
+ SelectionMode: selectionMode,
+ LanguageSettings: settings,
+ }
}
+ fallbackReason = "manual model not available for language"
+ logger.Info("Manual SelectedModelID %q is not available for language %q, falling back to language default %q", cfg.SelectedModelID, settings.EffectiveLanguageID, modelID)
+ } else {
+ fallbackReason = "invalid manual model selection"
+ logger.Info("Invalid manual SelectedModelID %q, falling back to language default %q", cfg.SelectedModelID, modelID)
}
- fallbackReason = "invalid manual model selection"
- logger.Info("Invalid manual SelectedModelID %q, falling back to language default %q", cfg.SelectedModelID, modelID)
}
profile, err := model.GetModelProfile(modelID)
@@ -54,3 +59,19 @@
FallbackReason: fallbackReason,
}
}
+
+func isModelAvailableForLanguage(modelID, languageID string) bool {
+ profile, err := model.GetLanguageProfile(languageID)
+ if err != nil {
+ return false
+ }
+ if profile.DefaultModelID == modelID {
+ return true
+ }
+ for _, upgradeModelID := range profile.UpgradeModelIDs {
+ if upgradeModelID == modelID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/privatevoice.src/internal/modelselection/selection_test.go b/privatevoice.src/internal/modelselection/selection_test.go
index 47daac6..cd0fbc2 100644
--- a/privatevoice.src/internal/modelselection/selection_test.go
+++ b/privatevoice.src/internal/modelselection/selection_test.go
@@ -59,11 +59,26 @@
cfg := config.Default()
cfg.LanguageMode = config.LanguageModeAuto
cfg.ModelSelectionMode = config.ModelSelectionModeManual
- cfg.SelectedModelID = model.DefaultModelID
+ cfg.SelectedModelID = model.ParakeetModelID
current := Resolve(cfg, fakeDetector{locale: "en-US"})
+ if current.ModelID != model.ParakeetModelID {
+ t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.ParakeetModelID)
+ }
+}
+
+func TestResolveManualModelFallsBackWhenLanguageIncompatible(t *testing.T) {
+ cfg := config.Default()
+ cfg.LanguageMode = config.LanguageModeAuto
+ cfg.ModelSelectionMode = config.ModelSelectionModeManual
+ cfg.SelectedModelID = model.ParakeetModelID
+
+ current := Resolve(cfg, fakeDetector{locale: "zh-CN"})
if current.ModelID != model.DefaultModelID {
- t.Fatalf("model id = %q, want manual selected %q", current.ModelID, model.DefaultModelID)
+ t.Fatalf("model id = %q, want Chinese default %q", current.ModelID, model.DefaultModelID)
+ }
+ if current.FallbackReason == "" {
+ t.Fatal("expected fallback reason")
}
}
diff --git a/privatevoice.src/services/config_service.go b/privatevoice.src/services/config_service.go
index 49c53c6..5905f75 100755
--- a/privatevoice.src/services/config_service.go
+++ b/privatevoice.src/services/config_service.go
@@ -38,6 +38,7 @@
// SetLanguageAuto makes the UI language follow the system language.
func (s *ConfigService) SetLanguageAuto() language.LanguageSettings {
s.cfg.LanguageMode = config.LanguageModeAuto
+ s.cfg.ModelSelectionMode = config.ModelSelectionModeAuto
resolved := language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
s.cfg.LanguageID = resolved.EffectiveLanguageID
config.Save(s.cfg)
@@ -48,6 +49,7 @@
func (s *ConfigService) SetLanguageManual(languageID string) language.LanguageSettings {
s.cfg.LanguageMode = config.LanguageModeManual
s.cfg.LanguageID = model.NormalizeLanguageID(languageID)
+ s.cfg.ModelSelectionMode = config.ModelSelectionModeAuto
config.Save(s.cfg)
return language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
}
diff --git a/privatevoice.src/services/engine_service.go b/privatevoice.src/services/engine_service.go
index f44fe18..d40c951 100755
--- a/privatevoice.src/services/engine_service.go
+++ b/privatevoice.src/services/engine_service.go
@@ -15,6 +15,7 @@
// EngineService provides engine status and model management to the frontend.
type EngineService struct {
+ cfg *config.Config
app *application.App
initCallback func()
mu sync.RWMutex
@@ -23,8 +24,11 @@
lastError string
}
-func NewEngineService() *EngineService {
- return &EngineService{status: "loading"}
+func NewEngineService(cfg *config.Config) *EngineService {
+ if cfg == nil {
+ cfg = config.Default()
+ }
+ return &EngineService{cfg: cfg, status: "loading"}
}
// ModelExists returns true if the ASR model files are present.
@@ -44,7 +48,84 @@
func (s *EngineService) GetCurrentModelStatus() map[string]interface{} {
current := s.currentModel()
- resolved, err := model.ResolveModel(current.ModelID)
+ return s.modelStatusMap(current.Profile, current)
+}
+
+func (s *EngineService) ListModelOptions() []map[string]interface{} {
+ current := s.currentModel()
+ languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID)
+ if err != nil {
+ return nil
+ }
+
+ ids := make([]string, 0, 1+len(languageProfile.UpgradeModelIDs))
+ ids = append(ids, languageProfile.DefaultModelID)
+ ids = append(ids, languageProfile.UpgradeModelIDs...)
+
+ options := make([]map[string]interface{}, 0, len(ids))
+ for _, id := range ids {
+ profile, err := model.GetModelProfile(id)
+ if err != nil {
+ continue
+ }
+ options = append(options, s.modelStatusMap(profile, current))
+ }
+ return options
+}
+
+func (s *EngineService) SelectModel(modelID string) error {
+ profile, err := s.allowedModelProfile(modelID)
+ if err != nil {
+ return err
+ }
+ resolved, err := model.ResolveModel(profile.ID)
+ if err != nil {
+ return err
+ }
+ if !resolved.IsUsable() {
+ return fmt.Errorf("model %s is not installed", profile.ID)
+ }
+
+ s.cfg.ModelSelectionMode = config.ModelSelectionModeManual
+ s.cfg.SelectedModelID = profile.ID
+ config.Save(s.cfg)
+ s.ReloadCurrentModel()
+ return nil
+}
+
+func (s *EngineService) DownloadModelByID(modelID string) error {
+ profile, err := s.allowedModelProfile(modelID)
+ if err != nil {
+ return err
+ }
+ if len(profile.DownloadURLs) == 0 {
+ return fmt.Errorf("no download URL configured for model %s", profile.ID)
+ }
+
+ err = model.DownloadProfile(profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
+ if s.app != nil {
+ s.app.Event.Emit("model:download-progress", map[string]interface{}{
+ "percent": percent,
+ "downloaded": downloaded,
+ "total": total,
+ "modelID": profile.ID,
+ "modelName": profile.DisplayName,
+ })
+ }
+ })
+ if err != nil {
+ return err
+ }
+
+ s.cfg.ModelSelectionMode = config.ModelSelectionModeManual
+ s.cfg.SelectedModelID = profile.ID
+ config.Save(s.cfg)
+ s.ReloadCurrentModel()
+ return nil
+}
+
+func (s *EngineService) modelStatusMap(profile model.ModelProfile, current modelselection.CurrentModel) map[string]interface{} {
+ resolved, err := model.ResolveModel(profile.ID)
installed := err == nil && resolved.IsUsable()
status := model.ModelNotInstalled
var missing []string
@@ -56,19 +137,23 @@
}
return map[string]interface{}{
- "modelID": current.ModelID,
- "displayName": current.Profile.DisplayName,
- "backendKind": current.Profile.BackendKind,
+ "modelID": profile.ID,
+ "displayName": profile.DisplayName,
+ "backendKind": profile.BackendKind,
+ "tier": profile.Tier,
"languageID": current.LanguageSettings.EffectiveLanguageID,
"selectionMode": current.SelectionMode,
+ "isCurrent": profile.ID == current.ModelID,
+ "isDefault": profile.ID == current.LanguageSettings.DefaultModelID,
"installed": installed,
"installStatus": status,
"missing": missing,
"problems": problems,
"fallbackReason": current.FallbackReason,
- "downloadSize": current.Profile.ApproxSize,
- "supportedLanguages": current.Profile.SupportedLanguageIDs,
- "recommendedLanguage": current.Profile.RecommendedFor,
+ "downloadSize": profile.ApproxSize,
+ "supportedLanguages": profile.SupportedLanguageIDs,
+ "recommendedLanguage": profile.RecommendedFor,
+ "description": profile.Description,
}
}
@@ -145,9 +230,34 @@
}
func (s *EngineService) currentModel() modelselection.CurrentModel {
- cfg, err := config.Load()
- if err != nil {
- cfg = config.Default()
+ if s.cfg == nil {
+ cfg, err := config.Load()
+ if err != nil {
+ cfg = config.Default()
+ }
+ s.cfg = cfg
}
- return modelselection.Resolve(cfg, language.NewSystemDetector())
+ return modelselection.Resolve(s.cfg, language.NewSystemDetector())
+}
+
+func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) {
+ profile, err := model.GetModelProfile(modelID)
+ if err != nil {
+ return model.ModelProfile{}, err
+ }
+
+ current := s.currentModel()
+ languageProfile, err := model.GetLanguageProfile(current.LanguageSettings.EffectiveLanguageID)
+ if err != nil {
+ return model.ModelProfile{}, err
+ }
+ if profile.ID == languageProfile.DefaultModelID {
+ return profile, nil
+ }
+ for _, id := range languageProfile.UpgradeModelIDs {
+ if profile.ID == id {
+ return profile, nil
+ }
+ }
+ return model.ModelProfile{}, fmt.Errorf("model %s is not available for language %s", profile.ID, current.LanguageSettings.EffectiveLanguageID)
}
--
Gitblit v1.9.3