Implement Round 4 Parakeet model selection
14 files modified
8 files added
| New file |
| | |
| | | # 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` |
| | | |
| New file |
| | |
| | | # 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。 |
| | | |
| New file |
| | |
| | | ? 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] |
| New file |
| | |
| | | |
| | | 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. |
| New file |
| | |
| | | |
| | | > 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 |
| New file |
| | |
| | | # 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 正常识别。 |
| | | - 失败下载不会破坏原模型。 |
| | | - 用户历史、词库、权限、热键配置不丢失。 |
| | | |
| | |
| | | # Round 4 Todo List:Parakeet English 高级模型 |
| | | |
| | | 状态:进行中 |
| | | 状态:研发自测通过,待 QA 真机验证 |
| | | 所属子项目:`02-P-NBL` |
| | | Round:Round 4 |
| | | 日期:2026-06-03 |
| | |
| | | |
| | | ## 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 |
| | | |
| | |
| | | // 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() |
| | |
| | | <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' |
| | |
| | | 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) |
| | |
| | | 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 }) |
| | |
| | | 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() { |
| | |
| | | const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(lang) |
| | | } catch {} |
| | | await loadModelOptions() |
| | | settingsLoaded = true |
| | | } |
| | | loadSettings() |
| | |
| | | } |
| | | 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) { |
| | |
| | | : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection) |
| | | applyLanguageSettings(settings) |
| | | await syncEngineForCurrentModel() |
| | | await loadModelOptions() |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | |
| | | </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} |
| | | |
| | |
| | | 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; |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "languageAutoDesc": "跟随系统语言,当前只会在中文和 English 之间选择", |
| | | "languageChineseDesc": "使用中文界面,并默认使用 SenseVoice 离线模型", |
| | | "languageEnglishDesc": "使用 English 界面,并默认使用 Moonshine English 离线模型", |
| | | "recognitionModel": "识别模型", |
| | | "recognitionModelChineseDesc": "使用默认轻量中文离线模型", |
| | | "recognitionModelEnglishDesc": "可选择默认轻量英文模型,或下载更高质量的英文模型", |
| | | "modelDefault": "默认", |
| | | "modelAdvanced": "高级", |
| | | "modelCurrent": "当前", |
| | | "modelUse": "使用", |
| | | "modelDownload": "下载", |
| | | "modelWorking": "处理中", |
| | | "modelInstalled": "已安装", |
| | | "modelNotInstalled": "未安装", |
| | | "modelActionFailed": "模型操作失败", |
| | | "autoHide": "自动隐藏指示器", |
| | | "autoHideDesc": "操作完成后自动隐藏浮动指示器", |
| | | "soundFeedback": "提示音", |
| | |
| | | "hideDockIcon": "隐藏 Dock 图标", |
| | | "hideDockIconDesc": "只在菜单栏显示私语输入图标" |
| | | }, |
| | | "models": { |
| | | "sensevoiceDesc": "轻量中文离线模型,适合中文和中英混输。", |
| | | "moonshineDesc": "轻量英文离线模型,默认推荐。", |
| | | "parakeetDesc": "更高质量的英文离线模型,适合长句和技术词。" |
| | | }, |
| | | "permissions": { |
| | | "title": "权限检查", |
| | | "subtitle": "私语输入需要这些权限才能稳定完成录音、全局热键和自动上屏", |
| | |
| | | |
| | | func isSupportedBackend(backend string) bool { |
| | | switch backend { |
| | | case model.BackendSenseVoice, model.BackendMoonshine: |
| | | case model.BackendSenseVoice, model.BackendMoonshine, model.BackendNemoTransducer: |
| | | return true |
| | | default: |
| | | return false |
| | |
| | | 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) |
| | | } |
| New file |
| | |
| | | //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) |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | |
| | | } |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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") |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | const ( |
| | | DefaultModelID = "sensevoice-zh" |
| | | MoonshineModelID = "moonshine-en" |
| | | ParakeetModelID = "parakeet-en" |
| | | DefaultLanguageID = "zh-CN" |
| | | EnglishLanguageID = "en" |
| | | ) |
| | |
| | | const ( |
| | | BackendSenseVoice = "sensevoice" |
| | | BackendMoonshine = "moonshine" |
| | | BackendNemoTransducer = "nemo_transducer" |
| | | ) |
| | | |
| | | var modelProfiles = map[string]ModelProfile{ |
| | |
| | | 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{ |
| | |
| | | UILocale: "en", |
| | | SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"}, |
| | | DefaultModelID: MoonshineModelID, |
| | | UpgradeModelIDs: []string{ParakeetModelID}, |
| | | }, |
| | | } |
| | | |
| | |
| | | |
| | | if selectionMode == config.ModelSelectionModeManual { |
| | | if profile, err := model.GetModelProfile(cfg.SelectedModelID); err == nil { |
| | | if isModelAvailableForLanguage(profile.ID, settings.EffectiveLanguageID) { |
| | | return CurrentModel{ |
| | | ModelID: profile.ID, |
| | | Profile: profile, |
| | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | | profile, err := model.GetModelProfile(modelID) |
| | |
| | | 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 |
| | | } |
| | |
| | | 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") |
| | | } |
| | | } |
| | | |
| | |
| | | // 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) |
| | |
| | | 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) |
| | | } |
| | |
| | | |
| | | // EngineService provides engine status and model management to the frontend. |
| | | type EngineService struct { |
| | | cfg *config.Config |
| | | app *application.App |
| | | initCallback func() |
| | | mu sync.RWMutex |
| | |
| | | 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. |
| | |
| | | |
| | | 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 |
| | |
| | | } |
| | | |
| | | 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, |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | func (s *EngineService) currentModel() modelselection.CurrentModel { |
| | | if s.cfg == nil { |
| | | cfg, err := config.Load() |
| | | if err != nil { |
| | | cfg = config.Default() |
| | | } |
| | | return modelselection.Resolve(cfg, language.NewSystemDetector()) |
| | | s.cfg = cfg |
| | | } |
| | | 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) |
| | | } |