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