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