From 9a85354d8b41e05ba8729cd8f44592291de9117a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 03 Jun 2026 01:40:19 +0800
Subject: [PATCH] Implement Round 2 language settings
---
privatevoice.src/internal/model/profile.go | 5
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte | 101 +++
privatevoice.src/internal/language/language_test.go | 75 ++
02-P-NBL/round-2/qa-handoff-current.md | 129 ++++
privatevoice.src/frontend/src/lib/stores/config.ts | 4
privatevoice.src/frontend/src/lib/i18n/index.ts | 16
privatevoice.src/internal/language/detect.go | 46 +
privatevoice.src/internal/language/detect_other.go | 7
02-P-NBL/round-2/README.md | 51 +
privatevoice.src/frontend/src/lib/i18n/zh.json | 7
privatevoice.src/frontend/src/lib/i18n/en.json | 7
02-P-NBL/round-2/qa-test-plan.md | 222 +++++++
02-P-NBL/round-2/todo-list.md | 52 +
02-P-NBL/round-2/prd.md | 337 +++++++++++
privatevoice.src/internal/model/registry.go | 38 +
02-P-NBL/round-2/logs/npm-run-build-20260603.log | 38 +
privatevoice.src/internal/config/config.go | 25
privatevoice.src/internal/config/config_test.go | 29
privatevoice.src/internal/language/resolver.go | 62 ++
02-P-NBL/round-2/dev-checklist-current.md | 58 +
02-P-NBL/round-2/logs/go-test-all-20260603.log | 22
privatevoice.src/app.go | 3
privatevoice.src/frontend/src/App.svelte | 34 +
02-P-NBL/round-2/engineering-tasks.md | 328 ++++++++++
privatevoice.src/internal/language/detect_darwin.go | 34 +
privatevoice.src/services/config_service.go | 36 +
26 files changed, 1,746 insertions(+), 20 deletions(-)
diff --git a/02-P-NBL/round-2/README.md b/02-P-NBL/round-2/README.md
new file mode 100644
index 0000000..06ef6d3
--- /dev/null
+++ b/02-P-NBL/round-2/README.md
@@ -0,0 +1,51 @@
+# Round 2:语言检测与语言版本设置
+
+状态:启动稿
+所属子项目:`02-P-NBL`
+Round:Round 2
+日期:2026-06-03
+
+## 1. 本轮目标
+
+Round 2 的目标是建立 VoiceSnap 的“语言版本”概念:
+
+```text
+系统语言检测 -> LanguageProfile -> UI locale -> 默认语言配置
+```
+
+本轮只支持两个可见语言版本:
+
+- 中文
+- English
+
+本轮不接入 Moonshine、Parakeet、Qwen3-ASR,也不做模型选择页。模型仍然使用 Round 1 已验证通过的 `sensevoice-zh`。
+
+## 2. 文档
+
+- [`prd.md`](prd.md):产品需求与验收边界
+- [`engineering-tasks.md`](engineering-tasks.md):研发任务拆解
+- [`qa-test-plan.md`](qa-test-plan.md):QA 测试计划
+- [`todo-list.md`](todo-list.md):执行清单
+
+## 3. 进入条件
+
+Round 1 当前状态:
+
+- 功能性 QA PASS。
+- QA 包:PrivateVoice Input 2.1.19 build 20260602.2022。
+- 正式公开分发仍需 Developer ID 签名和 notarization。
+
+Round 2 可以在 Round 1 功能基线上启动研发。
+
+## 4. 本轮完成定义
+
+Round 2 完成时应满足:
+
+- 老用户升级后默认保持中文界面,不破坏设置、词库、历史、模型。
+- 新用户首次启动可根据系统语言解析为中文或 English。
+- 设置页提供语言版本选择:自动、中文、English。
+- 手动选择后持久化,重启后仍生效。
+- UI 文案语言跟随有效语言版本。
+- 不暴露 Moonshine、Parakeet、Qwen3-ASR。
+- 不删除、不迁移、不重新下载现有 SenseVoice 模型。
+
diff --git a/02-P-NBL/round-2/dev-checklist-current.md b/02-P-NBL/round-2/dev-checklist-current.md
new file mode 100644
index 0000000..76eb3c7
--- /dev/null
+++ b/02-P-NBL/round-2/dev-checklist-current.md
@@ -0,0 +1,58 @@
+# Round 2 开发现状检查表
+
+状态:T0 完成
+日期:2026-06-03
+分支:`codex/round2-language-settings`
+worktree:`/Users/ar/Projects/VoiceSnap-2.1.1-Round2`
+基线 commit:`f4194105fe06baa18f0145cc59e1914f96f2d4cb`
+
+## 1. 当前代码事实
+
+- Round 1 已有 `ModelProfile`、`LanguageProfile`、`ModelRegistry`、`LanguageRegistry` 的基础结构。
+- `LanguageProfile` 当前只有 `zh-CN`。
+- `Config` 当前有 `SelectedModelID`,没有 `LanguageMode` / `LanguageID`。
+- `ConfigService` 当前只有通用设置 API,没有语言设置 API。
+- 前端 i18n 当前在 `frontend/src/lib/i18n/index.ts` 内使用 `navigator.language` 自行判断。
+- 设置页在 `GeneralPage.svelte`,现有控件风格以设置行、toggle、segmented button 为主。
+- Round 2 不需要改 engine、downloader、model resolver 主流程。
+
+## 2. Round 2 改动入口
+
+后端:
+
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/services/config_service.go`
+- 新增 `privatevoice.src/internal/language/`
+
+前端:
+
+- `privatevoice.src/frontend/src/lib/i18n/index.ts`
+- `privatevoice.src/frontend/src/lib/stores/config.ts`
+- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+
+## 3. 不应触碰的范围
+
+- 不接入 Moonshine。
+- 不接入 Parakeet。
+- 不接入 Qwen3-ASR。
+- 不改变 `SelectedModelID` 默认值。
+- 不改变 SenseVoice 下载、安装、加载路径逻辑。
+- 不改用户词库和历史格式。
+
+## 4. 开发顺序
+
+- [x] T0 代码现状确认。
+- [x] T1 LanguageProfile 扩展。
+- [x] T2 Config 语言字段。
+- [x] T3 系统语言检测。
+- [x] T4 EffectiveLanguage resolver。
+- [x] T5 ConfigService 语言 API。
+- [x] T6 前端 i18n 初始化。
+- [x] T7 设置页语言控件。
+- [x] T8 文案补齐。
+- [x] T9 自动化测试。
+- [x] T10 开发自查。
diff --git a/02-P-NBL/round-2/engineering-tasks.md b/02-P-NBL/round-2/engineering-tasks.md
new file mode 100644
index 0000000..d95b970
--- /dev/null
+++ b/02-P-NBL/round-2/engineering-tasks.md
@@ -0,0 +1,328 @@
+# Round 2 研发任务拆解:语言检测与语言版本设置
+
+状态:v0.1 研发拆解稿
+所属子项目:`02-P-NBL`
+Round:Round 2
+日期:2026-06-03
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`../engineering-plan.md`](../engineering-plan.md)
+- [`../plan.md`](../plan.md)
+
+## 1. 研发目标
+
+Round 2 的研发目标是:
+
+```text
+建立后端可验证的语言版本配置,并让前端 UI locale 跟随后端 effective language。
+```
+
+本轮不接入新模型,不改变模型选择,不做模型管理 UI。
+
+## 2. 交付边界
+
+### 2.1 必须交付
+
+- `LanguageMode` / `LanguageID` 配置字段。
+- 语言配置 normalize 与旧配置兼容。
+- `en` language profile。
+- 系统语言检测与 mockable detector。
+- `ResolveEffectiveLanguage`。
+- `ConfigService` 语言设置 API。
+- 前端 i18n 由后端 effective language 初始化。
+- 设置页语言版本控件。
+- 中文/English 文案补齐。
+- 自动化测试覆盖配置、语言解析、无效值 fallback。
+- QA 交接说明。
+
+### 2.2 明确不交付
+
+- Moonshine profile。
+- Parakeet profile。
+- Qwen3-ASR profile。
+- 模型选择页。
+- 模型删除或回滚。
+- 30+ 语言列表。
+- 粤语独立语言。
+- 欧洲语言 UI。
+- 正式签名和 notarization。
+
+## 3. 推荐任务顺序
+
+```text
+T0 代码现状确认
+T1 LanguageProfile 扩展
+T2 Config 字段与 normalize
+T3 系统语言检测
+T4 EffectiveLanguage resolver
+T5 ConfigService 语言 API
+T6 前端 i18n 初始化改造
+T7 设置页语言控件
+T8 文案补齐
+T9 自动化测试
+T10 开发自查与 QA handoff
+```
+
+## 4. 任务明细
+
+### T0:代码现状确认
+
+负责人:后端 + 前端
+类型:准备任务
+
+重点文件:
+
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/services/config_service.go`
+- `privatevoice.src/frontend/src/lib/i18n/index.ts`
+- `privatevoice.src/frontend/src/lib/stores/config.ts`
+- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+
+完成标准:
+
+- 明确当前前端 locale 来源。
+- 明确当前后端配置保存路径。
+- 明确设置页控件样式和布局约束。
+
+### T1:LanguageProfile 扩展
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+
+工作内容:
+
+- 确认 `LanguageProfile` 字段是否需要补 `NativeName`。
+- 新增 `en` profile。
+- 增加 `DefaultLanguageID`。
+- 增加 `NormalizeLanguageID`。
+- 增加 `ListLanguageProfiles()` 的稳定排序,避免前端选项顺序随机。
+
+完成标准:
+
+- `zh-CN` 和 `en` 都可查询。
+- 未知语言 id 有明确 fallback。
+- 单元测试覆盖查询和列表顺序。
+
+### T2:Config 字段与 normalize
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/config/config.go`
+
+工作内容:
+
+- 新增字段:
+ - `LanguageMode string`
+ - `LanguageID string`
+- 新增常量:
+ - `LanguageModeAuto`
+ - `LanguageModeManual`
+- 新增 normalize:
+ - `NormalizeLanguageMode`
+ - `NormalizeLanguageID`
+- 老配置缺字段时默认:
+ - `LanguageMode=auto`
+ - `LanguageID=zh-CN`
+
+完成标准:
+
+- 老 `config.json` 可正常加载。
+- 非法 `LanguageMode` 不导致启动失败。
+- 非法 `LanguageID` 不导致启动失败。
+- 保存配置不丢失原有字段。
+
+### T3:系统语言检测
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/language/detect.go`
+- `privatevoice.src/internal/language/detect_darwin.go`
+- `privatevoice.src/internal/language/detect_fallback.go`
+
+工作内容:
+
+- 实现 macOS 系统语言读取。
+- 提供环境变量 fallback。
+- 提供可 mock detector。
+- 把原始 locale normalize 为 `zh-CN` 或 `en`。
+
+完成标准:
+
+- `zh*` 解析为 `zh-CN`。
+- `en*` 解析为 `en`。
+- 其他语言解析为 `en`。
+- 读取失败解析为 `en`。
+- 单元测试不依赖真实系统语言。
+
+### T4:EffectiveLanguage resolver
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/language/resolver.go`
+
+工作内容:
+
+- 实现 `ResolveEffectiveLanguage(cfg, detectedLocale)`。
+- manual 优先于 auto。
+- auto 依赖 detector。
+- 返回 `EffectiveLanguageID`、`UILocale`、`DefaultModelID`。
+
+完成标准:
+
+- manual `zh-CN` 返回中文。
+- manual `en` 返回 English。
+- auto 中文系统返回中文。
+- auto English 系统返回 English。
+- auto 其他语言返回 English。
+
+### T5:ConfigService 语言 API
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/services/config_service.go`
+
+工作内容:
+
+- 新增 `LanguageSettings` 返回结构。
+- 新增 `GetLanguageSettings()`。
+- 新增 `SetLanguageAuto()`。
+- 新增 `SetLanguageManual(languageID string)`。
+- API 返回 options:`zh-CN`、`en`。
+- 保存后返回新的 effective language。
+
+完成标准:
+
+- 前端无需猜测系统语言。
+- 语言切换保存失败时返回错误。
+- 不改变 `SelectedModelID`。
+
+### T6:前端 i18n 初始化改造
+
+负责人:前端
+建议文件:
+
+- `privatevoice.src/frontend/src/lib/i18n/index.ts`
+- `privatevoice.src/frontend/src/App.svelte`
+- `privatevoice.src/frontend/src/lib/stores/config.ts`
+
+工作内容:
+
+- 允许从后端设置 locale。
+- App 启动时调用 `GetLanguageSettings()`。
+- 根据 `UILocale` 调用 `setLocale()`。
+- 保留 `navigator.language` 作为后端调用失败时的兜底。
+- 增加 language store。
+
+完成标准:
+
+- 中文 effective language 时 UI 中文。
+- English effective language 时 UI English。
+- 后端调用失败时 UI 仍可渲染。
+
+### T7:设置页语言控件
+
+负责人:前端
+建议文件:
+
+- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+
+工作内容:
+
+- 在设置页新增“语言版本”区域。
+- 选项:
+ - 自动
+ - 中文
+ - English
+- 切换后调用后端保存。
+- 保存成功后立即切换 UI locale。
+- 保持现有设置页视觉密度,不做营销式大卡片。
+
+完成标准:
+
+- 选中状态清晰。
+- 切换时不影响其他设置。
+- 文案不溢出。
+- English UI 下设置页仍可读。
+
+### T8:文案补齐
+
+负责人:前端
+建议文件:
+
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+
+工作内容:
+
+- 补充语言设置相关 key。
+- 检查 settings/onboarding 关键路径是否缺英文。
+- 避免把模型能力写成“English model”。
+
+完成标准:
+
+- `t()` 不返回 key path。
+- 中文/English 文案含义一致。
+
+### T9:自动化测试
+
+负责人:后端 + 前端
+
+建议后端测试:
+
+- `config` language normalize。
+- `language` detector normalize。
+- `EffectiveLanguage` resolver。
+- `LanguageRegistry` 查询和排序。
+- `ConfigService` 保存语言设置。
+
+建议前端测试或构建检查:
+
+- `npm run build`。
+- 如果项目已有前端测试,覆盖 language store 和 i18n 切换。
+
+完成标准:
+
+- `go test ./...` 通过。
+- 前端构建通过。
+- 不新增明显 Svelte runtime warning。
+
+### T10:开发自查与 QA handoff
+
+负责人:研发
+
+交付内容:
+
+- 变更范围。
+- 自动化测试命令与结果。
+- 已知风险。
+- QA 数据准备说明。
+- 旧用户升级注意事项。
+
+完成标准:
+
+- 可以进入 QA。
+- QA 能按 `qa-test-plan.md` 独立验证。
+
+## 5. 实施注意事项
+
+- 不要在 Round 2 中把 English 默认模型改成 Moonshine。
+- 不要让语言切换触发模型下载。
+- 不要删除、移动、重命名用户现有模型目录。
+- 不要用前端 `navigator.language` 作为唯一事实源。
+- 不要把 `LanguageID` 和 `SelectedModelID` 混为一谈。
+- 不要在设置页做 30+ 语言列表。
+
diff --git a/02-P-NBL/round-2/logs/go-test-all-20260603.log b/02-P-NBL/round-2/logs/go-test-all-20260603.log
new file mode 100644
index 0000000..502891a
--- /dev/null
+++ b/02-P-NBL/round-2/logs/go-test-all-20260603.log
@@ -0,0 +1,22 @@
+? voicesnap [no test files]
+? voicesnap/internal/audio [no test files]
+ok voicesnap/internal/config 1.510s
+ok voicesnap/internal/correctioncsv 1.049s
+? voicesnap/internal/dock [no test files]
+? voicesnap/internal/engine [no test files]
+ok voicesnap/internal/history 0.590s
+? voicesnap/internal/hotkey [no test files]
+? voicesnap/internal/input [no test files]
+ok voicesnap/internal/language 2.018s
+? voicesnap/internal/logger [no test files]
+ok voicesnap/internal/model 2.532s
+? 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 2.984s
+? voicesnap/services [no test files]
diff --git a/02-P-NBL/round-2/logs/npm-run-build-20260603.log b/02-P-NBL/round-2/logs/npm-run-build-20260603.log
new file mode 100644
index 0000000..b377606
--- /dev/null
+++ b/02-P-NBL/round-2/logs/npm-run-build-20260603.log
@@ -0,0 +1,38 @@
+
+> privatevoice-input-frontend@2.1.19 build
+> vite build
+
+vite v6.4.1 building for production...
+transforming...
+1:38:11 AM [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">
+1:38:11 AM [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>
+1:38:12 AM [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.28 kB
+dist/assets/app-icon-Bs-pS5hx.png 400.98 kB
+dist/assets/index-p3GQqwqB.css 33.39 kB │ gzip: 5.73 kB
+dist/assets/index-yZ25Q-mY.js 137.08 kB │ gzip: 45.83 kB
+✓ built in 712ms
diff --git a/02-P-NBL/round-2/prd.md b/02-P-NBL/round-2/prd.md
new file mode 100644
index 0000000..3e98ddd
--- /dev/null
+++ b/02-P-NBL/round-2/prd.md
@@ -0,0 +1,337 @@
+# Round 2 PRD:语言检测与语言版本设置
+
+状态:v0.1 启动稿
+所属子项目:`02-P-NBL`
+Round:Round 2
+日期:2026-06-03
+关联文档:
+
+- [`../product-plan.md`](../product-plan.md)
+- [`../engineering-plan.md`](../engineering-plan.md)
+- [`../plan.md`](../plan.md)
+- [`../round-1/prd.md`](../round-1/prd.md)
+
+## 1. 背景
+
+Round 1 已经把现有 SenseVoice 纳入 `ModelProfile` 架构,并通过 QA 验证:
+
+- 老用户旧模型目录 `models/sensevoice` 可 fallback。
+- 新用户模型可安装到 `models/sensevoice-zh`。
+- `SelectedModelID=sensevoice-zh` 已成为隐藏配置。
+- `LanguageProfile` 已有最小中文实现,但尚未真正参与语言选择和 UI 设置。
+
+后续产品路线要求:
+
+| 语言版本 | 默认轻量模型 | 高级模型 |
+|---|---|---|
+| 中文 | SenseVoice | Qwen3-ASR |
+| English | Moonshine English | Parakeet |
+
+Round 2 的任务不是接入英文模型,而是先建立语言版本机制,让后续 Round 3 可以把 English 默认模型切到 Moonshine。
+
+## 2. 本轮目标
+
+Round 2 完成后,系统内部应从:
+
+```text
+前端按 navigator.language 自行判断 zh/en
+后端没有语言版本配置
+```
+
+演进为:
+
+```text
+Config.LanguageMode / Config.LanguageID
+ -> LanguageRegistry
+ -> EffectiveLanguage
+ -> UI locale
+ -> 未来默认模型选择
+```
+
+用户可见目标:
+
+- 中文系统首次启动默认中文界面。
+- English 系统首次启动默认 English 界面。
+- 其他系统语言暂时默认 English。
+- 设置页可手动选择中文或 English。
+- 选择“自动”时跟随系统语言。
+- 语言选择持久化,重启后仍生效。
+
+## 3. 本轮非目标
+
+明确不做:
+
+- 不接入 Moonshine。
+- 不接入 Parakeet。
+- 不接入 Qwen3-ASR。
+- 不做模型选择页。
+- 不做模型删除、回滚、清理。
+- 不做 30+ 语言列表。
+- 不做粤语作为独立语言版本。
+- 不做法语、德语、西班牙语、意大利语、葡萄牙语、俄语、乌克兰语等欧洲语言 UI。
+- 不根据语言切换自动删除任何模型。
+- 不强制老用户重新下载 SenseVoice。
+- 不改变用户词库、历史记录格式。
+- 不做正式安装包签名和 notarization。
+
+## 4. 用户故事
+
+### 4.1 中文系统的新用户
+
+作为中文 macOS 用户,我第一次安装 VoiceSnap 后,应用应默认使用中文界面,并继续使用 SenseVoice。
+
+验收点:
+
+- `LanguageMode=auto`。
+- 有效语言解析为 `zh-CN`。
+- UI 显示中文。
+- 默认模型仍为 `sensevoice-zh`。
+- 新用户模型下载路径不退化。
+
+### 4.2 English 系统的新用户
+
+作为 English macOS 用户,我第一次安装 VoiceSnap 后,应用应默认显示 English UI。
+
+验收点:
+
+- `LanguageMode=auto`。
+- 有效语言解析为 `en`。
+- UI 显示 English。
+- 本轮模型仍为 `sensevoice-zh`。
+- 不出现 Moonshine 或 Parakeet。
+
+说明:
+
+Round 2 的 English 只是语言版本和 UI 准备。真正的 English 默认 Moonshine 在 Round 3 交付。
+
+### 4.3 其他系统语言的新用户
+
+作为法语、德语、日语等系统语言用户,本轮应用应默认进入 English UI,避免误判为中文。
+
+验收点:
+
+- 系统语言不是中文且不是 English 时,有效语言为 `en`。
+- UI 显示 English。
+- 模型仍为 `sensevoice-zh`。
+
+### 4.4 老用户升级
+
+作为已有 VoiceSnap 用户,我升级到 Round 2 后,不应因为新增语言配置而丢失设置、词库、历史或模型。
+
+验收点:
+
+- 旧 `config.json` 缺少 `LanguageMode` / `LanguageID` 时可以正常加载。
+- 默认补齐为 `LanguageMode=auto`。
+- 中文用户有效语言为 `zh-CN`。
+- 旧 `SelectedModelID` 兼容。
+- `userdict.json`、`history.json`、模型目录不被修改。
+
+### 4.5 用户手动切换语言
+
+作为用户,我可以在设置页选择“自动、中文、English”。
+
+验收点:
+
+- 选择中文后 UI 切到中文。
+- 选择 English 后 UI 切到 English。
+- 选择自动后根据系统语言解析有效语言。
+- 重启后保持选择结果。
+- 切换语言不触发模型下载、删除或迁移。
+
+## 5. 配置规则
+
+新增配置字段:
+
+```json
+{
+ "LanguageMode": "auto",
+ "LanguageID": "zh-CN"
+}
+```
+
+### 5.1 LanguageMode
+
+允许值:
+
+| 值 | 含义 |
+|---|---|
+| `auto` | 跟随系统语言 |
+| `manual` | 使用用户手动选择的语言 |
+
+非法值处理:
+
+- 回退为 `auto`。
+- 记录日志。
+- 不中断 App 启动。
+
+### 5.2 LanguageID
+
+当前允许值:
+
+| 值 | 含义 | UI locale |
+|---|---|---|
+| `zh-CN` | 中文 | `zh` |
+| `en` | English | `en` |
+
+非法值处理:
+
+- 在 `manual` 模式下回退为 `zh-CN` 或 `en`,具体规则由默认有效语言决定。
+- 在 `auto` 模式下忽略非法 `LanguageID`,按系统语言重新解析。
+- 不中断 App 启动。
+
+### 5.3 有效语言
+
+后端需要提供一个确定性的有效语言解析:
+
+```text
+EffectiveLanguage = Resolve(LanguageMode, LanguageID, SystemLocale)
+```
+
+规则:
+
+| 条件 | 有效语言 |
+|---|---|
+| `LanguageMode=manual` 且 `LanguageID=zh-CN` | `zh-CN` |
+| `LanguageMode=manual` 且 `LanguageID=en` | `en` |
+| `LanguageMode=auto` 且系统语言为中文 | `zh-CN` |
+| `LanguageMode=auto` 且系统语言为 English | `en` |
+| `LanguageMode=auto` 且系统语言为其他语言 | `en` |
+
+## 6. LanguageProfile
+
+Round 2 需要把 Round 1 的最小 language registry 扩展为:
+
+```text
+zh-CN
+en
+```
+
+建议字段:
+
+```go
+type LanguageProfile struct {
+ ID string
+ DisplayName string
+ NativeName string
+ UILocale string
+ SystemMatchers []string
+ DefaultModelID string
+}
+```
+
+Round 2 profile:
+
+| ID | DisplayName | NativeName | UILocale | SystemMatchers | DefaultModelID |
+|---|---|---|---|---|---|
+| `zh-CN` | Chinese | 中文 | `zh` | `zh`, `zh-CN`, `zh-Hans`, `zh-Hant` | `sensevoice-zh` |
+| `en` | English | English | `en` | `en`, `en-US`, `en-GB`, `en-AU`, `en-CA` | `sensevoice-zh` |
+
+注意:
+
+- `en` 的默认模型本轮仍是 `sensevoice-zh`。
+- Round 3 再把 English 默认模型切换为 `moonshine-en`。
+
+## 7. 系统语言检测
+
+系统语言检测必须在后端有确定实现,不能只依赖前端 `navigator.language`。
+
+优先级建议:
+
+1. macOS 系统 locale,例如 `AppleLocale` / `AppleLanguages`。
+2. 环境变量 `LANG` / `LC_ALL` / `LC_MESSAGES`。
+3. 无法读取时默认 `en`。
+
+研发实现时应提供可 mock 的检测接口,便于单元测试覆盖:
+
+```go
+type SystemLocaleDetector interface {
+ Detect() string
+}
+```
+
+## 8. 前端交互
+
+设置页新增“语言版本”区域。
+
+中文 UI:
+
+```text
+语言版本
+自动(跟随系统)
+中文
+English
+```
+
+English UI:
+
+```text
+Language
+Automatic
+Chinese
+English
+```
+
+交互规则:
+
+- 使用单选或分段控制,不使用自由输入。
+- 当前选择必须有清晰选中状态。
+- 切换后立即保存。
+- 保存成功后 UI 文案立即切换。
+- 保存失败时保留原选择并显示现有风格的错误提示或静默回退日志,本轮不新增复杂弹窗。
+
+## 9. 后端 API
+
+建议新增或扩展 `ConfigService`:
+
+```go
+GetLanguageSettings() LanguageSettings
+SetLanguageAuto() LanguageSettings
+SetLanguageManual(languageID string) LanguageSettings
+```
+
+建议返回结构:
+
+```go
+type LanguageSettings struct {
+ LanguageMode string
+ LanguageID string
+ EffectiveLanguageID string
+ UILocale string
+ Options []LanguageOption
+}
+```
+
+其中 `Options` 只包含:
+
+- `zh-CN`
+- `en`
+
+如果研发希望直接扩展 `GetConfig()`,必须保证旧前端调用不破坏。
+
+## 10. 验收标准
+
+Round 2 必须全部满足:
+
+- 老配置缺少语言字段时可正常加载。
+- 新配置保存后包含 `LanguageMode` 和 `LanguageID`。
+- 中文系统 auto 解析为 `zh-CN`。
+- English 系统 auto 解析为 `en`。
+- 其他系统语言 auto 解析为 `en`。
+- 手动选择中文后 UI 立即切到中文。
+- 手动选择 English 后 UI 立即切到 English。
+- 手动选择重启后仍生效。
+- 切换语言不删除、不迁移、不重新下载模型。
+- `SelectedModelID` 保持 `sensevoice-zh`。
+- 词库、历史、热键、音频设备、Dock 设置不丢失。
+- 不出现 Moonshine、Parakeet、Qwen3-ASR。
+
+## 11. 已知风险
+
+| 风险 | 说明 | 处理 |
+|---|---|---|
+| 系统语言检测不稳定 | macOS locale 来源可能有多个 | 使用 detector 抽象和 mock 测试 |
+| 前后端语言源冲突 | 当前前端自己用 `navigator.language` | Round 2 以后以前端从后端 effective language 为准 |
+| English UI 文案缺失 | 现有 i18n 已有 en.json,但可能不完整 | QA 检查关键设置页和 onboarding |
+| 用户误解为英文模型 | Round 2 English UI 仍用 SenseVoice | 文档和 UI 不展示模型升级承诺 |
+| 语言切换引发模型切换 | 本轮不应发生 | QA 明确验证模型目录和 SelectedModelID 不变 |
+
diff --git a/02-P-NBL/round-2/qa-handoff-current.md b/02-P-NBL/round-2/qa-handoff-current.md
new file mode 100644
index 0000000..ab0854f
--- /dev/null
+++ b/02-P-NBL/round-2/qa-handoff-current.md
@@ -0,0 +1,129 @@
+# Round 2 QA Handoff
+
+状态:研发自测完成,待 QA
+日期:2026-06-03
+分支:`codex/round2-language-settings`
+worktree:`/Users/ar/Projects/VoiceSnap-2.1.1-Round2`
+基线 commit:`f4194105fe06baa18f0145cc59e1914f96f2d4cb`
+
+## 1. 交付内容
+
+本轮实现:
+
+- 新增 `LanguageMode` / `LanguageID` 配置字段。
+- 扩展 `LanguageProfile`,新增 `en`。
+- 新增 `internal/language` 包:
+ - 系统语言检测。
+ - locale normalize。
+ - effective language resolver。
+- 新增 `ConfigService` 语言 API:
+ - `GetLanguageSettings`
+ - `SetLanguageAuto`
+ - `SetLanguageManual`
+- 前端 i18n 改为优先使用后端 effective language。
+- 设置页新增语言版本控件:
+ - 自动
+ - 中文
+ - English
+- 语言切换不改变 `SelectedModelID`,不触发模型下载、删除或迁移。
+
+## 2. 变更文件
+
+后端:
+
+- `privatevoice.src/app.go`
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/internal/config/config_test.go`
+- `privatevoice.src/internal/language/`
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/services/config_service.go`
+
+前端:
+
+- `privatevoice.src/frontend/src/App.svelte`
+- `privatevoice.src/frontend/src/components/settings/GeneralPage.svelte`
+- `privatevoice.src/frontend/src/lib/i18n/index.ts`
+- `privatevoice.src/frontend/src/lib/i18n/zh.json`
+- `privatevoice.src/frontend/src/lib/i18n/en.json`
+- `privatevoice.src/frontend/src/lib/stores/config.ts`
+
+项目资料:
+
+- `02-P-NBL/round-2/`
+
+## 3. 自测结果
+
+已执行:
+
+```bash
+cd privatevoice.src/frontend
+npm run build
+```
+
+结果:PASS。
+
+日志:
+
+```text
+/Users/ar/Projects/VoiceSnap-2.1.1-Round2/02-P-NBL/round-2/logs/npm-run-build-20260603.log
+```
+
+已执行:
+
+```bash
+cd privatevoice.src
+go test ./... -count=1
+```
+
+结果:PASS。
+
+日志:
+
+```text
+/Users/ar/Projects/VoiceSnap-2.1.1-Round2/02-P-NBL/round-2/logs/go-test-all-20260603.log
+```
+
+已执行:
+
+```bash
+git diff --check
+```
+
+结果:PASS。
+
+## 4. QA 重点
+
+必须重点验证:
+
+- 老用户缺少 `LanguageMode` / `LanguageID` 的旧 config 可正常升级。
+- 中文系统 auto 解析为中文 UI。
+- English 系统 auto 解析为 English UI。
+- 其他系统语言 auto 解析为 English UI。
+- 设置页选择中文后立即中文。
+- 设置页选择 English 后立即 English。
+- 设置页选择自动后跟随系统语言。
+- 重启后语言设置持久化。
+- 语言切换不改变 `SelectedModelID`。
+- 语言切换不触发模型下载。
+- 语言切换不删除或迁移 `models/sensevoice`。
+- 语言切换后 replacement、history、热键、音频设备、Dock 设置仍保留。
+- 断网已有模型识别仍通过。
+
+## 5. 已知风险
+
+- 前端 build 仍有既有 Svelte a11y warning,来源为 `UpdateDialog.svelte` 和 `ToggleSwitch.svelte`,非本轮新增控件。
+- `npm audit` 仍报告 6 个问题,沿用 Round 1 已知风险。
+- 新用户 English UI 本轮仍使用 SenseVoice;Moonshine 要等 Round 3。
+- macOS 系统语言检测使用系统 locale / fallback 环境变量,QA 应优先用 mock 或构造配置覆盖确定性路径。
+
+## 6. 不属于本轮
+
+- Moonshine。
+- Parakeet。
+- Qwen3-ASR。
+- 模型选择页。
+- 模型删除/回滚。
+- 30+ 语言列表。
+- Developer ID 签名和 notarization。
+
diff --git a/02-P-NBL/round-2/qa-test-plan.md b/02-P-NBL/round-2/qa-test-plan.md
new file mode 100644
index 0000000..33993a2
--- /dev/null
+++ b/02-P-NBL/round-2/qa-test-plan.md
@@ -0,0 +1,222 @@
+# Round 2 QA 测试计划:语言检测与语言版本设置
+
+状态:v0.1 QA 计划稿
+所属子项目:`02-P-NBL`
+Round:Round 2
+日期:2026-06-03
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+
+## 1. QA 目标
+
+Round 2 的 QA 目标是证明:
+
+```text
+语言版本配置可被正确检测、保存、恢复,并且不会破坏 Round 1 已验证通过的 SenseVoice 离线识别链路。
+```
+
+重点验证:
+
+- 老用户升级不丢数据。
+- 新用户首次启动语言解析正确。
+- 设置页可切换自动、中文、English。
+- UI 文案跟随 effective language。
+- 语言切换不触发模型切换、模型删除或模型下载。
+- Round 1 的 SenseVoice、replacement、历史、断网识别不退化。
+
+## 2. 测试范围
+
+### 2.1 本轮必须测
+
+- `LanguageMode` / `LanguageID` 默认值。
+- 旧 config 兼容。
+- 非法 language config fallback。
+- 中文系统 auto 解析。
+- English 系统 auto 解析。
+- 其他系统语言 auto 解析。
+- 设置页语言切换。
+- 重启后语言选择持久化。
+- 前端 i18n 切换。
+- SenseVoice 模型目录不变。
+- `SelectedModelID` 不被语言切换改写。
+- 用户词库、历史、热键、音频设备、Dock 设置不丢失。
+- 断网已有模型识别。
+
+### 2.2 本轮不测
+
+- Moonshine 识别。
+- Parakeet 识别。
+- Qwen3-ASR 识别。
+- 30+ 语言列表。
+- 粤语独立语言。
+- 欧洲语言 UI。
+- 模型选择页。
+- 模型删除和回滚。
+- Developer ID 签名和 notarization。
+
+## 3. QA 进入标准
+
+研发移交 QA 前必须提供:
+
+- 可运行构建或分支。
+- 变更范围说明。
+- `go test ./...` 结果。
+- 前端构建结果。
+- 语言检测 mock 或测试方式说明。
+- 老用户升级测试数据说明。
+- 已知风险说明。
+
+## 4. 测试环境
+
+| 项目 | 要求 |
+|---|---|
+| OS | macOS |
+| 芯片 | Apple Silicon 必测;Intel 如仍支持则抽测 |
+| 网络 | 联网、断网 |
+| 系统语言 | 中文、English、至少一种其他语言模拟 |
+| 用户状态 | 老用户、新用户、非法配置用户 |
+| 模型状态 | 旧目录 `models/sensevoice`、新目录 `models/sensevoice-zh` |
+
+系统语言不建议真实反复改系统全局设置作为唯一测试手段。自动化应通过 detector mock 覆盖;真机只做关键 smoke。
+
+## 5. 测试矩阵
+
+| ID | 场景 | 前置条件 | 期望结果 |
+|---|---|---|---|
+| R2-QA-001 | 老配置升级 | config 无 `LanguageMode` / `LanguageID` | 正常启动,默认 auto,有效语言正确 |
+| R2-QA-002 | 中文系统 auto | detector 返回 `zh-CN` | effective=`zh-CN`,UI 中文 |
+| R2-QA-003 | English 系统 auto | detector 返回 `en-US` | effective=`en`,UI English |
+| R2-QA-004 | 其他系统语言 auto | detector 返回 `fr-FR` | effective=`en`,UI English |
+| R2-QA-005 | 手动中文 | 设置页选择中文 | 保存 `manual/zh-CN`,UI 立即中文 |
+| R2-QA-006 | 手动 English | 设置页选择 English | 保存 `manual/en`,UI 立即 English |
+| R2-QA-007 | 切回自动 | 设置页选择自动 | 保存 `auto`,effective 跟随 detector |
+| R2-QA-008 | 重启持久化 | 手动 English 后重启 | 仍为 English |
+| R2-QA-009 | 非法 LanguageMode | config 写入坏值 | 启动不崩溃,fallback auto |
+| R2-QA-010 | 非法 LanguageID | config 写入坏值 | 启动不崩溃,fallback 有效语言 |
+| R2-QA-011 | 语言切换不改模型 | 已安装 SenseVoice | `SelectedModelID` 仍为 `sensevoice-zh` |
+| R2-QA-012 | 语言切换不下载模型 | 已有模型,联网观察 | 不触发新下载 |
+| R2-QA-013 | 语言切换不删模型 | 旧目录模型 | `models/sensevoice` 保留 |
+| R2-QA-014 | 词库保留 | userdict 有 replacement | 切换语言后 replacement 仍在 |
+| R2-QA-015 | 历史保留 | history 有记录 | 切换语言后历史不丢 |
+| R2-QA-016 | 热键保留 | 自定义热键或默认热键 | 切换语言后仍可触发 |
+| R2-QA-017 | 断网识别 | 已有模型,断网 | 可录音识别并写历史 |
+| R2-QA-018 | UI 不暴露新模型 | 任意语言 | 不出现 Moonshine/Parakeet/Qwen3-ASR |
+| R2-QA-019 | English UI 文案 | effective=`en` | 设置页、onboarding 关键文案不是中文 |
+| R2-QA-020 | 中文 UI 文案 | effective=`zh-CN` | 设置页、onboarding 关键文案是中文 |
+
+## 6. 自动化测试建议
+
+后端优先覆盖:
+
+- `NormalizeLanguageMode`。
+- `NormalizeLanguageID`。
+- detector locale normalize。
+- `ResolveEffectiveLanguage`。
+- `LanguageRegistry` 查询和排序。
+- `GetLanguageSettings` 返回 options。
+- `SetLanguageAuto`。
+- `SetLanguageManual`。
+- 老 config 缺字段加载。
+- 非法 config fallback。
+
+建议命令:
+
+```bash
+cd privatevoice.src
+go test ./...
+```
+
+前端至少执行:
+
+```bash
+cd privatevoice.src/frontend
+npm run build
+```
+
+## 7. 手工测试步骤
+
+### 7.1 老用户升级
+
+1. 准备已有 App Support 数据,含:
+ - `config.json`
+ - `userdict.json`
+ - `history.json`
+ - `models/sensevoice`
+2. 确保旧 config 不含语言字段。
+3. 启动 Round 2 App。
+4. 检查语言设置默认值。
+5. 完成一次识别。
+6. 检查词库、历史、模型目录。
+
+通过标准:
+
+- 老用户无需重新设置。
+- 识别可用。
+- 用户数据不丢。
+
+### 7.2 新用户中文系统
+
+1. 使用干净 App Support。
+2. mock 或真实设置 detector 返回中文。
+3. 启动 App。
+4. 检查 UI 为中文。
+5. 检查默认模型仍是 `sensevoice-zh`。
+
+通过标准:
+
+- 中文用户默认中文。
+- 不出现模型选择步骤。
+
+### 7.3 新用户 English 系统
+
+1. 使用干净 App Support。
+2. mock detector 返回 `en-US`。
+3. 启动 App。
+4. 检查 UI 为 English。
+5. 检查默认模型仍是 `sensevoice-zh`。
+
+通过标准:
+
+- English 用户默认 English。
+- 本轮不出现 Moonshine。
+
+### 7.4 设置页切换
+
+1. 打开设置页。
+2. 在语言版本中依次选择:
+ - 中文
+ - English
+ - 自动
+3. 每次切换后检查 UI 文案、config、模型目录。
+4. 重启 App 后复核。
+
+通过标准:
+
+- UI 立即切换。
+- 重启后持久化。
+- 模型不被删除、迁移或下载。
+
+### 7.5 离线回归
+
+1. 准备完整 SenseVoice 模型。
+2. 切到 English UI。
+3. 断网。
+4. 完成一次识别。
+5. 切回中文 UI。
+6. 再完成一次识别。
+
+通过标准:
+
+- 不管 UI 语言是什么,已有模型离线识别都可用。
+- history 正常写入。
+
+## 8. QA 结论标准
+
+可标记 Round 2 功能性 QA PASS 的条件:
+
+- 测试矩阵中 R2-QA-001 到 R2-QA-020 全部通过,或失败项有明确非阻断说明。
+- Round 1 核心路径未回归。
+- QA 报告记录版本、build、commit、测试包、日志和关键证据。
+
diff --git a/02-P-NBL/round-2/todo-list.md b/02-P-NBL/round-2/todo-list.md
new file mode 100644
index 0000000..3f5efc6
--- /dev/null
+++ b/02-P-NBL/round-2/todo-list.md
@@ -0,0 +1,52 @@
+# Round 2 To Do List
+
+状态:待启动
+所属子项目:`02-P-NBL`
+Round:Round 2
+日期:2026-06-03
+
+## 1. 项目准备
+
+- [x] 创建 `round-2` 项目资料目录。
+- [x] 产出 Round 2 PRD。
+- [x] 产出 Round 2 研发任务拆解。
+- [x] 产出 Round 2 QA 测试计划。
+- [x] 创建 Round 2 代码分支或独立 worktree:`/Users/ar/Projects/VoiceSnap-2.1.1-Round2`。
+- [x] 确认 Round 2 基线 commit:`f4194105fe06baa18f0145cc59e1914f96f2d4cb`。
+
+## 2. 研发任务
+
+- [x] T0 代码现状确认。
+- [x] T1 扩展 `LanguageProfile`,新增 `en`。
+- [x] T2 增加 `LanguageMode` / `LanguageID` 配置字段。
+- [x] T3 实现系统语言检测。
+- [x] T4 实现 effective language resolver。
+- [x] T5 增加 ConfigService 语言 API。
+- [x] T6 改造前端 i18n 初始化。
+- [x] T7 设置页新增语言版本控件。
+- [x] T8 补齐中文/English 文案。
+- [x] T9 增加自动化测试。
+- [x] T10 完成开发自查与 QA handoff。
+
+## 3. QA 准备
+
+- [ ] 准备老用户升级测试数据。
+- [ ] 准备新用户干净安装测试数据。
+- [ ] 准备 detector mock 或测试开关。
+- [ ] 准备非法 config 测试样本。
+- [ ] 准备断网识别回归步骤。
+- [ ] 准备 QA 报告模板。
+
+## 4. 验收
+
+- [ ] 中文系统 auto 解析为中文。
+- [ ] English 系统 auto 解析为 English。
+- [ ] 其他系统语言 auto 解析为 English。
+- [ ] 手动中文持久化。
+- [ ] 手动 English 持久化。
+- [ ] 语言切换不触发模型下载。
+- [ ] 语言切换不破坏 SenseVoice 模型。
+- [ ] 语言切换不破坏词库。
+- [ ] 语言切换不破坏历史。
+- [ ] 断网识别回归通过。
+- [ ] QA 报告确认功能性 PASS。
diff --git a/privatevoice.src/app.go b/privatevoice.src/app.go
index 40d79a1..552a49d 100755
--- a/privatevoice.src/app.go
+++ b/privatevoice.src/app.go
@@ -14,6 +14,7 @@
"voicesnap/internal/history"
"voicesnap/internal/hotkey"
"voicesnap/internal/input"
+ "voicesnap/internal/language"
"voicesnap/internal/logger"
"voicesnap/internal/overlay"
"voicesnap/internal/sound"
@@ -186,7 +187,7 @@
tray.SetTemplateIcon(trayIcon)
showLabel, exitLabel := "Show Settings", "Exit"
- if isChinese() {
+ if language.Resolve(app.cfg.LanguageMode, app.cfg.LanguageID, language.NewSystemDetector()).UILocale == "zh" {
showLabel, exitLabel = "显示设置", "退出"
}
diff --git a/privatevoice.src/frontend/src/App.svelte b/privatevoice.src/frontend/src/App.svelte
index 31f69cd..e6dc993 100755
--- a/privatevoice.src/frontend/src/App.svelte
+++ b/privatevoice.src/frontend/src/App.svelte
@@ -9,11 +9,20 @@
import { indicatorVisible, indicatorStatus, indicatorVolume, hotkeyName } from './lib/stores/indicator'
import { updateAvailable, type UpdateInfo } from './lib/stores/update'
import { deviceName, engineHardwareInfo } from './lib/stores/app'
+ import { setLocale, localeRevision } from './lib/i18n'
+ import { languageMode, languageID, effectiveLanguageID, uiLocale } from './lib/stores/config'
let isIndicatorRoute = $state(window.location.hash === '#/indicator')
let showUpdateDialog = $state(false)
+ let languageReady = $state(false)
onMount(() => {
+ Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings').then((settings: any) => {
+ applyLanguageSettings(settings)
+ }).catch(() => {
+ languageReady = true
+ })
+
Events.On('indicator:show', (ev: any) => {
indicatorVisible.set(true)
if (ev?.data?.status) {
@@ -88,15 +97,32 @@
}
})
})
+
+ function applyLanguageSettings(settings: any) {
+ const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto'
+ const configured = settings?.languageID === 'en' ? 'en' : 'zh-CN'
+ const effective = settings?.effectiveLanguageID === 'en' ? 'en' : 'zh-CN'
+ const locale = settings?.uiLocale === 'en' ? 'en' : 'zh'
+ languageMode.set(mode)
+ languageID.set(configured)
+ effectiveLanguageID.set(effective)
+ uiLocale.set(locale)
+ setLocale(locale)
+ languageReady = true
+ }
</script>
{#if isIndicatorRoute}
<FloatingIndicator />
{:else}
- {#if $engineStatus === 'need_model'}
- <OnboardingView />
- {:else}
- <SettingsWindow />
+ {#if languageReady}
+ {#key $localeRevision}
+ {#if $engineStatus === 'need_model'}
+ <OnboardingView />
+ {:else}
+ <SettingsWindow />
+ {/if}
+ {/key}
{/if}
{#if showUpdateDialog && $updateAvailable}
diff --git a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index d54ca49..9762ac3 100755
--- a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,8 +1,8 @@
<script lang="ts">
import { Call } from '@wailsio/runtime'
import ToggleSwitch from '../shared/ToggleSwitch.svelte'
- import { t } from '../../lib/i18n'
- import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
+ import { t, setLocale } from '../../lib/i18n'
+ import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode, languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config'
import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
import { hotkeyName } from '../../lib/stores/indicator'
@@ -22,6 +22,9 @@
let hwInfo = $state('')
let currentKeyName = $state('Ctrl')
let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
+ let languageModeVal: 'auto' | 'manual' = $state('auto')
+ let languageIDVal: 'zh-CN' | 'en' = $state('zh-CN')
+ let effectiveLanguageIDVal: 'zh-CN' | 'en' = $state('zh-CN')
let isRecording = $state(false)
let hintText = $state('')
let devices = $state<InputDevice[]>([])
@@ -36,6 +39,9 @@
const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
+ const unsub10 = languageMode.subscribe(v => { languageModeVal = v })
+ const unsub11 = languageID.subscribe(v => { languageIDVal = v })
+ const unsub12 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v })
// Load actual device name from backend on mount
async function loadDeviceName() {
@@ -80,9 +86,28 @@
const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
hideDockIconVal = !!hideDock
} catch {}
+ try {
+ const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
+ applyLanguageSettings(lang)
+ } catch {}
settingsLoaded = true
}
loadSettings()
+
+ function applyLanguageSettings(settings: any) {
+ const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto'
+ const configured = settings?.languageID === 'en' ? 'en' : 'zh-CN'
+ const effective = settings?.effectiveLanguageID === 'en' ? 'en' : 'zh-CN'
+ const locale = settings?.uiLocale === 'en' ? 'en' : 'zh'
+ languageMode.set(mode)
+ languageID.set(configured)
+ effectiveLanguageID.set(effective)
+ uiLocale.set(locale)
+ languageModeVal = mode
+ languageIDVal = configured
+ effectiveLanguageIDVal = effective
+ setLocale(locale)
+ }
async function onAutoHideChange(checked: boolean) {
autoHide.set(checked)
@@ -111,6 +136,31 @@
try {
await Call.ByName('voicesnap/services.ConfigService.SetHotkeyMode', mode)
} catch {}
+ }
+
+ async function onLanguageChange(selection: 'auto' | 'zh-CN' | 'en') {
+ const prevMode = languageModeVal
+ const prevID = languageIDVal
+ if (selection === 'auto') {
+ languageModeVal = 'auto'
+ languageMode.set('auto')
+ } else {
+ languageModeVal = 'manual'
+ languageIDVal = selection
+ languageMode.set('manual')
+ languageID.set(selection)
+ }
+ try {
+ const settings: any = selection === 'auto'
+ ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto')
+ : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
+ applyLanguageSettings(settings)
+ } catch {
+ languageModeVal = prevMode
+ languageIDVal = prevID
+ languageMode.set(prevMode)
+ languageID.set(prevID)
+ }
}
async function onStartAtLoginChange(checked: boolean) {
@@ -275,6 +325,43 @@
</div>
</div>
+
+ {#if settingsLoaded}
+ <div class="section">
+ <div class="setting-row">
+ <div class="setting-info">
+ <span class="setting-label">{t('settings.language')}</span>
+ <span class="setting-desc">
+ {languageModeVal === 'auto'
+ ? t('settings.languageAutoDesc')
+ : effectiveLanguageIDVal === 'en'
+ ? t('settings.languageEnglishDesc')
+ : t('settings.languageChineseDesc')}
+ </span>
+ </div>
+ <div class="segmented language" role="group" aria-label={t('settings.language')}>
+ <button
+ class:active={languageModeVal === 'auto'}
+ onclick={() => onLanguageChange('auto')}
+ >
+ {t('settings.languageAuto')}
+ </button>
+ <button
+ class:active={languageModeVal === 'manual' && languageIDVal === 'zh-CN'}
+ onclick={() => onLanguageChange('zh-CN')}
+ >
+ {t('settings.languageChinese')}
+ </button>
+ <button
+ class:active={languageModeVal === 'manual' && languageIDVal === 'en'}
+ onclick={() => onLanguageChange('en')}
+ >
+ {t('settings.languageEnglish')}
+ </button>
+ </div>
+ </div>
+ </div>
+ {/if}
<!-- Engine + Device -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -527,6 +614,16 @@
flex-shrink: 0;
}
+ .segmented.language {
+ grid-template-columns: repeat(3, 1fr);
+ min-width: 246px;
+ }
+
+ .segmented.language button {
+ min-width: 76px;
+ padding: 0 10px;
+ }
+
.segmented button {
min-width: 66px;
height: 30px;
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
index 94b25f3..1b2d78b 100755
--- a/privatevoice.src/frontend/src/lib/i18n/en.json
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -44,6 +44,13 @@
"hotkeyModeHold": "Hold",
"hotkeyModeTapDesc": "Tap the hotkey to start speaking, then tap again to stop and type",
"hotkeyModeHoldDesc": "Hold the hotkey to speak, release to stop and type",
+ "language": "Language",
+ "languageAuto": "Automatic",
+ "languageChinese": "Chinese",
+ "languageEnglish": "English",
+ "languageAutoDesc": "Follow the system language, currently choosing only between Chinese and English",
+ "languageChineseDesc": "Use the Chinese interface. The offline model remains SenseVoice in this round",
+ "languageEnglishDesc": "Use the English interface. The offline model remains SenseVoice in this round",
"autoHide": "Auto-hide Indicator",
"autoHideDesc": "Automatically hide the floating indicator after completion",
"soundFeedback": "Sound Feedback",
diff --git a/privatevoice.src/frontend/src/lib/i18n/index.ts b/privatevoice.src/frontend/src/lib/i18n/index.ts
index ec9e004..307f660 100755
--- a/privatevoice.src/frontend/src/lib/i18n/index.ts
+++ b/privatevoice.src/frontend/src/lib/i18n/index.ts
@@ -1,9 +1,11 @@
import zh from "./zh.json";
import en from "./en.json";
+import { writable } from "svelte/store";
-type Locale = "zh" | "en";
+export type Locale = "zh" | "en";
const translations: Record<Locale, Record<string, any>> = { zh, en };
+export const localeRevision = writable(0);
// Detect system language, default to Chinese
function detectLocale(): Locale {
@@ -14,8 +16,16 @@
let currentLocale: Locale = detectLocale();
-export function setLocale(locale: Locale) {
- currentLocale = locale;
+function normalizeLocale(locale: string): Locale {
+ return locale.toLowerCase().startsWith("zh") ? "zh" : "en";
+}
+
+export function setLocale(locale: Locale | string) {
+ const next = normalizeLocale(locale);
+ if (currentLocale !== next) {
+ currentLocale = next;
+ localeRevision.update((value) => value + 1);
+ }
}
export function getLocale(): Locale {
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
index 3b7e8f5..6d60438 100755
--- a/privatevoice.src/frontend/src/lib/i18n/zh.json
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -44,6 +44,13 @@
"hotkeyModeHold": "长按",
"hotkeyModeTapDesc": "点一下快捷键开始说话,再点一下结束并输入",
"hotkeyModeHoldDesc": "按住快捷键开始说话,松开后结束并输入",
+ "language": "语言版本",
+ "languageAuto": "自动",
+ "languageChinese": "中文",
+ "languageEnglish": "English",
+ "languageAutoDesc": "跟随系统语言,当前只会在中文和 English 之间选择",
+ "languageChineseDesc": "使用中文界面,模型仍保持当前离线 SenseVoice",
+ "languageEnglishDesc": "Use English interface. The offline model remains SenseVoice in this round",
"autoHide": "自动隐藏指示器",
"autoHideDesc": "操作完成后自动隐藏浮动指示器",
"soundFeedback": "提示音",
diff --git a/privatevoice.src/frontend/src/lib/stores/config.ts b/privatevoice.src/frontend/src/lib/stores/config.ts
index 1c78f57..60d8f8e 100755
--- a/privatevoice.src/frontend/src/lib/stores/config.ts
+++ b/privatevoice.src/frontend/src/lib/stores/config.ts
@@ -6,3 +6,7 @@
export const startAtLogin = writable<boolean>(false);
export const hotkeyVK = writable<number>(0xa5);
export const hotkeyMode = writable<"tap" | "hold">("hold");
+export const languageMode = writable<"auto" | "manual">("auto");
+export const languageID = writable<"zh-CN" | "en">("zh-CN");
+export const effectiveLanguageID = writable<"zh-CN" | "en">("zh-CN");
+export const uiLocale = writable<"zh" | "en">("zh");
diff --git a/privatevoice.src/internal/config/config.go b/privatevoice.src/internal/config/config.go
index 62b4673..473bf25 100755
--- a/privatevoice.src/internal/config/config.go
+++ b/privatevoice.src/internal/config/config.go
@@ -9,8 +9,10 @@
)
const (
- HotkeyModeTap = "tap"
- HotkeyModeHold = "hold"
+ HotkeyModeTap = "tap"
+ HotkeyModeHold = "hold"
+ LanguageModeAuto = "auto"
+ LanguageModeManual = "manual"
)
// Config holds the application configuration, compatible with the WPF version's config.json.
@@ -25,6 +27,8 @@
IndicatorX int `json:"IndicatorX,omitempty"`
IndicatorY int `json:"IndicatorY,omitempty"`
SelectedModelID string `json:"SelectedModelID,omitempty"`
+ LanguageMode string `json:"LanguageMode,omitempty"`
+ LanguageID string `json:"LanguageID,omitempty"`
ModelDownloadUrl string `json:"ModelDownloadUrl"`
FallbackModelDownloadUrl string `json:"FallbackModelDownloadUrl"`
}
@@ -39,6 +43,8 @@
HideDockIcon: true,
CopyToClipboard: true,
SelectedModelID: model.DefaultModelID,
+ LanguageMode: LanguageModeAuto,
+ LanguageID: model.DefaultLanguageID,
ModelDownloadUrl: "http://www.maikami.com/voicesnap/sensevoice.zip",
FallbackModelDownloadUrl: "https://modelscope.cn/models/sherpa-onnx/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2",
}
@@ -66,6 +72,12 @@
return nil, err
}
cfg.HotkeyMode = NormalizeHotkeyMode(cfg.HotkeyMode)
+ cfg.LanguageMode = NormalizeLanguageMode(cfg.LanguageMode)
+ languageID := cfg.LanguageID
+ cfg.LanguageID = model.NormalizeLanguageID(cfg.LanguageID)
+ if languageID != "" && languageID != cfg.LanguageID {
+ logger.Info("Unknown LanguageID %q, falling back to %q", languageID, cfg.LanguageID)
+ }
selectedModelID := cfg.SelectedModelID
cfg.SelectedModelID = model.NormalizeModelID(cfg.SelectedModelID)
if selectedModelID != "" && selectedModelID != cfg.SelectedModelID {
@@ -74,6 +86,15 @@
return cfg, nil
}
+func NormalizeLanguageMode(mode string) string {
+ switch mode {
+ case LanguageModeAuto, LanguageModeManual:
+ return mode
+ default:
+ return LanguageModeAuto
+ }
+}
+
func NormalizeHotkeyMode(mode string) string {
switch mode {
case HotkeyModeTap, HotkeyModeHold:
diff --git a/privatevoice.src/internal/config/config_test.go b/privatevoice.src/internal/config/config_test.go
new file mode 100644
index 0000000..1d7ee7a
--- /dev/null
+++ b/privatevoice.src/internal/config/config_test.go
@@ -0,0 +1,29 @@
+package config
+
+import (
+ "testing"
+
+ "voicesnap/internal/model"
+)
+
+func TestDefaultLanguageConfig(t *testing.T) {
+ cfg := Default()
+ if cfg.LanguageMode != LanguageModeAuto {
+ t.Fatalf("language mode = %q, want %q", cfg.LanguageMode, LanguageModeAuto)
+ }
+ if cfg.LanguageID != model.DefaultLanguageID {
+ t.Fatalf("language id = %q, want %q", cfg.LanguageID, model.DefaultLanguageID)
+ }
+ if cfg.SelectedModelID != model.DefaultModelID {
+ t.Fatalf("selected model = %q, want %q", cfg.SelectedModelID, model.DefaultModelID)
+ }
+}
+
+func TestNormalizeLanguageMode(t *testing.T) {
+ if got := NormalizeLanguageMode(LanguageModeManual); got != LanguageModeManual {
+ t.Fatalf("manual normalized to %q", got)
+ }
+ if got := NormalizeLanguageMode("bad"); got != LanguageModeAuto {
+ t.Fatalf("bad mode normalized to %q, want %q", got, LanguageModeAuto)
+ }
+}
diff --git a/privatevoice.src/internal/language/detect.go b/privatevoice.src/internal/language/detect.go
new file mode 100644
index 0000000..3d53442
--- /dev/null
+++ b/privatevoice.src/internal/language/detect.go
@@ -0,0 +1,46 @@
+package language
+
+import (
+ "os"
+ "strings"
+
+ "voicesnap/internal/model"
+)
+
+type SystemLocaleDetector interface {
+ Detect() string
+}
+
+type SystemDetector struct{}
+
+func NewSystemDetector() SystemDetector {
+ return SystemDetector{}
+}
+
+func (SystemDetector) Detect() string {
+ if locale := platformLocale(); strings.TrimSpace(locale) != "" {
+ return locale
+ }
+ for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
+ if locale := os.Getenv(key); strings.TrimSpace(locale) != "" {
+ return locale
+ }
+ }
+ return ""
+}
+
+func NormalizeLocale(locale string) string {
+ normalized := strings.ToLower(strings.TrimSpace(locale))
+ normalized = strings.ReplaceAll(normalized, "_", "-")
+ if idx := strings.Index(normalized, "."); idx >= 0 {
+ normalized = normalized[:idx]
+ }
+ switch {
+ case strings.HasPrefix(normalized, "zh"):
+ return model.DefaultLanguageID
+ case strings.HasPrefix(normalized, "en"):
+ return model.EnglishLanguageID
+ default:
+ return model.EnglishLanguageID
+ }
+}
diff --git a/privatevoice.src/internal/language/detect_darwin.go b/privatevoice.src/internal/language/detect_darwin.go
new file mode 100644
index 0000000..962c2b2
--- /dev/null
+++ b/privatevoice.src/internal/language/detect_darwin.go
@@ -0,0 +1,34 @@
+//go:build darwin
+
+package language
+
+import (
+ "os/exec"
+ "strings"
+)
+
+func platformLocale() string {
+ out, err := exec.Command("/usr/bin/defaults", "read", "-g", "AppleLocale").Output()
+ if err == nil && strings.TrimSpace(string(out)) != "" {
+ return strings.TrimSpace(string(out))
+ }
+ out, err = exec.Command("/usr/bin/defaults", "read", "-g", "AppleLanguages").Output()
+ if err == nil {
+ return firstAppleLanguage(string(out))
+ }
+ return ""
+}
+
+func firstAppleLanguage(raw string) string {
+ raw = strings.TrimSpace(raw)
+ raw = strings.TrimPrefix(raw, "(")
+ raw = strings.TrimSuffix(raw, ")")
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ part = strings.Trim(part, "\"")
+ if part != "" {
+ return part
+ }
+ }
+ return ""
+}
diff --git a/privatevoice.src/internal/language/detect_other.go b/privatevoice.src/internal/language/detect_other.go
new file mode 100644
index 0000000..712d384
--- /dev/null
+++ b/privatevoice.src/internal/language/detect_other.go
@@ -0,0 +1,7 @@
+//go:build !darwin
+
+package language
+
+func platformLocale() string {
+ return ""
+}
diff --git a/privatevoice.src/internal/language/language_test.go b/privatevoice.src/internal/language/language_test.go
new file mode 100644
index 0000000..0bf3b79
--- /dev/null
+++ b/privatevoice.src/internal/language/language_test.go
@@ -0,0 +1,75 @@
+package language
+
+import (
+ "testing"
+
+ "voicesnap/internal/config"
+ "voicesnap/internal/model"
+)
+
+type fakeDetector struct {
+ locale string
+}
+
+func (f fakeDetector) Detect() string {
+ return f.locale
+}
+
+func TestNormalizeLocale(t *testing.T) {
+ tests := map[string]string{
+ "zh-CN": model.DefaultLanguageID,
+ "zh_Hans_CN": model.DefaultLanguageID,
+ "en-US": model.EnglishLanguageID,
+ "en_GB.UTF-8": model.EnglishLanguageID,
+ "fr-FR": model.EnglishLanguageID,
+ "": model.EnglishLanguageID,
+ }
+ for input, want := range tests {
+ if got := NormalizeLocale(input); got != want {
+ t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want)
+ }
+ }
+}
+
+func TestResolveAutoUsesDetector(t *testing.T) {
+ settings := Resolve(config.LanguageModeAuto, model.DefaultLanguageID, fakeDetector{locale: "en-US"})
+ if settings.EffectiveLanguageID != model.EnglishLanguageID {
+ t.Fatalf("effective language = %q, want %q", settings.EffectiveLanguageID, model.EnglishLanguageID)
+ }
+ if settings.UILocale != "en" {
+ t.Fatalf("ui locale = %q, want en", settings.UILocale)
+ }
+}
+
+func TestResolveManualIgnoresDetector(t *testing.T) {
+ settings := Resolve(config.LanguageModeManual, model.DefaultLanguageID, fakeDetector{locale: "en-US"})
+ if settings.EffectiveLanguageID != model.DefaultLanguageID {
+ t.Fatalf("effective language = %q, want %q", settings.EffectiveLanguageID, model.DefaultLanguageID)
+ }
+ if settings.UILocale != "zh" {
+ t.Fatalf("ui locale = %q, want zh", settings.UILocale)
+ }
+}
+
+func TestResolveFallsBackInvalidValues(t *testing.T) {
+ settings := Resolve("bad-mode", "bad-language", fakeDetector{locale: "fr-FR"})
+ if settings.LanguageMode != config.LanguageModeAuto {
+ t.Fatalf("mode = %q, want %q", settings.LanguageMode, config.LanguageModeAuto)
+ }
+ if settings.EffectiveLanguageID != model.EnglishLanguageID {
+ t.Fatalf("effective language = %q, want %q", settings.EffectiveLanguageID, model.EnglishLanguageID)
+ }
+}
+
+func TestResolveOptionsAreStable(t *testing.T) {
+ settings := Resolve(config.LanguageModeAuto, model.DefaultLanguageID, fakeDetector{locale: "zh-CN"})
+ if len(settings.Options) != 2 {
+ t.Fatalf("options len = %d, want 2", len(settings.Options))
+ }
+ if settings.Options[0].ID != model.DefaultLanguageID {
+ t.Fatalf("first option = %q, want %q", settings.Options[0].ID, model.DefaultLanguageID)
+ }
+ if settings.Options[1].ID != model.EnglishLanguageID {
+ t.Fatalf("second option = %q, want %q", settings.Options[1].ID, model.EnglishLanguageID)
+ }
+}
diff --git a/privatevoice.src/internal/language/resolver.go b/privatevoice.src/internal/language/resolver.go
new file mode 100644
index 0000000..2f2e18c
--- /dev/null
+++ b/privatevoice.src/internal/language/resolver.go
@@ -0,0 +1,62 @@
+package language
+
+import (
+ "voicesnap/internal/config"
+ "voicesnap/internal/model"
+)
+
+type LanguageOption struct {
+ ID string `json:"id"`
+ DisplayName string `json:"displayName"`
+ NativeName string `json:"nativeName"`
+ UILocale string `json:"uiLocale"`
+}
+
+type LanguageSettings struct {
+ LanguageMode string `json:"languageMode"`
+ LanguageID string `json:"languageID"`
+ EffectiveLanguageID string `json:"effectiveLanguageID"`
+ UILocale string `json:"uiLocale"`
+ DefaultModelID string `json:"defaultModelID"`
+ Options []LanguageOption `json:"options"`
+}
+
+func Resolve(languageMode, languageID string, detector SystemLocaleDetector) LanguageSettings {
+ mode := config.NormalizeLanguageMode(languageMode)
+ configuredID := model.NormalizeLanguageID(languageID)
+ effectiveID := configuredID
+ if mode == config.LanguageModeAuto {
+ detected := ""
+ if detector != nil {
+ detected = detector.Detect()
+ }
+ effectiveID = NormalizeLocale(detected)
+ }
+ profile, err := model.GetLanguageProfile(effectiveID)
+ if err != nil {
+ profile, _ = model.GetLanguageProfile(model.DefaultLanguageID)
+ effectiveID = model.DefaultLanguageID
+ }
+ return LanguageSettings{
+ LanguageMode: mode,
+ LanguageID: configuredID,
+ EffectiveLanguageID: effectiveID,
+ UILocale: profile.UILocale,
+ DefaultModelID: profile.DefaultModelID,
+ Options: languageOptions(),
+ }
+}
+
+func languageOptions() []LanguageOption {
+ profiles := model.ListLanguageProfiles()
+ options := make([]LanguageOption, 0, len(profiles))
+ for _, profile := range profiles {
+ options = append(options, LanguageOption{
+ ID: profile.ID,
+ DisplayName: profile.DisplayName,
+ NativeName: profile.NativeName,
+ UILocale: profile.UILocale,
+ })
+ }
+ return options
+}
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
index 6a7821b..21e2e53 100644
--- a/privatevoice.src/internal/model/profile.go
+++ b/privatevoice.src/internal/model/profile.go
@@ -1,7 +1,9 @@
package model
const (
- DefaultModelID = "sensevoice-zh"
+ DefaultModelID = "sensevoice-zh"
+ DefaultLanguageID = "zh-CN"
+ EnglishLanguageID = "en"
)
type ModelProfile struct {
@@ -31,6 +33,7 @@
type LanguageProfile struct {
ID string
DisplayName string
+ NativeName string
UILocale string
SystemMatchers []string
DefaultModelID string
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
index 9314941..c9db604 100644
--- a/privatevoice.src/internal/model/registry.go
+++ b/privatevoice.src/internal/model/registry.go
@@ -1,6 +1,9 @@
package model
-import "fmt"
+import (
+ "fmt"
+ "sort"
+)
const (
BackendSenseVoice = "sensevoice"
@@ -43,11 +46,20 @@
}
var languageProfiles = map[string]LanguageProfile{
- "zh-CN": {
- ID: "zh-CN",
- DisplayName: "中文",
- UILocale: "zh-CN",
+ DefaultLanguageID: {
+ ID: DefaultLanguageID,
+ DisplayName: "Chinese",
+ NativeName: "中文",
+ UILocale: "zh",
SystemMatchers: []string{"zh", "zh-CN", "zh-Hans", "zh-Hant"},
+ DefaultModelID: DefaultModelID,
+ },
+ EnglishLanguageID: {
+ ID: EnglishLanguageID,
+ DisplayName: "English",
+ NativeName: "English",
+ UILocale: "en",
+ SystemMatchers: []string{"en", "en-US", "en-GB", "en-AU", "en-CA"},
DefaultModelID: DefaultModelID,
},
}
@@ -95,5 +107,21 @@
for _, profile := range languageProfiles {
profiles = append(profiles, profile)
}
+ sort.Slice(profiles, func(i, j int) bool {
+ if profiles[i].ID == DefaultLanguageID {
+ return true
+ }
+ if profiles[j].ID == DefaultLanguageID {
+ return false
+ }
+ return profiles[i].ID < profiles[j].ID
+ })
return profiles
}
+
+func NormalizeLanguageID(id string) string {
+ if _, err := GetLanguageProfile(id); err != nil {
+ return DefaultLanguageID
+ }
+ return id
+}
diff --git a/privatevoice.src/services/config_service.go b/privatevoice.src/services/config_service.go
index 6bffa6f..49c53c6 100755
--- a/privatevoice.src/services/config_service.go
+++ b/privatevoice.src/services/config_service.go
@@ -3,16 +3,26 @@
import (
"voicesnap/internal/config"
"voicesnap/internal/dock"
+ "voicesnap/internal/language"
+ "voicesnap/internal/model"
"voicesnap/internal/startup"
)
// ConfigService provides configuration read/write methods to the frontend.
type ConfigService struct {
- cfg *config.Config
+ cfg *config.Config
+ localeDetector language.SystemLocaleDetector
}
func NewConfigService(cfg *config.Config) *ConfigService {
- return &ConfigService{cfg: cfg}
+ return NewConfigServiceWithDetector(cfg, language.NewSystemDetector())
+}
+
+func NewConfigServiceWithDetector(cfg *config.Config, detector language.SystemLocaleDetector) *ConfigService {
+ if detector == nil {
+ detector = language.NewSystemDetector()
+ }
+ return &ConfigService{cfg: cfg, localeDetector: detector}
}
// GetConfig returns the current configuration.
@@ -20,6 +30,28 @@
return s.cfg
}
+// GetLanguageSettings returns the configured and effective UI language.
+func (s *ConfigService) GetLanguageSettings() language.LanguageSettings {
+ return language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
+}
+
+// SetLanguageAuto makes the UI language follow the system language.
+func (s *ConfigService) SetLanguageAuto() language.LanguageSettings {
+ s.cfg.LanguageMode = config.LanguageModeAuto
+ resolved := language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
+ s.cfg.LanguageID = resolved.EffectiveLanguageID
+ config.Save(s.cfg)
+ return language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
+}
+
+// SetLanguageManual persists a user-selected UI language.
+func (s *ConfigService) SetLanguageManual(languageID string) language.LanguageSettings {
+ s.cfg.LanguageMode = config.LanguageModeManual
+ s.cfg.LanguageID = model.NormalizeLanguageID(languageID)
+ config.Save(s.cfg)
+ return language.Resolve(s.cfg.LanguageMode, s.cfg.LanguageID, s.localeDetector)
+}
+
// SetAutoHide sets the auto-hide indicator preference.
func (s *ConfigService) SetAutoHide(enabled bool) {
s.cfg.AutoHide = enabled
--
Gitblit v1.9.3