From 3fbceec8de833416bf1ae049f959f222c3f1a4a0 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 02 Jun 2026 14:05:59 +0800
Subject: [PATCH] Prepare Round 1 model profile QA baseline

---
 privatevoice.src/frontend/src/components/settings/CorrectionImportPreviewDialog.svelte          |  325 +
 privatevoice.src/internal/singleinstance/singleinstance_unix.go                                 |    2 
 privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/model.int8.onnx                 |    1 
 privatevoice.src/services/hotkey_service.go                                                     |    0 
 privatevoice.src/frontend/src/components/settings/GeneralPage.svelte                            |    0 
 privatevoice.src/services/permission_service.go                                                 |    0 
 privatevoice.src/build/darwin/VoiceSnap-macos26-mic.icns                                        |    0 
 privatevoice.src/frontend/src/components/settings/UserDictPage.svelte                           |  704 +++
 privatevoice.src/services/audio_service.go                                                      |    0 
 privatevoice.src/scripts/build-local-macos.sh                                                   |   22 
 privatevoice.src/internal/hotkey/hotkey_windows.go                                              |    0 
 privatevoice.src/design/icon-candidates/voicesnap_icon_E_compact-liquid.png                     |    0 
 privatevoice.src/frontend/package.json                                                          |    4 
 privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte                        |   16 
 privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/model.int8.onnx              |    1 
 02-P-NBL/round-1/dev-checklist.md                                                               |  142 
 privatevoice.src/design/icon-candidates/voicesnap_icon_C_taglauncher-style.png                  |    0 
 privatevoice.src/internal/startup/startup_linux.go                                              |   74 
 privatevoice.src/internal/dock/dock_darwin.go                                                   |    0 
 privatevoice.src/frontend/src/lib/i18n/en.json                                                  |  245 +
 02-P-NBL/round-1/todo-list.md                                                                   |  133 
 privatevoice.src/build/darwin/app_icon_source.png                                               |    0 
 privatevoice.src/testdata/round1/models-incomplete-missing-tokens/sensevoice-zh/model.int8.onnx |    1 
 privatevoice.src/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.svg                    |    0 
 privatevoice.src/internal/startup/startup_darwin.go                                             |   95 
 privatevoice.src/scripts/build-appstore-developerid-macos.sh                                    |  206 
 privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte                         |   18 
 privatevoice.src/internal/logger/logger.go                                                      |    0 
 privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/model.int8.onnx              |    1 
 privatevoice.src/build/windows/wails.exe.manifest                                               |    4 
 privatevoice.src/frontend/svelte.config.js                                                      |    0 
 privatevoice.src/internal/startup/startup_windows.go                                            |   67 
 privatevoice.src/frontend/src/components/shared/ToggleSwitch.svelte                             |    0 
 privatevoice.src/app.go                                                                         |  212 
 02-P-NBL/round-1/README.md                                                                      |   17 
 privatevoice.src/frontend/src/lib/stores/app.ts                                                 |    0 
 privatevoice.src/frontend/instructions.md                                                       |    0 
 privatevoice.src/frontend/package-lock.json                                                     |    8 
 privatevoice.src/internal/paths/paths.go                                                        |   50 
 privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/model.int8.onnx                 |    1 
 02-P-NBL/round-1/qa-precheck-current.md                                                         |  143 
 privatevoice.src/build/windows/Taskfile.yml                                                     |    0 
 privatevoice.src/frontend/src/components/update/UpdateDialog.svelte                             |    0 
 privatevoice.src/internal/audio/recorder.go                                                     |   53 
 privatevoice.src/build/darwin/entitlements-appstore.plist                                       |   14 
 privatevoice.src/services/app_service.go                                                        |    2 
 privatevoice.src/internal/model/install_state.go                                                |   97 
 02-P-NBL/round-1/logs/go-test-all-20260602.log                                                  |   21 
 02-P-NBL/round-1/engineering-tasks.md                                                           |  386 +
 privatevoice.src/build/darwin/VoiceSnap-selected-B-shadow.icns                                  |    0 
 privatevoice.src/frontend/src/styles/global.css                                                 |    0 
 privatevoice.src/internal/userdict/userdict.go                                                  |  558 ++
 privatevoice.src/frontend/vite.config.ts                                                        |    0 
 privatevoice.src/build/windows/icon.ico                                                         |    0 
 privatevoice.src/frontend/src/lib/stores/update.ts                                              |    0 
 privatevoice.src/design/icon-candidates/voicesnap_icon_D_glass-circle.svg                       |    0 
 privatevoice.src/instructions.md                                                                |    0 
 privatevoice.src/testdata/round1/state-points-to-legacy/state.json                              |   12 
 privatevoice.src/internal/dock/dock.go                                                          |    0 
 privatevoice.src/testdata/round1/models-zero-byte/sensevoice-zh/tokens.txt                      |    1 
 privatevoice.src/frontend/src/components/shared/AppIcon.svelte                                  |   36 
 privatevoice.src/design/icon-candidates/voicesnap_icon_E_compact-liquid.svg                     |    0 
 privatevoice.src/frontend/tsconfig.json                                                         |    0 
 privatevoice.src/internal/model/validator.go                                                    |   80 
 privatevoice.src/build/darwin/macos26_mic_app_icon.png                                          |    0 
 privatevoice.src/frontend/src/lib/i18n/zh.json                                                  |  245 +
 privatevoice.src/frontend/src/lib/stores/indicator.ts                                           |    0 
 privatevoice.src/services/history_service.go                                                    |  124 
 privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte                            |    0 
 privatevoice.src/scripts/build-release-macos.sh                                                 |   10 
 02-P-NBL/round-1/qa-report-template.md                                                          |  127 
 privatevoice.src/internal/input/paste_linux.go                                                  |    0 
 privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/tokens.txt                      |    1 
 privatevoice.src/internal/overlay/overlay.go                                                    |   31 
 privatevoice.src/internal/model/resolver.go                                                     |  136 
 privatevoice.src/build/darwin/Info.plist                                                        |   14 
 privatevoice.src/design/icon-candidates/voicesnap_icon_candidates_preview.svg                   |    0 
 privatevoice.src/frontend/src/vite-env.d.ts                                                     |    4 
 privatevoice.src/build/darwin/Taskfile.yml                                                      |    0 
 privatevoice.src/testdata/round1/state-points-to-broken/broken/tokens.txt                       |    1 
 privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/tokens.txt                   |    1 
 privatevoice.src/build/darwin/refined_mic_app_icon.png                                          |    0 
 privatevoice.src/build/darwin/VoiceSnap-original.icns                                           |    0 
 privatevoice.src/build/darwin/icon.icns                                                         |    0 
 privatevoice.src/internal/updater/updater.go                                                    |    2 
 privatevoice.src/testdata/round1/state-points-to-broken/state.json                              |   12 
 privatevoice.src/go.mod                                                                         |    0 
 privatevoice.src/internal/textproc/textproc.go                                                  |    0 
 privatevoice.src/services/config_service.go                                                     |    0 
 privatevoice.src/internal/hotkey/hotkey_darwin.go                                               |   17 
 privatevoice.src/build/linux/privatevoice-input.desktop                                         |   10 
 privatevoice.src/services/correction_csv_service.go                                             |  222 
 privatevoice.src/internal/model/profile.go                                                      |   65 
 privatevoice.src/embed.go                                                                       |    0 
 privatevoice.src/internal/engine/engine_darwin.go                                               |   54 
 privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte                       |   14 
 privatevoice.src/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.svg                      |    0 
 privatevoice.src/frontend/src/lib/stores/config.ts                                              |    0 
 02-P-NBL/round-1/logs/npm-run-build-20260602.log                                                |   38 
 privatevoice.src/testdata/round1/README.md                                                      |   18 
 privatevoice.src/internal/correctioncsv/correctioncsv.go                                        |  412 +
 privatevoice.src/build/Taskfile.yml                                                             |    0 
 privatevoice.src/internal/engine/engine_windows.go                                              |   14 
 privatevoice.src/design/icon-candidates/voicesnap_icon_D_glass-circle.png                       |    0 
 privatevoice.src/internal/singleinstance/singleinstance_windows.go                              |    2 
 privatevoice.src/internal/overlay/overlay_windows.go                                            |   55 
 02-P-NBL/product-plan.md                                                                        |  399 +
 privatevoice.src/design/icon-candidates/voicesnap_icon_B_shadow_preview.svg                     |    0 
 privatevoice.src/internal/input/paste.go                                                        |    0 
 privatevoice.src/voicesnap.rc                                                                   |    0 
 privatevoice.src/build/darwin/app_icon.png                                                      |    0 
 privatevoice.src/internal/history/history_test.go                                               |  379 +
 privatevoice.src/internal/hotkey/hotkey_linux.go                                                |    0 
 privatevoice.src/internal/startup/startup.go                                                    |    0 
 02-P-NBL/round-1/logs/go-test-internal-model-20260602.log                                       |    1 
 privatevoice.src/internal/config/config.go                                                      |    8 
 privatevoice.src/design/icon-candidates/voicesnap_icon_candidates_preview.png                   |    0 
 privatevoice.src/internal/input/paste_darwin.go                                                 |    0 
 privatevoice.src/internal/hotkey/hotkey.go                                                      |    0 
 privatevoice.src/frontend/src/components/settings/AboutPage.svelte                              |  157 
 privatevoice.src/internal/engine/engine_linux.go                                                |   14 
 privatevoice.src/build/darwin/VoiceSnap-refined-mic.icns                                        |    0 
 privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte                     |    0 
 privatevoice.src/internal/permissions/permissions_darwin.go                                     |    0 
 privatevoice.src/frontend/index.html                                                            |    2 
 privatevoice.src/internal/singleinstance/singleinstance.go                                      |    0 
 privatevoice.src/go.sum                                                                         |    0 
 privatevoice.src/internal/startup/startup_darwin_appstore.go                                    |   61 
 privatevoice.src/build/config.yml                                                               |   14 
 privatevoice.src/build/darwin/approved_app_icon.png                                             |    0 
 02-P-NBL/round-1/qa-handoff-current.md                                                          |  130 
 02-P-NBL/round-1/qa-test-plan.md                                                                |  266 +
 02-P-NBL/round-1/dev-checklist-current.md                                                       |  150 
 privatevoice.src/build/darwin/entitlements.plist                                                |    0 
 privatevoice.src/testdata/round1/models-incomplete-missing-model/sensevoice-zh/tokens.txt       |    1 
 privatevoice.src/design/icon-candidates/voicesnap_icon_B_shadow_preview.png                     |    0 
 privatevoice.src/services/updater_service.go                                                    |    0 
 privatevoice.src/build/darwin/VoiceSnap-approved.icns                                           |    0 
 .gitignore                                                                                      |    7 
 02-P-NBL/engineering-plan.md                                                                    |  691 +++
 privatevoice.src/frontend/src/components/settings/HistoryPage.svelte                            | 1129 +++++
 privatevoice.src/services/userdict_service.go                                                   |    5 
 privatevoice.src/internal/correctioncsv/correctioncsv_e2e_test.go                               |  130 
 privatevoice.src/internal/input/paste_windows.go                                                |    0 
 02-P-NBL/plan.md                                                                                |  537 ++
 privatevoice.src/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.png                      |    0 
 privatevoice.src/frontend/src/lib/i18n/index.ts                                                 |    0 
 privatevoice.src/internal/sound/sound_windows.go                                                |    0 
 privatevoice.src/internal/userdict/userdict_test.go                                             |  160 
 privatevoice.src/design/icon-candidates/voicesnap_icon_C_taglauncher-style.svg                  |    0 
 privatevoice.src/wails.json                                                                     |    6 
 privatevoice.src/main.go                                                                        |    4 
 privatevoice.src/frontend/src/assets/app-icon.png                                               |    0 
 privatevoice.src/build/darwin/original_app_icon.png                                             |    0 
 privatevoice.src/internal/engine/engine.go                                                      |   94 
 privatevoice.src/Taskfile.yml                                                                   |    2 
 privatevoice.src/scripts/build-appstore-macos.sh                                                |  156 
 privatevoice.src/build/darwin/tray_icon.png                                                     |    0 
 privatevoice.src/locale_windows.go                                                              |    0 
 02-P-NBL/round-1/prd.md                                                                         |  930 ++++
 privatevoice.src/internal/dock/dock_other.go                                                    |    0 
 privatevoice.src/internal/model/registry.go                                                     |   99 
 privatevoice.src/internal/sound/sound_other.go                                                  |    0 
 privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/tokens.txt                   |    1 
 privatevoice.src/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.png                    |    0 
 privatevoice.src/internal/overlay/overlay_other.go                                              |   16 
 privatevoice.src/internal/permissions/permissions_other.go                                      |    0 
 privatevoice.src/internal/model/model_test.go                                                   |  389 +
 privatevoice.src/internal/permissions/permissions.go                                            |    0 
 privatevoice.src/testdata/round1/state-invalid-json/state.json                                  |    1 
 privatevoice.src/internal/correctioncsv/correctioncsv_test.go                                   |  186 
 /dev/null                                                                                       |   44 
 privatevoice.src/internal/model/downloader.go                                                   |  264 +
 privatevoice.src/frontend/src/main.ts                                                           |    0 
 privatevoice.src/internal/history/history.go                                                    |  480 ++
 privatevoice.src/internal/overlay/overlay_darwin.go                                             | 1027 ++++
 privatevoice.src/frontend/src/App.svelte                                                        |    9 
 privatevoice.src/services/engine_service.go                                                     |   25 
 privatevoice.src/locale_other.go                                                                |    0 
 privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/tokens.txt                      |    1 
 180 files changed, 13,136 insertions(+), 220 deletions(-)

diff --git a/.gitignore b/.gitignore
index fbce929..6e2778c 100755
--- a/.gitignore
+++ b/.gitignore
@@ -6,16 +6,23 @@
 # Frontend dependencies and generated output
 VoiceSnapGo/frontend/node_modules/
 VoiceSnapGo/frontend/dist/
+privatevoice.src/frontend/node_modules/
+privatevoice.src/frontend/dist/
 
 # Go/build artifacts
 VoiceSnapGo/build/local/
 VoiceSnapGo/VoiceSnap
+privatevoice.src/build/local/
+privatevoice.src/build/appstore-local/
+privatevoice.src/PrivateVoice
 *.app/
 *.dmg
 
 # Generated iconset intermediates; keep source PNG/ICNS assets in build/darwin.
 VoiceSnapGo/build/darwin/icon.iconset/
 VoiceSnapGo/build/darwin/approved_app_icon.iconset/
+privatevoice.src/build/darwin/icon.iconset/
+privatevoice.src/build/darwin/approved_app_icon.iconset/
 
 # Runtime data should live in Application Support, not source control.
 config.json
diff --git a/02-P-NBL/engineering-plan.md b/02-P-NBL/engineering-plan.md
new file mode 100644
index 0000000..6ae6c31
--- /dev/null
+++ b/02-P-NBL/engineering-plan.md
@@ -0,0 +1,691 @@
+# VoiceSnap 多语言与多模型总体研发规划
+
+状态:研发规划稿
+日期:2026-06-01
+范围:语言检测、模型 profile 抽象、多模型下载/切换/删除、首批四模型接入
+
+## 1. 当前技术现状
+
+当前实现基本是单模型架构,核心路径围绕 SenseVoice 写死。
+
+已知硬编码点:
+
+- 模型目录固定为 `models/sensevoice`。
+- 模型文件固定为 `model.int8.onnx` 或 `model.onnx`。
+- `tokens.txt` 固定在同一个 SenseVoice 目录下。
+- engine 初始化固定写入 `config.ModelConfig.SenseVoice.Model`。
+- 下载器只识别 `sherpa-onnx-sense-voice...` 并重命名为 `sensevoice`。
+- 配置项只有一个模型下载 URL 和 fallback URL。
+
+要支持:
+
+```text
+中文默认 SenseVoice
+中文高级 Qwen3-ASR
+English 默认 Moonshine
+English 高级 Parakeet
+```
+
+必须引入模型 profile 抽象。
+
+## 2. 研发目标
+
+本轮研发目标:
+
+1. 从单 SenseVoice 模型改为多模型 profile。
+2. 支持语言版本和默认模型映射。
+3. 支持按系统语言首次选择默认语言和默认模型。
+4. 支持模型安装状态管理。
+5. 支持下载、校验、切换、删除模型。
+6. 支持四个首批模型:
+   - SenseVoice
+   - Qwen3-ASR
+   - Moonshine English
+   - Parakeet
+7. 保持现有录音、热键、上屏、历史、用户词库逻辑可复用。
+
+## 3. 核心架构设计
+
+新增三个核心概念:
+
+```text
+LanguageProfile
+ModelProfile
+ModelInstallState
+```
+
+## 4. LanguageProfile
+
+`LanguageProfile` 表示产品语言版本和默认识别方向。
+
+建议结构:
+
+```go
+type LanguageProfile struct {
+	ID              string
+	DisplayName     string
+	UILocale        string
+	SystemMatchers  []string
+	DefaultModelID  string
+	UpgradeModelIDs []string
+}
+```
+
+首批配置:
+
+```text
+zh-CN
+- displayName: 中文
+- uiLocale: zh-CN
+- systemMatchers: zh, zh-CN, zh-Hans, zh-Hant
+- defaultModelId: sensevoice-zh
+- upgradeModelIds: qwen3-asr-0.6b
+
+en
+- displayName: English
+- uiLocale: en
+- systemMatchers: en, en-US, en-GB
+- defaultModelId: moonshine-en
+- upgradeModelIds: parakeet-en
+```
+
+未来可扩展:
+
+```text
+yue
+ja
+ko
+fr
+de
+es
+...
+```
+
+粤语应使用独立 `languageId`,不要简单归入中文。
+
+## 5. ModelProfile
+
+`ModelProfile` 表示一个可安装、可切换的 ASR 模型。
+
+建议结构:
+
+```go
+type ModelProfile struct {
+	ID              string
+	DisplayName     string
+	BackendKind     string
+	Tier            string
+	LanguageIDs     []string
+	RecommendedFor  []string
+	Description     string
+	ApproxSize      string
+	DownloadURLs    []string
+	InstallDirName  string
+	RequiredFiles   []string
+	ProviderOrder   []string
+	NumThreads      int
+}
+```
+
+字段说明:
+
+| 字段 | 说明 |
+|---|---|
+| `ID` | 稳定模型 ID,不能随显示名变化 |
+| `BackendKind` | 决定 sherpa config builder |
+| `Tier` | `default` 或 `advanced` |
+| `LanguageIDs` | 模型支持的语言 |
+| `RecommendedFor` | 当前产品推荐使用的语言 |
+| `DownloadURLs` | 主下载和备用下载 |
+| `RequiredFiles` | 安装完成必须存在的文件 |
+| `ProviderOrder` | macOS 可为 `coreml,cpu` 或 `cpu` |
+
+## 6. 首批 ModelProfile
+
+### 6.1 SenseVoice
+
+```text
+id: sensevoice-zh
+backendKind: sensevoice
+tier: default
+recommendedFor: zh-CN
+installDirName: sensevoice-zh
+requiredFiles:
+  - model.int8.onnx 或 model.onnx
+  - tokens.txt
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.SenseVoice.Model = modelPath
+config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
+config.ModelConfig.Tokens = tokensPath
+```
+
+### 6.2 Qwen3-ASR
+
+```text
+id: qwen3-asr-0.6b
+backendKind: qwen3_asr
+tier: advanced
+recommendedFor: zh-CN, yue
+installDirName: qwen3-asr-0.6b
+requiredFiles:
+  - conv_frontend.onnx
+  - encoder.int8.onnx
+  - decoder.int8.onnx
+  - tokenizer/merges.txt
+  - tokenizer/vocab.json
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.Qwen3ASR.ConvFrontend = convFrontendPath
+config.ModelConfig.Qwen3ASR.Encoder = encoderPath
+config.ModelConfig.Qwen3ASR.Decoder = decoderPath
+config.ModelConfig.Qwen3ASR.Tokenizer = tokenizerDir
+```
+
+备注:
+
+- Qwen3-ASR 体积明显大于 SenseVoice。
+- 只作为用户主动选择的高级模型。
+- Qwen3-ASR 支持多语言和多种中文方言,后续可以作为更多语言的高级模型复用。
+
+### 6.3 Moonshine English
+
+```text
+id: moonshine-en
+backendKind: moonshine
+tier: default
+recommendedFor: en
+installDirName: moonshine-en
+requiredFiles:
+  - encoder_model.ort
+  - decoder_model_merged.ort
+  - tokens.txt
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.Moonshine.Encoder = encoderPath
+config.ModelConfig.Moonshine.MergedDecoder = mergedDecoderPath
+config.ModelConfig.Tokens = tokensPath
+```
+
+备注:
+
+- 使用 sherpa-onnx Moonshine English quantized 路线。
+- 不在第一阶段接 Moonshine Voice framework。
+- 不做实时 partial transcript。
+
+### 6.4 Parakeet
+
+```text
+id: parakeet-en
+backendKind: nemo_transducer
+tier: advanced
+recommendedFor: en
+installDirName: parakeet-en
+requiredFiles:
+  - encoder.int8.onnx
+  - decoder.int8.onnx
+  - joiner.int8.onnx
+  - tokens.txt
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.Transducer.Encoder = encoderPath
+config.ModelConfig.Transducer.Decoder = decoderPath
+config.ModelConfig.Transducer.Joiner = joinerPath
+config.ModelConfig.Tokens = tokensPath
+config.ModelConfig.ModelType = "nemo_transducer"
+```
+
+备注:
+
+- 作为 English 高级模型。
+- 体积大于 Moonshine English。
+- 用户主动下载后可切换。
+
+## 7. 配置文件扩展
+
+当前 config 需要新增字段。
+
+建议:
+
+```go
+type Config struct {
+	// existing fields...
+	LanguageMode    string `json:"LanguageMode"`    // auto | manual
+	LanguageID      string `json:"LanguageID"`      // zh-CN | en | yue ...
+	SelectedModelID string `json:"SelectedModelID"` // sensevoice-zh | moonshine-en ...
+}
+```
+
+默认逻辑:
+
+```text
+LanguageMode = auto
+LanguageID = detectSystemLanguage()
+SelectedModelID = defaultModelFor(LanguageID)
+```
+
+如果用户手动切换语言:
+
+```text
+LanguageMode = manual
+LanguageID = userSelectedLanguage
+```
+
+切换语言时:
+
+- 如果当前模型仍适合该语言,可以不强制切换。
+- 如果当前模型不适合该语言,提示用户切换到推荐默认模型。
+- 不自动删除已安装模型。
+
+## 8. 模型安装状态
+
+建议新增模型状态文件:
+
+```text
+models/state.json
+```
+
+示例:
+
+```json
+{
+  "installedModels": {
+    "sensevoice-zh": {
+      "installedAt": 1780120000000,
+      "version": "2025.09.09",
+      "path": "models/sensevoice-zh"
+    },
+    "moonshine-en": {
+      "installedAt": 1780120000000,
+      "version": "2026.02.27",
+      "path": "models/moonshine-en"
+    }
+  }
+}
+```
+
+也可以用每个模型目录下的 manifest:
+
+```text
+models/<modelId>/manifest.json
+```
+
+推荐两者都支持:
+
+- `models/state.json` 用于快速列出状态。
+- `models/<modelId>/manifest.json` 用于校验单个模型目录。
+
+## 9. 路径规划
+
+旧结构:
+
+```text
+models/sensevoice
+```
+
+新结构:
+
+```text
+models/
+  sensevoice-zh/
+  qwen3-asr-0.6b/
+  moonshine-en/
+  parakeet-en/
+  state.json
+```
+
+兼容策略:
+
+- 如果发现旧目录 `models/sensevoice`,迁移或识别为 `sensevoice-zh`。
+- 不覆盖用户已有模型。
+- 迁移失败时保留旧目录,并提示重新下载。
+
+## 10. 下载器改造
+
+当前下载器写死 SenseVoice 解压逻辑,需要改成通用下载器。
+
+目标接口:
+
+```go
+func DownloadModel(profile ModelProfile, progress ProgressCallback) error
+```
+
+流程:
+
+1. 创建临时下载目录。
+2. 按 `profile.DownloadURLs` 顺序下载。
+3. 解压到临时目录。
+4. 在解压结果中定位 `RequiredFiles`。
+5. 移动到 `models/<modelId>`。
+6. 写入 manifest。
+7. 更新 `models/state.json`。
+8. 清理临时文件。
+
+必须保证:
+
+- 下载失败不破坏已安装模型。
+- 解压失败不破坏已安装模型。
+- 校验失败不破坏已安装模型。
+- 切换模型失败不改变当前 active model。
+
+## 11. EngineFactory 改造
+
+新增:
+
+```go
+func New(profileID string) (Engine, error)
+```
+
+或:
+
+```go
+func NewWithProfile(profile ModelProfile) (Engine, error)
+```
+
+内部根据 `BackendKind` 分发:
+
+```go
+switch profile.BackendKind {
+case "sensevoice":
+	buildSenseVoiceConfig(profile)
+case "moonshine":
+	buildMoonshineConfig(profile)
+case "nemo_transducer":
+	buildNemoTransducerConfig(profile)
+case "qwen3_asr":
+	buildQwen3ASRConfig(profile)
+}
+```
+
+平台文件 `engine_darwin.go`、`engine_windows.go`、`engine_linux.go` 保留 provider 差异,但不要再写死 SenseVoice。
+
+## 12. 服务层改造
+
+`EngineService` 增加:
+
+```text
+ListLanguages()
+GetCurrentLanguage()
+SetLanguage(languageId)
+
+ListModels()
+GetCurrentModel()
+ModelExists(modelId)
+DownloadModel(modelId)
+UseModel(modelId)
+DeleteModel(modelId)
+```
+
+注意:
+
+- `DeleteModel(currentModelId)` 必须拒绝。
+- `UseModel(modelId)` 要求模型已安装。
+- `DownloadModel(modelId)` 成功后不一定自动使用,产品可决定。
+- 如果是用户点击“下载并使用”,前端可以下载成功后调用 `UseModel`。
+
+## 13. 前端改造
+
+设置页增加两个页面或区域:
+
+```text
+Language
+Recognition Model
+```
+
+### 13.1 Language 页面
+
+第一阶段显示:
+
+```text
+Auto
+中文
+English
+```
+
+后续扩展为多语言列表。
+
+### 13.2 Recognition Model 页面
+
+模型卡片字段:
+
+- 名称
+- 适合语言
+- 特点说明
+- 大小
+- 状态
+- 操作按钮
+
+状态:
+
+```text
+Not Installed
+Downloading
+Installed
+In Use
+```
+
+操作:
+
+```text
+Download
+Use
+Delete
+```
+
+## 14. 实现阶段
+
+### Phase 1:模型 profile 基础设施
+
+目标:不改变用户体验,先把现有 SenseVoice 放进 profile 架构。
+
+交付:
+
+- `ModelProfile`。
+- `LanguageProfile`。
+- `ModelRegistry`。
+- `LanguageRegistry`。
+- `sensevoice-zh` profile。
+- `EngineFactory` 初步抽象。
+- 现有 SenseVoice 路径兼容。
+
+验收:
+
+- 当前中文路径功能不退化。
+- 现有模型仍能加载。
+- 当前配置仍能兼容。
+
+### Phase 2:语言检测和默认模型
+
+目标:首次启动按系统语言选择语言版本和默认模型。
+
+交付:
+
+- 系统语言检测。
+- `LanguageMode`、`LanguageID`、`SelectedModelID` 配置字段。
+- 中文默认 `sensevoice-zh`。
+- English 默认 `moonshine-en`。
+- 未支持语言默认 English。
+
+验收:
+
+- 中文系统默认中文 + SenseVoice。
+- English 系统默认 English + Moonshine。
+- 用户手动切换语言后保存。
+
+### Phase 3:Moonshine English 接入
+
+目标:English 默认模型可用。
+
+交付:
+
+- `moonshine-en` profile。
+- Moonshine required files 校验。
+- Moonshine config builder。
+- Moonshine 下载/安装/加载。
+- English UI 文案。
+
+验收:
+
+- Moonshine 模型可下载。
+- 下载后可初始化。
+- 英文音频可识别。
+- 用户词库、历史、上屏逻辑复用。
+
+### Phase 4:Parakeet 接入
+
+目标:English 高级模型可选。
+
+交付:
+
+- `parakeet-en` profile。
+- NeMo transducer config builder。
+- Parakeet required files 校验。
+- Parakeet 下载/安装/切换/删除。
+
+验收:
+
+- Moonshine 和 Parakeet 可在设置中切换。
+- Parakeet 未安装时不能直接使用。
+- Parakeet 下载后可使用。
+- 删除 Parakeet 后可回到 Moonshine。
+
+### Phase 5:Qwen3-ASR 接入
+
+目标:中文高级模型可选。
+
+交付:
+
+- `qwen3-asr-0.6b` profile。
+- Qwen3-ASR config builder。
+- Qwen3-ASR tokenizer 目录校验。
+- Qwen3-ASR 下载/安装/切换/删除。
+
+验收:
+
+- SenseVoice 和 Qwen3-ASR 可切换。
+- Qwen3-ASR 未安装时不能直接使用。
+- 删除 Qwen3-ASR 后可回到 SenseVoice。
+
+### Phase 6:模型删除与恢复体验
+
+目标:完善长期使用体验。
+
+交付:
+
+- 当前模型删除保护。
+- 删除确认。
+- 模型磁盘占用展示。
+- 模型损坏后重新下载。
+- 模型切换失败回滚。
+
+验收:
+
+- 不会删除当前模型。
+- 删除未使用模型不影响设置和用户数据。
+- 模型损坏时可重新下载。
+
+## 15. 测试计划
+
+### 15.1 单元测试
+
+- 系统语言匹配。
+- 默认模型映射。
+- profile required files 校验。
+- 模型状态读写。
+- 旧配置兼容。
+- 旧 `models/sensevoice` 兼容。
+- 删除当前模型被拒绝。
+- 未安装模型不能使用。
+
+### 15.2 集成测试
+
+- 中文默认路径。
+- English 默认路径。
+- SenseVoice -> Qwen3-ASR 切换。
+- Qwen3-ASR -> SenseVoice 回退。
+- Moonshine -> Parakeet 切换。
+- Parakeet -> Moonshine 回退。
+- 下载失败不破坏当前模型。
+- 初始化失败不改变当前模型。
+
+### 15.3 手工 QA
+
+- macOS 中文系统首次启动。
+- macOS English 系统首次启动。
+- 修改系统语言后首次启动。
+- 用户手动切换语言。
+- 用户下载高级模型。
+- 用户删除非当前模型。
+- 用户尝试删除当前模型。
+
+## 16. 主要风险
+
+### 16.1 模型体积
+
+高级模型体积大。Qwen3-ASR 和 Parakeet 都不适合作为默认模型。
+
+缓解:
+
+- 默认模型轻量。
+- 高级模型主动下载。
+- 下载前显示体积。
+- 默认模型保留。
+
+### 16.2 模型包结构差异
+
+四个模型文件结构不同。
+
+缓解:
+
+- 用 `BackendKind` + `RequiredFiles`。
+- 不再写死 `model.int8.onnx`。
+
+### 16.3 切换失败
+
+模型下载成功不代表初始化成功。
+
+缓解:
+
+- 切换前保留旧 recognizer。
+- 新模型初始化成功后再替换 active engine。
+- 失败时回滚。
+
+### 16.4 未来语言扩展
+
+如果第一版把 UI 和模型写死为中英,会阻碍后续 30 种语言。
+
+缓解:
+
+- 语言和模型均 profile 化。
+- UI 从 registry 渲染。
+- 不在业务逻辑里硬编码四个模型。
+
+### 16.5 许可证和分发
+
+模型能否随 App 分发、是否需要用户单独下载,需要发布前确认。
+
+缓解:
+
+- runtime JSON 不需要保存许可证字段。
+- 但发布流程中必须做模型分发许可检查。
+- 优先采用用户主动下载模型包的方式。
+
+## 17. 参考资料
+
+- sherpa-onnx Moonshine v2 models: https://k2-fsa.github.io/sherpa/onnx/moonshine/models-v2.html
+- sherpa-onnx Parakeet NeMo transducer models: https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html
+- sherpa-onnx Qwen3-ASR: https://k2-fsa.github.io/sherpa/onnx/qwen3-asr/index.html
+- sherpa-onnx Qwen3-ASR pretrained model: https://k2-fsa.github.io/sherpa/onnx/qwen3-asr/pretrained.html
+- Qwen3-ASR Technical Report: https://arxiv.org/abs/2601.21337
diff --git a/02-P-NBL/plan.md b/02-P-NBL/plan.md
new file mode 100644
index 0000000..02f1b28
--- /dev/null
+++ b/02-P-NBL/plan.md
@@ -0,0 +1,537 @@
+# VoiceSnap 多语言与多模型 Round 分期计划
+
+状态:研发分期建议稿
+日期:2026-06-02
+子项目目录:`02-P-NBL`
+关联文档:
+
+- `product-plan.md`
+- `engineering-plan.md`
+
+## 1. 分期原则
+
+这次改造不建议一次性做完。原因:
+
+- 当前代码是单 SenseVoice 模型路径,直接上四模型会扩大风险。
+- Moonshine、Parakeet、Qwen3-ASR 的模型文件结构不同。
+- 下载、切换、删除、回滚都涉及用户本地大文件,必须分阶段验证。
+- 未来要支持 30+ 语言,第一版就要避免硬编码。
+
+建议拆成 6 个 Round。
+
+核心顺序:
+
+```text
+Round 1:先抽象
+Round 2:再做语言
+Round 3:接英文默认 Moonshine
+Round 4:接英文高级 Parakeet
+Round 5:接中文高级 Qwen3-ASR
+Round 6:完善模型管理和未来欧洲语言扩展
+```
+
+第一期可交付版本建议至少做到:
+
+```text
+Round 1 + Round 2 + Round 3
+```
+
+这样可以真正形成一个英文默认 Moonshine 的可用分支。
+
+## 2. Round 总览
+
+| Round | 目标 | 用户可见变化 | 风险 |
+|---|---|---|---|
+| Round 1 | 模型 profile 基础设施 | 基本无变化 | 中 |
+| Round 2 | 语言检测与语言设置 | 可选择中文/English | 中 |
+| Round 3 | Moonshine English 默认模型 | 英文系统默认 Moonshine | 中高 |
+| Round 4 | Parakeet 高级模型 | 英文用户可下载 Parakeet | 中 |
+| Round 5 | Qwen3-ASR 高级模型 | 中文用户可下载 Qwen3-ASR | 高 |
+| Round 6 | 模型管理成熟化 + 欧洲语言准备 | 删除、回滚、未来多语言 | 中 |
+
+## 3. Round 1:模型 Profile 基础设施
+
+### 3.1 目标
+
+不改变用户体验,先把现有 SenseVoice 纳入多模型架构。
+
+当前:
+
+```text
+写死 SenseVoice
+```
+
+目标:
+
+```text
+ModelProfile -> EngineFactory -> SenseVoice
+```
+
+### 3.2 范围
+
+新增:
+
+- `ModelProfile`
+- `ModelRegistry`
+- `LanguageProfile`
+- `LanguageRegistry`
+- `ModelInstallState`
+- `sensevoice-zh` profile
+- `EngineFactory`
+
+改造:
+
+- 把现有 `ModelPath()`、`ModelDir()`、`ModelExists()` 改成基于 profile。
+- 保持旧 `models/sensevoice` 兼容。
+- 现有 SenseVoice 仍然能加载。
+
+### 3.3 不做
+
+- 不接 Moonshine。
+- 不接 Parakeet。
+- 不接 Qwen3-ASR。
+- 不做语言 UI。
+- 不做模型选择 UI。
+
+### 3.4 验收标准
+
+- 中文现有功能不退化。
+- SenseVoice 可正常下载、加载、识别。
+- 旧模型目录兼容。
+- 旧配置兼容。
+- 单元测试覆盖 profile required files 校验。
+
+### 3.5 预估工作量
+
+中等。
+建议 3-5 个研发日。
+
+## 4. Round 2:语言检测与语言版本设置
+
+### 4.1 目标
+
+建立语言版本概念,首次启动根据系统语言选择中文或 English。
+
+### 4.2 范围
+
+新增配置:
+
+```json
+{
+  "LanguageMode": "auto",
+  "LanguageID": "zh-CN",
+  "SelectedModelID": "sensevoice-zh"
+}
+```
+
+支持:
+
+- 自动检测系统语言。
+- 中文系统默认 `zh-CN`。
+- English 系统默认 `en`.
+- 其他系统语言暂时默认 `en`。
+- 用户可手动选择中文或 English。
+- 手动选择后保存。
+
+设置页新增:
+
+```text
+语言版本
+自动(跟随系统)
+中文
+English
+```
+
+### 4.3 不做
+
+- 不做 30 种语言列表。
+- 不做粤语。
+- 不做法语/德语/西语等欧洲语言。
+- 不自动切换模型删除。
+
+### 4.4 验收标准
+
+- 中文系统首次启动为中文 + SenseVoice。
+- English 系统首次启动为 English。
+- 用户手动切换语言后配置持久化。
+- 语言切换不破坏用户词库、历史、热键设置。
+
+### 4.5 预估工作量
+
+中等。
+建议 3-5 个研发日。
+
+## 5. Round 3:Moonshine English 默认模型
+
+### 5.1 目标
+
+让 English 语言版本真正可用,并默认使用 Moonshine English。
+
+### 5.2 范围
+
+新增:
+
+- `moonshine-en` profile。
+- Moonshine required files 校验。
+- Moonshine 下载、解压、安装。
+- Moonshine config builder。
+- English UI 文案。
+
+模型路径:
+
+```text
+models/moonshine-en/
+```
+
+预期文件:
+
+```text
+encoder_model.ort
+decoder_model_merged.ort
+tokens.txt
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.Moonshine.Encoder = encoderPath
+config.ModelConfig.Moonshine.MergedDecoder = mergedDecoderPath
+config.ModelConfig.Tokens = tokensPath
+```
+
+### 5.3 用户体验
+
+English 系统首次启动:
+
+```text
+English UI
+Moonshine English 默认模型
+```
+
+中文系统不受影响。
+
+### 5.4 不做
+
+- 不接 Moonshine Voice framework。
+- 不做实时 partial transcript。
+- 不做 Parakeet。
+- 不做 Qwen3-ASR。
+
+### 5.5 验收标准
+
+- English 系统默认模型为 Moonshine English。
+- Moonshine 可下载、校验、加载。
+- 英文语音可识别。
+- 用户词库、历史、上屏逻辑复用。
+- Moonshine 初始化失败时不破坏 SenseVoice。
+
+### 5.6 预估工作量
+
+中高。
+建议 5-8 个研发日。
+
+## 6. Round 4:Parakeet 英文高级模型
+
+### 6.1 目标
+
+让 English 用户可以主动下载并切换到 Parakeet。
+
+### 6.2 范围
+
+新增:
+
+- `parakeet-en` profile。
+- `nemo_transducer` backend builder。
+- Parakeet required files 校验。
+- Parakeet 下载、安装、使用、删除。
+
+模型路径:
+
+```text
+models/parakeet-en/
+```
+
+预期文件:
+
+```text
+encoder.int8.onnx
+decoder.int8.onnx
+joiner.int8.onnx
+tokens.txt
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.Transducer.Encoder = encoderPath
+config.ModelConfig.Transducer.Decoder = decoderPath
+config.ModelConfig.Transducer.Joiner = joinerPath
+config.ModelConfig.Tokens = tokensPath
+config.ModelConfig.ModelType = "nemo_transducer"
+```
+
+### 6.3 用户体验
+
+English 识别模型页:
+
+```text
+Moonshine English
+轻量英文模型,推荐。
+
+Parakeet
+高质量英文/欧洲语言模型,体积较大。
+```
+
+用户点击 Parakeet:
+
+```text
+下载 -> 安装 -> 使用
+```
+
+Moonshine 保留。
+
+### 6.4 不做
+
+- 不把 Parakeet 作为 English 默认模型。
+- 不开放法语/德语/西语等欧洲语言 UI。
+- 不自动删除 Moonshine。
+
+### 6.5 验收标准
+
+- Parakeet 未安装时不能直接使用。
+- Parakeet 可下载、校验、加载。
+- Moonshine 和 Parakeet 可切换。
+- 删除 Parakeet 后可回到 Moonshine。
+- 当前正在使用 Parakeet 时不能直接删除 Parakeet。
+
+### 6.6 预估工作量
+
+中等。
+建议 4-7 个研发日。
+
+## 7. Round 5:Qwen3-ASR 中文高级模型
+
+### 7.1 目标
+
+让中文用户可以主动下载并切换到 Qwen3-ASR。
+
+### 7.2 范围
+
+新增:
+
+- `qwen3-asr-0.6b` profile。
+- `qwen3_asr` backend builder。
+- Qwen3-ASR required files 校验。
+- tokenizer 目录校验。
+- Qwen3-ASR 下载、安装、使用、删除。
+
+模型路径:
+
+```text
+models/qwen3-asr-0.6b/
+```
+
+预期文件:
+
+```text
+conv_frontend.onnx
+encoder.int8.onnx
+decoder.int8.onnx
+tokenizer/merges.txt
+tokenizer/vocab.json
+```
+
+sherpa config:
+
+```go
+config.ModelConfig.Qwen3ASR.ConvFrontend = convFrontendPath
+config.ModelConfig.Qwen3ASR.Encoder = encoderPath
+config.ModelConfig.Qwen3ASR.Decoder = decoderPath
+config.ModelConfig.Qwen3ASR.Tokenizer = tokenizerDir
+```
+
+### 7.3 用户体验
+
+中文识别模型页:
+
+```text
+SenseVoice
+轻量中文模型,推荐。
+
+Qwen3-ASR
+高质量多语言大模型,适合中文、中英混输、粤语和更多语言。体积较大。
+```
+
+SenseVoice 保留。
+
+### 7.4 不做
+
+- 不把 Qwen3-ASR 作为中文默认模型。
+- 不自动删除 SenseVoice。
+- 不把粤语作为独立 UI 语言开放。
+
+### 7.5 验收标准
+
+- Qwen3-ASR 未安装时不能直接使用。
+- Qwen3-ASR 可下载、校验、加载。
+- SenseVoice 和 Qwen3-ASR 可切换。
+- 删除 Qwen3-ASR 后可回到 SenseVoice。
+- 当前正在使用 Qwen3-ASR 时不能直接删除 Qwen3-ASR。
+
+### 7.6 预估工作量
+
+高。
+建议 6-10 个研发日。
+
+## 8. Round 6:模型管理成熟化与欧洲语言准备
+
+### 8.1 目标
+
+完善模型管理体验,并为未来欧洲语言版本做准备。
+
+### 8.2 范围
+
+模型管理:
+
+- 当前模型删除保护。
+- 删除确认。
+- 模型磁盘占用展示。
+- 模型损坏检测。
+- 重新下载。
+- 切换失败回滚。
+- 下载失败不破坏旧模型。
+- 解压失败不破坏旧模型。
+
+未来语言准备:
+
+- 扩展 `LanguageProfile` 支持更多语言。
+- 为 Parakeet 标注欧洲语言支持范围。
+- 预留语言列表 UI。
+- 明确 Parakeet 可作为法语、德语、西班牙语、意大利语、葡萄牙语、俄语、乌克兰语等语言的默认候选模型。
+
+### 8.3 不做
+
+- 不要求一次性上线 30 种语言。
+- 不要求上线欧洲语言 UI。
+- 不做模型市场。
+- 不做自动语言检测切换模型。
+
+### 8.4 验收标准
+
+- 模型删除有确认。
+- 当前模型不能删除。
+- 非当前模型可删除。
+- 删除不影响用户词库、历史、配置。
+- 模型损坏时可重新下载。
+- 切换失败时原模型继续可用。
+- 语言和模型配置没有硬编码阻碍未来扩展。
+
+### 8.5 预估工作量
+
+中等。
+建议 4-8 个研发日。
+
+## 9. 推荐发布组合
+
+### 9.1 内部技术版本
+
+```text
+Round 1
+```
+
+目的:
+
+- 验证 profile 抽象不会破坏现有 SenseVoice。
+
+### 9.2 English MVP
+
+```text
+Round 1 + Round 2 + Round 3
+```
+
+目的:
+
+- 英文系统可自动进入 English 体验。
+- 默认 Moonshine English。
+- 形成英文用户可用版本。
+
+### 9.3 English Pro
+
+```text
+Round 4
+```
+
+目的:
+
+- 为英文用户提供 Parakeet 高级模型。
+
+### 9.4 Chinese Pro
+
+```text
+Round 5
+```
+
+目的:
+
+- 为中文用户提供 Qwen3-ASR 高级模型。
+
+### 9.5 多语言准备版
+
+```text
+Round 6
+```
+
+目的:
+
+- 稳定模型管理。
+- 为未来欧洲语言版本做技术准备。
+
+## 10. 关键依赖
+
+### 10.1 模型包确认
+
+每个模型需要确认:
+
+- 下载 URL。
+- 压缩包格式。
+- 解压后目录结构。
+- required files。
+- 体积。
+- macOS 可运行性。
+- sherpa-onnx 当前 Go 绑定支持情况。
+
+### 10.2 sherpa-onnx 版本
+
+当前项目使用 `v1.12.24`。
+Qwen3-ASR 在较新版本中支持更完整。建议在 Round 1 或 Round 5 前评估是否升级到较新 `sherpa-onnx-go`。
+
+### 10.3 UI 文案
+
+English 分支需要完整英文 UI 文案,不只是模型页面。
+
+### 10.4 回归测试音频
+
+需要准备:
+
+- 中文短句。
+- 中英混输。
+- 英文短句。
+- 英文技术词。
+- Parakeet 支持语言样例,后续使用。
+
+## 11. 总体建议
+
+最稳的执行策略:
+
+```text
+先做 Round 1
+确认现有中文产品不退化
+再做 Round 2 + Round 3
+发布 English MVP
+再做 Round 4 和 Round 5
+最后做 Round 6
+```
+
+不要在 Round 1 里同时接 Moonshine、Parakeet、Qwen3-ASR。
+不要在 English MVP 之前先做 30 种语言 UI。
+不要把 Parakeet 和 Qwen3-ASR 作为默认模型自动下载。
diff --git a/02-P-NBL/product-plan.md b/02-P-NBL/product-plan.md
new file mode 100644
index 0000000..7adf104
--- /dev/null
+++ b/02-P-NBL/product-plan.md
@@ -0,0 +1,399 @@
+# VoiceSnap 多语言与多模型总体产品规划
+
+状态:产品规划稿
+日期:2026-06-01
+范围:安装语言识别、默认模型选择、用户可选大模型升级、未来多语言扩展
+
+## 1. 核心产品判断
+
+VoiceSnap 后续不应再把“模型”写死为某一个固定模型。产品层面应升级为:
+
+```text
+语言版本 -> 推荐默认模型 -> 可选高级模型 -> 用户可手动切换/删除
+```
+
+第一阶段只支持两个语言版本:
+
+- 中文
+- English
+
+但产品和架构必须按可扩展方式设计,未来可以增加更多语言。粤语应按独立语言版本处理,而不是简单并入中文。
+
+## 2. 第一阶段目标
+
+第一阶段目标不是做完整模型市场,而是把当前单模型产品升级成清晰的“双语言、双默认、双高级”产品结构。
+
+| 语言版本 | 默认轻量模型 | 用户可选高级模型 | 产品定位 |
+|---|---|---|---|
+| 中文 | SenseVoice | Qwen3-ASR | 中文、中英混输、未来可扩展粤语/方言 |
+| English | Moonshine English | Parakeet | 英文输入、英文技术口述、英文离线听写 |
+
+默认模型应尽量轻量、稳定、下载成本低。高级模型允许更大体积,但必须由用户主动选择下载。
+
+## 3. 安装与首次启动逻辑
+
+### 3.1 自动识别本机语言
+
+安装或首次启动时,VoiceSnap 读取系统语言/区域设置。
+
+MVP 规则:
+
+| 系统语言 | VoiceSnap 语言版本 | 默认模型 |
+|---|---|---|
+| 简体中文、繁体中文、中文区域 | 中文 | SenseVoice |
+| English | English | Moonshine English |
+| 其他暂未支持语言 | English | Moonshine English |
+
+说明:
+
+- 第一阶段不在安装流程中要求用户手动选择模型。
+- 中文用户默认进入中文产品体验。
+- 英文用户默认进入英文产品体验。
+- 其他语言第一阶段暂时回落到英文,未来再扩展。
+
+### 3.2 默认模型安装
+
+首次启动时,系统根据语言版本自动准备默认模型:
+
+```text
+中文 -> SenseVoice
+English -> Moonshine English
+```
+
+产品要求:
+
+- 用户不需要在首次启动时理解模型差异。
+- 默认模型准备完成后,用户即可开始离线输入。
+- 如果模型需要下载,应显示清晰下载进度。
+- 下载模型不等于上传用户数据;音频识别仍然离线。
+
+## 4. 模型选择与保留策略
+
+### 4.1 默认模型不自动删除
+
+如果用户安装高级模型,默认轻量模型必须保留。
+
+中文场景:
+
+```text
+默认 SenseVoice
+用户主动下载 Qwen3-ASR
+切换到 Qwen3-ASR
+SenseVoice 保留
+```
+
+English 场景:
+
+```text
+默认 Moonshine English
+用户主动下载 Parakeet
+切换到 Parakeet
+Moonshine English 保留
+```
+
+原因:
+
+- 高级模型更大,用户可能试用后回退。
+- 默认模型是低风险 fallback。
+- 删除模型是不可逆本地操作,应由用户明确触发。
+
+### 4.2 用户可手动删除模型
+
+模型管理规则:
+
+- 用户可以删除未使用的模型。
+- 当前正在使用的模型不能直接删除,必须先切换到其他已安装模型。
+- 如果用户删除高级模型,自动回退到该语言版本的默认模型。
+- 删除模型只删除本地模型文件,不删除用户词库、历史、设置。
+
+### 4.3 模型切换规则
+
+当用户选择一个未安装模型:
+
+```text
+选择模型 -> 显示模型说明和体积 -> 用户确认下载 -> 下载完成 -> 初始化 -> 切换成功
+```
+
+当用户选择已安装模型:
+
+```text
+选择模型 -> 释放当前引擎 -> 初始化目标模型 -> 切换成功
+```
+
+切换失败时:
+
+- 保持原模型不变。
+- 显示错误原因。
+- 不破坏已安装模型文件。
+
+## 5. 设置页产品结构
+
+设置页需要新增两个区域:
+
+1. 语言版本
+2. 识别模型
+
+## 6. 语言版本设置
+
+### 6.1 第一阶段 UI
+
+第一阶段只显示:
+
+```text
+语言版本
+选择 VoiceSnap 的界面语言和默认识别方向。
+
+自动(跟随系统)
+中文
+English
+```
+
+推荐默认:
+
+- 首次启动使用“自动”。
+- 一旦用户手动选择中文或 English,则保存为显式选择。
+
+### 6.2 未来扩展
+
+未来可以扩展为类似 TagLauncher 的多语言列表:
+
+```text
+自动
+简体中文
+English
+粤语
+繁體中文
+日本語
+한국어
+Français
+Deutsch
+Español
+...
+```
+
+架构上必须支持:
+
+- 每个语言有独立 `languageId`。
+- 每个语言可以配置默认模型。
+- 每个语言可以配置多个可选模型。
+- 一个模型可以服务多个语言。
+- 粤语作为独立语言处理。
+
+## 7. 识别模型设置
+
+### 7.1 第一阶段模型列表
+
+设置页显示四个模型。
+
+建议 UI 结构:
+
+```text
+识别模型
+
+○ SenseVoice
+  轻量中文模型,适合中文和中英混输。
+
+○ Qwen3-ASR
+  高质量多语言大模型,适合中文、中英混输、粤语和更多语言。体积较大。
+
+○ Moonshine English
+  轻量英文模型,适合英文离线输入和低延迟听写。
+
+○ Parakeet
+  高质量英文/欧洲语言模型,适合更高准确率需求。体积较大。
+```
+
+### 7.2 推荐与过滤
+
+第一阶段可以按当前语言版本排序:
+
+中文语言版本:
+
+1. SenseVoice,推荐
+2. Qwen3-ASR,高级
+3. Moonshine English
+4. Parakeet
+
+English 语言版本:
+
+1. Moonshine English,推荐
+2. Parakeet,高级
+3. SenseVoice
+4. Qwen3-ASR
+
+不建议隐藏其他语言模型。可以折叠到“其他模型”里,但不要让用户找不到。
+
+### 7.3 模型状态展示
+
+每个模型至少显示:
+
+- 模型名称
+- 适合语言
+- 模型特点
+- 体积提示
+- 状态:未安装 / 下载中 / 已安装 / 当前使用
+- 操作:下载 / 使用 / 删除
+
+示例:
+
+```text
+Moonshine English
+适合:English
+特点:轻量、英文专用、低延迟
+大小:约 135 MB
+状态:当前使用
+```
+
+## 8. 首批模型定位
+
+### 8.1 SenseVoice
+
+产品定位:
+
+- 中文默认轻量模型。
+- 适合中文、中英混输、常规离线输入。
+
+说明:
+
+- 继续作为中文用户的默认选择。
+- 用户升级 Qwen3-ASR 后仍保留 SenseVoice。
+
+### 8.2 Qwen3-ASR
+
+产品定位:
+
+- 中文高级模型。
+- 适合更高质量、多语言、粤语、方言、中英混输场景。
+
+说明:
+
+- 模型体积明显大于 SenseVoice。
+- 必须由用户主动下载。
+- 不作为第一阶段默认模型。
+
+### 8.3 Moonshine English
+
+产品定位:
+
+- English 默认轻量模型。
+- 适合英文用户日常离线输入。
+
+说明:
+
+- 作为英文版默认模型。
+- 优先使用 sherpa-onnx Moonshine English quantized 路线,以复用现有 Go + sherpa-onnx 框架。
+
+### 8.4 Parakeet
+
+产品定位:
+
+- English 高级模型。
+- 适合用户愿意下载更大模型以换取更高英文识别质量的场景。
+
+说明:
+
+- 不作为默认模型。
+- 用户主动下载后可切换。
+- Moonshine English 保留作为 fallback。
+
+## 9. 推荐用户流程
+
+### 9.1 中文用户首次使用
+
+```text
+安装/首次启动
+-> 检测系统语言为中文
+-> UI 使用中文
+-> 默认准备 SenseVoice
+-> 用户开始输入
+```
+
+后续:
+
+```text
+设置 -> 识别模型 -> Qwen3-ASR -> 下载 -> 使用
+```
+
+如果不满意:
+
+```text
+设置 -> 识别模型 -> SenseVoice -> 使用
+```
+
+### 9.2 English 用户首次使用
+
+```text
+Install / first launch
+-> Detect system language as English
+-> UI uses English
+-> Prepare Moonshine English
+-> User starts dictation
+```
+
+后续:
+
+```text
+Settings -> Recognition Model -> Parakeet -> Download -> Use
+```
+
+如果不满意:
+
+```text
+Settings -> Recognition Model -> Moonshine English -> Use
+```
+
+## 10. 非目标
+
+第一阶段不做:
+
+- 30 种语言完整 UI。
+- 自动识别用户说话语言并切换模型。
+- 自动删除旧模型。
+- 云端 ASR。
+- 模型市场。
+- 用户上传模型。
+- Moonshine Voice framework 重写。
+- 实时边说边出字。
+
+第一阶段重点是:
+
+```text
+语言版本 + 模型 profile + 默认模型 + 高级模型切换
+```
+
+## 11. 产品验收标准
+
+### 11.1 中文默认路径
+
+- 中文系统首次启动进入中文体验。
+- 默认模型为 SenseVoice。
+- 用户未主动选择时,不提示英文模型。
+- 用户可下载并切换 Qwen3-ASR。
+- Qwen3-ASR 下载后 SenseVoice 仍保留。
+- 删除 Qwen3-ASR 后可以回到 SenseVoice。
+
+### 11.2 English 默认路径
+
+- English 系统首次启动进入英文体验。
+- 默认模型为 Moonshine English。
+- 用户未主动选择时,不提示中文模型。
+- 用户可下载并切换 Parakeet。
+- Parakeet 下载后 Moonshine English 仍保留。
+- 删除 Parakeet 后可以回到 Moonshine English。
+
+### 11.3 通用路径
+
+- 语言版本可手动切换。
+- 模型可手动切换。
+- 未安装模型不能直接使用,必须先下载。
+- 当前使用模型不能直接删除。
+- 删除模型不影响用户词库、历史和设置。
+- 模型切换失败时,原模型继续可用。
+
+## 12. 参考资料
+
+- sherpa-onnx Moonshine v2 models: https://k2-fsa.github.io/sherpa/onnx/moonshine/models-v2.html
+- sherpa-onnx Parakeet NeMo transducer models: https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html
+- sherpa-onnx Qwen3-ASR: https://k2-fsa.github.io/sherpa/onnx/qwen3-asr/index.html
+- sherpa-onnx Qwen3-ASR pretrained model: https://k2-fsa.github.io/sherpa/onnx/qwen3-asr/pretrained.html
+- SenseVoice 2025-09-09 model files: https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09/tree/main
diff --git a/02-P-NBL/round-1/README.md b/02-P-NBL/round-1/README.md
new file mode 100644
index 0000000..70f8b6c
--- /dev/null
+++ b/02-P-NBL/round-1/README.md
@@ -0,0 +1,17 @@
+# Round 1:模型 Profile 基础设施
+
+本目录用于沉淀 Round 1 的项目资料、研发拆解、接口设计、测试记录和验收材料。
+
+Round 1 的完整范围以 [`../plan.md`](../plan.md) 中的“Round 1:模型 Profile 基础设施”为准。
+
+当前文档:
+
+- [`prd.md`](prd.md):Round 1 PRD v0.2 评审修订稿。
+- [`engineering-tasks.md`](engineering-tasks.md):Round 1 研发任务拆解。
+- [`qa-test-plan.md`](qa-test-plan.md):Round 1 QA 测试计划。
+- [`todo-list.md`](todo-list.md):Round 1 总 To Do List。
+- [`dev-checklist.md`](dev-checklist.md):Round 1 开发自查模板。
+- [`dev-checklist-current.md`](dev-checklist-current.md):Round 1 当前开发自查记录。
+- [`qa-precheck-current.md`](qa-precheck-current.md):Round 1 当前 QA 预检记录。
+- [`qa-handoff-current.md`](qa-handoff-current.md):Round 1 当前 QA 交接说明。
+- [`qa-report-template.md`](qa-report-template.md):Round 1 QA 报告模板。
diff --git a/02-P-NBL/round-1/dev-checklist-current.md b/02-P-NBL/round-1/dev-checklist-current.md
new file mode 100644
index 0000000..071e32f
--- /dev/null
+++ b/02-P-NBL/round-1/dev-checklist-current.md
@@ -0,0 +1,150 @@
+# Round 1 开发自查记录
+
+状态:开发自查记录
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+对应文档:
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+- [`qa-test-plan.md`](qa-test-plan.md)
+
+## 1. 基本信息
+
+| 项目 | 内容 |
+|---|---|
+| 分支 | `codex/round1-model-profile` |
+| commit id | 由最终 QA 交接信息填写 |
+| 研发负责人 | Codex |
+| 自查日期 | 2026-06-02 |
+| App 版本 / build | 未打包 |
+| macOS 版本 | 当前本机环境 |
+| 芯片 | 当前本机环境 |
+
+## 2. 本次实现范围
+
+已完成:
+
+- 新增 `ModelProfile` / `ModelRegistry`。
+- 新增 `LanguageProfile` / `LanguageRegistry` 最小实现。
+- 新增 `RequiredFileRule`,支持 `AllOf` / `AnyOf`。
+- 新增 `ModelInstallState`,支持 `models/state.json` 读写。
+- 新增 `ModelResolver`,支持 state、新目录、旧目录 fallback。
+- 新增 `ResolvedModel`。
+- 新增 `sensevoice-zh` profile。
+- `config.Config` 增加隐藏字段 `SelectedModelID`,默认 `sensevoice-zh`。
+- `engine.New()` 保持外部兼容,内部改为通过 resolver 加载 selected/default model。
+- macOS/Linux/Windows 平台 engine 改为接收 `ResolvedModel`。
+- 下载器改为临时下载、解压、staging 校验、安全安装到 `models/sensevoice-zh`。
+- 下载流程已覆盖“primary 下载成功但解压/校验失败时继续尝试 fallback URL”。
+- 用户主动触发下载时,`models/sensevoice-zh` 可被 staging 校验通过的新模型安全替换;替换失败会尝试恢复 backup。
+- Resolver 已拒绝 `state.json` 中的绝对路径或逃逸 `models` 根目录的相对路径。
+- macOS engine 已使用 profile 中的 `ProviderOrder`。
+- engine 初始化失败时会区分“缺模型”和“结构存在但不可加载”:后者发送 `engine:status = error`。
+- 保留 `EngineService.ModelExists()` 和 `EngineService.DownloadModel(primaryURL, fallbackURL)` 兼容签名。
+- 新增 `EngineService.GetStatus()`,用于前端补查可能早于订阅发生的 engine 状态。
+- Onboarding 已处理 `engine:status = need_model` 且带错误信息的场景。
+- 新增模型相关单元测试。
+
+未做,符合 Round 1 非目标:
+
+- 未接入 Moonshine。
+- 未接入 Parakeet。
+- 未接入 Qwen3-ASR。
+- 未新增语言 UI。
+- 未新增模型选择 UI。
+- 未新增模型删除 UI。
+- 未自动迁移旧模型目录。
+
+## 3. 变更文件
+
+代码:
+
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+- `privatevoice.src/internal/model/validator.go`
+- `privatevoice.src/internal/model/install_state.go`
+- `privatevoice.src/internal/model/resolver.go`
+- `privatevoice.src/internal/model/downloader.go`
+- `privatevoice.src/internal/model/model_test.go`
+- `privatevoice.src/internal/engine/engine.go`
+- `privatevoice.src/internal/engine/engine_darwin.go`
+- `privatevoice.src/internal/engine/engine_linux.go`
+- `privatevoice.src/internal/engine/engine_windows.go`
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/internal/paths/paths.go`
+
+文档:
+
+- `02-P-NBL/round-1/prd.md`
+- `02-P-NBL/round-1/engineering-tasks.md`
+- `02-P-NBL/round-1/qa-test-plan.md`
+- `02-P-NBL/round-1/dev-checklist.md`
+- `02-P-NBL/round-1/dev-checklist-current.md`
+- `02-P-NBL/round-1/qa-report-template.md`
+- `02-P-NBL/round-1/README.md`
+
+## 4. 自动化验证
+
+| 命令 | 结果 | 说明 |
+|---|---|---|
+| `cd privatevoice.src/frontend && npm run build` | 通过 | 存在既有 Svelte a11y warning,构建成功 |
+| `cd privatevoice.src && go test ./internal/model -count=1` | 通过 | 强制重跑模型包测试,非缓存 |
+| `cd privatevoice.src && go test ./... -count=1` | 通过 | 强制重跑全量 Go 测试,非缓存;新 checkout 需先完成前端构建以生成 `frontend/dist` |
+
+前端构建警告:
+
+- `UpdateDialog.svelte` 的 dialog focus / noninteractive listener 警告。
+- `ToggleSwitch.svelte` 的 button aria label 警告。
+
+这些警告不由本轮改动引入,本轮未修改相关组件。
+
+## 5. 已覆盖的自动化场景
+
+- 默认 model profile 为 `sensevoice-zh`。
+- 未知 model id 返回错误。
+- `AnyOf` 规则允许 `model.onnx`。
+- `model.int8.onnx` 优先于 `model.onnx`。
+- 缺少 `tokens.txt` 时校验失败。
+- 0 字节模型文件校验失败。
+- 无 state 时旧 `models/sensevoice` 可解析。
+- 无 state 时新旧目录都存在,优先新 `models/sensevoice-zh`。
+- 非法 `state.json` 时 fallback 到旧目录。
+- state 指向坏目录时 fallback 到旧目录。
+- state 指向绝对路径或逃逸 `models` 根目录的路径时被拒绝并 fallback。
+- 不完整目录返回 `installed_incomplete`。
+- state round trip。
+- 下载失败不删除旧模型目录。
+- primary 坏包时尝试 fallback URL。
+- bad archive 不写入 installed state。
+- 用户触发下载可替换已有 `models/sensevoice-zh`。
+
+## 6. 未完成验证
+
+以下需要 QA 在真机或测试包阶段执行:
+
+- 真实 SenseVoice 模型下载。
+- 真实模型初始化。
+- 真实录音识别。
+- replacement 在真实识别链路中生效。
+- 历史记录真实写入。
+- macOS 麦克风权限未授权/已授权回归。
+- 断网已有模型完整使用。
+- 网络观察确认识别阶段无上传。
+- 菜单栏、Dock、窗口层级、全屏、Split View smoke。
+
+## 7. 移交 QA 结论
+
+开发自查结论:
+
+```text
+可进入 QA 预检。
+```
+
+说明:
+
+```text
+代码级自动化测试和前端构建已通过,但尚未打包,也尚未执行真机真实模型回归。
+根据项目交付门禁,本状态不能直接交付用户验收,需要 QA 按 qa-test-plan.md 继续验证。
+```
diff --git a/02-P-NBL/round-1/dev-checklist.md b/02-P-NBL/round-1/dev-checklist.md
new file mode 100644
index 0000000..a04a082
--- /dev/null
+++ b/02-P-NBL/round-1/dev-checklist.md
@@ -0,0 +1,142 @@
+# Round 1 开发自查 Checklist
+
+状态:v0.1 模板
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+- [`qa-test-plan.md`](qa-test-plan.md)
+
+## 1. 基本信息
+
+| 项目 | 内容 |
+|---|---|
+| 分支 | 待填写 |
+| commit id | 待填写 |
+| 研发负责人 | 待填写 |
+| 自查日期 | 待填写 |
+| App 版本 / build | 待填写 |
+| macOS 版本 | 待填写 |
+| 芯片 | 待填写 |
+
+## 2. 变更范围确认
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| 未接入 Moonshine | 未检查 |  |
+| 未接入 Parakeet | 未检查 |  |
+| 未接入 Qwen3-ASR | 未检查 |  |
+| 未新增语言 UI | 未检查 |  |
+| 未新增模型选择 UI | 未检查 |  |
+| 未自动迁移旧模型目录 | 未检查 |  |
+| 未删除旧 `models/sensevoice` | 未检查 |  |
+| Wails public API 未改签名 | 未检查 |  |
+
+## 3. 架构实现自查
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| 已实现 `ModelProfile` | 未检查 |  |
+| 已实现 `ModelRegistry` | 未检查 |  |
+| 已实现 `LanguageProfile` 最小版本 | 未检查 |  |
+| 已实现 `LanguageRegistry` 最小版本 | 未检查 |  |
+| 已实现 `RequiredFileRule` | 未检查 |  |
+| `RequiredFileRule` 支持 `allOf` | 未检查 |  |
+| `RequiredFileRule` 支持 `anyOf` | 未检查 |  |
+| 已实现 `ModelInstallState` | 未检查 |  |
+| 已实现 `models/state.json` 读写 | 未检查 |  |
+| 已实现 `ModelResolver` | 未检查 |  |
+| 已实现 `ResolvedModel` | 未检查 |  |
+| 已实现 `sensevoice-zh` profile | 未检查 |  |
+| 已实现 `EngineFactory` | 未检查 |  |
+| `EngineFactory` 接收 `ResolvedModel` | 未检查 |  |
+| 平台 engine 不再自己猜模型路径 | 未检查 |  |
+
+## 4. 兼容性自查
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| 旧 `models/sensevoice` 可读取 | 未检查 |  |
+| 新 `models/sensevoice-zh` 可读取 | 未检查 |  |
+| 新旧目录都存在时优先新目录 | 未检查 |  |
+| state 指旧目录时可使用旧目录 | 未检查 |  |
+| state 损坏时可 fallback | 未检查 |  |
+| state 指坏目录时可 fallback | 未检查 |  |
+| `SelectedModelID` 缺失时默认 `sensevoice-zh` | 未检查 |  |
+| `SelectedModelID` 无效时回退 `sensevoice-zh` | 未检查 |  |
+| `ConfigService.GetConfig()` 旧字段保留 | 未检查 |  |
+
+## 5. 下载安全自查
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| 下载使用临时目录 | 未检查 |  |
+| 解压使用 staging 目录 | 未检查 |  |
+| required files 校验通过后才落盘 | 未检查 |  |
+| 新下载最终进入 `models/sensevoice-zh` | 未检查 |  |
+| 下载失败不删除旧模型 | 未检查 |  |
+| 解压失败不写入完整 state | 未检查 |  |
+| 校验失败不写入完整 state | 未检查 |  |
+| 失败残留不会被误判为已安装 | 未检查 |  |
+| 失败后可重新下载 | 未检查 |  |
+
+## 6. Wails / 前端链路自查
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| `EngineService.ModelExists() bool` 签名不变 | 未检查 |  |
+| `EngineService.DownloadModel(primaryURL, fallbackURL)` 签名不变 | 未检查 |  |
+| `model:download-progress` payload 不变 | 未检查 |  |
+| `engine:status` 语义不变 | 未检查 |  |
+| Onboarding 下载流程可用 | 未检查 |  |
+| `hardwareInfo` 不显示内部 profile id | 未检查 |  |
+| UI 不出现 Moonshine | 未检查 |  |
+| UI 不出现 Parakeet | 未检查 |  |
+| UI 不出现 Qwen3-ASR | 未检查 |  |
+| UI 不出现语言选择 | 未检查 |  |
+| UI 不出现模型选择 | 未检查 |  |
+
+## 7. 自动化测试记录
+
+| 命令 | 结果 | 日志/说明 |
+|---|---|---|
+| `cd privatevoice.src && go test ./...` | 未执行 |  |
+| 前端构建/检查命令 | 未执行 |  |
+| 其他 | 未执行 |  |
+
+## 8. 手工 smoke 记录
+
+| 场景 | 结果 | 说明 |
+|---|---|---|
+| 冷启动 | 未检查 |  |
+| 旧目录已有模型启动 | 未检查 |  |
+| 新用户无模型下载 | 未检查 |  |
+| 一次完整录音识别 | 未检查 |  |
+| replacement 生效 | 未检查 |  |
+| 历史写入 | 未检查 |  |
+| 麦克风权限未授权 | 未检查 |  |
+| 麦克风权限已授权 | 未检查 |  |
+| 断网已有模型识别 | 未检查 |  |
+
+## 9. 已知风险
+
+| 风险 | 影响 | 处理建议 |
+|---|---|---|
+| 待填写 | 待填写 | 待填写 |
+
+## 10. 移交 QA 结论
+
+研发自查结论:
+
+```text
+待填写:可移交 QA / 不可移交 QA
+```
+
+移交说明:
+
+```text
+待填写
+```
diff --git a/02-P-NBL/round-1/engineering-tasks.md b/02-P-NBL/round-1/engineering-tasks.md
new file mode 100644
index 0000000..d021ac1
--- /dev/null
+++ b/02-P-NBL/round-1/engineering-tasks.md
@@ -0,0 +1,386 @@
+# Round 1 研发任务拆解:模型 Profile 基础设施
+
+状态:v0.1 研发拆解稿
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`../engineering-plan.md`](../engineering-plan.md)
+- [`../plan.md`](../plan.md)
+
+## 1. 研发目标
+
+Round 1 的研发目标是:
+
+```text
+在不改变用户体验的前提下,把现有 SenseVoice 单模型实现改造成可扩展的 ModelProfile 架构。
+```
+
+本轮不接入 Moonshine、Parakeet、Qwen3-ASR,不做语言 UI,不做模型选择 UI。
+
+## 2. 交付边界
+
+### 2.1 必须交付
+
+- `ModelProfile` / `ModelRegistry`
+- `LanguageProfile` / `LanguageRegistry` 最小实现
+- `RequiredFileRule`,支持 `allOf` / `anyOf`
+- `ModelInstallState`,读写 `models/state.json`
+- `ModelResolver`,支持 state、新目录、旧目录 fallback
+- `ResolvedModel`
+- `sensevoice-zh` profile
+- `SelectedModelID` 隐藏配置,默认 `sensevoice-zh`
+- `EngineFactory` 初步实现
+- SenseVoice 新旧路径兼容
+- 下载器 staging + required files 校验 + 安全落盘
+- Wails public API 兼容
+- 自动化测试覆盖核心解析、校验、状态文件、下载失败保护
+
+### 2.2 明确不交付
+
+- Moonshine 真实模型接入
+- Parakeet 真实模型接入
+- Qwen3-ASR 真实模型接入
+- 语言设置 UI
+- 模型选择 UI
+- 模型删除 UI
+- 旧模型目录自动迁移
+- 每个模型目录下的 `manifest.json`
+- checksum / 签名校验
+- 远程模型 catalog
+
+## 3. 推荐任务顺序
+
+```text
+T0 代码现状确认
+T1 Profile / Registry 基础类型
+T2 Required files 校验器
+T3 Install state 读写
+T4 ModelResolver
+T5 Config 兼容与 SelectedModelID
+T6 EngineFactory 改造
+T7 下载器安全安装
+T8 EngineService / Wails 兼容
+T9 自动化测试
+T10 开发自查与交付说明
+```
+
+建议按顺序推进。`T1-T5` 是地基,`T6-T8` 是业务接入,`T9-T10` 是进入 QA 前的门禁。
+
+## 4. 任务明细
+
+### T0:代码现状确认
+
+负责人:后端
+类型:准备任务
+建议文件:
+
+- `privatevoice.src/internal/engine/engine.go`
+- `privatevoice.src/internal/engine/engine_darwin.go`
+- `privatevoice.src/internal/model/downloader.go`
+- `privatevoice.src/internal/paths/paths.go`
+- `privatevoice.src/internal/config/config.go`
+- `privatevoice.src/services/engine_service.go`
+- `privatevoice.src/frontend/src/App.svelte`
+- `privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte`
+
+工作内容:
+
+- 标出当前写死 SenseVoice 的路径。
+- 标出前端 Wails 调用链。
+- 标出下载器中会删除旧目录的风险点。
+- 确认当前测试命令和前端构建命令。
+
+完成标准:
+
+- 形成代码改动前 checklist。
+- 明确哪些 public API 不允许改签名。
+
+### T1:Profile / Registry 基础类型
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/profile.go`
+- `privatevoice.src/internal/model/registry.go`
+
+工作内容:
+
+- 新增 `ModelProfile`。
+- 新增 `LanguageProfile`。
+- 新增 `RequiredFileRule` 类型。
+- 新增 `sensevoice-zh` profile。
+- 新增 `zh-CN` language profile。
+- 提供 `GetModelProfile(id)`、`DefaultModelProfile()`、`ListModelProfiles()` 等最小 registry 方法。
+
+关键要求:
+
+- `ModelProfile` 使用 `SupportedLanguageIDs` 和 `RecommendedFor` 两个字段,不混用。
+- `sensevoice-zh` 的 `InstallDirName` 为 `sensevoice-zh`。
+- `BackendKind` 为 `sensevoice`。
+
+完成标准:
+
+- 未知 model id 返回明确错误。
+- 默认 profile 为 `sensevoice-zh`。
+- 单元测试覆盖 registry 查询。
+
+### T2:Required files 校验器
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/validator.go`
+
+工作内容:
+
+- 实现 `RequiredFileRule` 的 `AllOf` / `AnyOf` 校验。
+- 文件不存在视为失败。
+- 文件为 0 字节视为失败。
+- 对 SenseVoice 输出角色化文件路径:`model`、`tokens`。
+- 同时存在 `model.int8.onnx` 和 `model.onnx` 时优先选择 `model.int8.onnx`。
+
+完成标准:
+
+- 只有 `model.onnx` 时可通过。
+- 只有 `model.int8.onnx` 时可通过。
+- 两个模型文件都存在时选择 int8。
+- 缺少 `tokens.txt` 时失败。
+- 0 字节 required file 失败。
+
+### T3:Install state 读写
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/install_state.go`
+- `privatevoice.src/internal/paths/paths.go`
+
+工作内容:
+
+- 新增 `paths.ModelInstallDir(name string)`。
+- 新增 `paths.ModelStatePath()`。
+- 实现 `models/state.json` 最小 schema。
+- `path` 字段存相对 `models` 根目录路径。
+- 非法 JSON、空文件、未知 model id 都不能 panic。
+
+完成标准:
+
+- state 不存在时返回空状态。
+- state 非法 JSON 时返回可恢复错误,由 Resolver fallback。
+- state 写入失败不阻塞旧模型加载。
+
+### T4:ModelResolver
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/resolver.go`
+
+工作内容:
+
+- 实现 `ResolvedModel`。
+- 实现安装状态枚举。
+- 路径候选顺序:
+  1. state 中 `sensevoice-zh` 记录路径
+  2. `models/sensevoice-zh`
+  3. `models/sensevoice`
+- 每个候选目录都执行 required files 校验。
+- state 指向坏目录时继续 fallback。
+- 新旧目录都完整且无 state 时使用新目录。
+
+完成标准:
+
+- 旧目录完整时可解析。
+- 新目录完整时可解析。
+- state 损坏时可 fallback。
+- 不完整目录不会被解析为可用。
+- 返回 `ResolvedModel.Files["model"]` 和 `ResolvedModel.Files["tokens"]`。
+
+### T5:Config 兼容与 SelectedModelID
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/config/config.go`
+
+工作内容:
+
+- 新增隐藏字段 `SelectedModelID string`。
+- 老配置缺失该字段时默认 `sensevoice-zh`。
+- 空值或未知 model id 回退 `sensevoice-zh`。
+- 保存配置时不得丢失旧字段。
+- 保留 `ModelDownloadUrl` 和 `FallbackModelDownloadUrl`。
+
+完成标准:
+
+- 老 `config.json` 可正常加载。
+- `ConfigService.GetConfig()` 返回兼容旧前端的数据。
+- 新字段不会要求 UI 改动。
+
+### T6:EngineFactory 改造
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/engine/engine.go`
+- `privatevoice.src/internal/engine/factory.go`
+- `privatevoice.src/internal/engine/engine_darwin.go`
+- `privatevoice.src/internal/engine/engine_linux.go`
+- `privatevoice.src/internal/engine/engine_windows.go`
+
+工作内容:
+
+- 保留 `engine.New()`。
+- 新增内部入口 `NewWithModelID(modelID string)`。
+- 新增内部入口 `NewWithResolvedModel(resolved model.ResolvedModel)`。
+- 平台 `newPlatformEngine` 改为接收 `ResolvedModel`。
+- SenseVoice config 使用 `resolved.Files["model"]` 和 `resolved.Files["tokens"]`。
+- 用户可见 `HardwareInfo()` 保持 `SenseVoice · CoreML/CPU` 风格。
+
+完成标准:
+
+- 调用方不用再直接拼 SenseVoice 路径。
+- 不完整模型不会进入 sherpa 初始化。
+- sherpa 初始化失败不导致 App 崩溃。
+
+### T7:下载器安全安装
+
+负责人:后端
+建议文件:
+
+- `privatevoice.src/internal/model/downloader.go`
+
+工作内容:
+
+- 保留旧外部调用能力。
+- 内部按默认 profile `sensevoice-zh` 安装。
+- 下载到 `models/.downloads/sensevoice-zh/<run-id>`。
+- 解压到临时 extract 目录。
+- 校验 staging 目录 required files。
+- 校验通过后安装到 `models/sensevoice-zh`。
+- 写入或更新 `models/state.json`。
+- 下载失败、解压失败、校验失败不得破坏旧 `models/sensevoice`。
+
+完成标准:
+
+- 不再直接 `RemoveAll(models/sensevoice)`。
+- 半成品不会被 Resolver 误判为可用。
+- 下载失败后可再次重试。
+
+### T8:EngineService / Wails 兼容
+
+负责人:后端 + 前端确认
+建议文件:
+
+- `privatevoice.src/services/engine_service.go`
+- `privatevoice.src/frontend/src/App.svelte`
+- `privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte`
+
+工作内容:
+
+- 保持 `EngineService.ModelExists() bool` 签名。
+- 保持 `EngineService.DownloadModel(primaryURL, fallbackURL) error` 签名。
+- `ModelExists()` 返回当前 default/selected model 是否完整可用。
+- 目录不完整时返回 false。
+- 保持 `model:download-progress` payload。
+- 保持 `engine:status` 事件语义。
+
+完成标准:
+
+- 前端无需改调用即可运行。
+- `ModelExists -> need_model -> OnboardingView -> DownloadModel -> ready` 链路正常。
+- 不出现新模型 UI。
+
+### T9:自动化测试
+
+负责人:后端 + QA 协作
+建议文件:
+
+- `privatevoice.src/internal/model/*_test.go`
+- 必要时补充 engine 相关测试
+
+最低测试集:
+
+- registry 默认模型。
+- required files `allOf` / `anyOf`。
+- int8 优先。
+- state 缺失 fallback。
+- state 非法 JSON fallback。
+- state 指坏目录 fallback。
+- 新旧目录优先级。
+- 不完整目录不可用。
+- `SelectedModelID` 默认和无效值回退。
+- 下载失败不破坏已有目录。
+
+完成标准:
+
+- Go 单元测试通过。
+- 测试能在无真实大模型的 fixture 目录下运行。
+
+### T10:开发自查与交付说明
+
+负责人:研发负责人
+建议文件:
+
+- `02-P-NBL/round-1/dev-checklist.md`
+
+工作内容:
+
+- 记录开发自查结果。
+- 记录执行过的命令和结果。
+- 记录手工 smoke 结果。
+- 记录仍需 QA 覆盖的风险。
+
+完成标准:
+
+- 开发自查完成后才能移交 QA。
+- 交付说明包含变更范围、测试范围、已知风险。
+
+## 5. 依赖关系
+
+| 任务 | 依赖 |
+|---|---|
+| T1 | T0 |
+| T2 | T1 |
+| T3 | T1 |
+| T4 | T1, T2, T3 |
+| T5 | T1 |
+| T6 | T4, T5 |
+| T7 | T2, T3, T4 |
+| T8 | T4, T6, T7 |
+| T9 | T1-T8 |
+| T10 | T9 |
+
+## 6. 研发完成定义
+
+Round 1 研发完成必须同时满足:
+
+- 所有必须交付项已实现。
+- 旧 Wails public API 未破坏。
+- 老用户旧目录场景可用。
+- 新用户下载路径可用。
+- 不完整模型不会进入 engine。
+- 下载失败不会破坏已有完整模型。
+- Go 单元测试通过。
+- 前端构建或现有前端检查通过。
+- 研发自查记录完成。
+
+## 7. 进入 QA 的交付包
+
+研发移交 QA 时应提供:
+
+- 分支名 / commit id。
+- 变更文件列表。
+- 开发自查记录。
+- 执行命令和结果。
+- 测试 fixture 说明。
+- 已知风险。
+- 需要 QA 特别关注的路径:
+  - 旧目录兼容。
+  - state 损坏 fallback。
+  - 下载失败保护。
+  - Wails API 兼容。
+  - 断网已有模型识别。
diff --git a/02-P-NBL/round-1/logs/go-test-all-20260602.log b/02-P-NBL/round-1/logs/go-test-all-20260602.log
new file mode 100644
index 0000000..c91c8a9
--- /dev/null
+++ b/02-P-NBL/round-1/logs/go-test-all-20260602.log
@@ -0,0 +1,21 @@
+?   	voicesnap	[no test files]
+?   	voicesnap/internal/audio	[no test files]
+?   	voicesnap/internal/config	[no test files]
+ok  	voicesnap/internal/correctioncsv	0.534s
+?   	voicesnap/internal/dock	[no test files]
+?   	voicesnap/internal/engine	[no test files]
+ok  	voicesnap/internal/history	1.081s
+?   	voicesnap/internal/hotkey	[no test files]
+?   	voicesnap/internal/input	[no test files]
+?   	voicesnap/internal/logger	[no test files]
+ok  	voicesnap/internal/model	1.649s
+?   	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.125s
+?   	voicesnap/services	[no test files]
diff --git a/02-P-NBL/round-1/logs/go-test-internal-model-20260602.log b/02-P-NBL/round-1/logs/go-test-internal-model-20260602.log
new file mode 100644
index 0000000..af35d54
--- /dev/null
+++ b/02-P-NBL/round-1/logs/go-test-internal-model-20260602.log
@@ -0,0 +1 @@
+ok  	voicesnap/internal/model	0.912s
diff --git a/02-P-NBL/round-1/logs/npm-run-build-20260602.log b/02-P-NBL/round-1/logs/npm-run-build-20260602.log
new file mode 100644
index 0000000..8729b98
--- /dev/null
+++ b/02-P-NBL/round-1/logs/npm-run-build-20260602.log
@@ -0,0 +1,38 @@
+
+> privatevoice-input-frontend@2.1.18 build
+> vite build
+
+vite v6.4.1 building for production...
+transforming...
+2:04:51 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:28:0 Elements with the 'dialog' interactive role must have a tabindex value
+https://svelte.dev/e/a11y_interactive_supports_focus
+26:
+27: <!-- svelte-ignore a11y_click_events_have_key_events -->
+28: <div class="overlay" onclick={onclose} role="dialog">
+          ^
+29:   <!-- svelte-ignore a11y_click_events_have_key_events -->
+30:   <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+2:04:51 PM [vite-plugin-svelte] src/components/update/UpdateDialog.svelte:30:2 Non-interactive element `<div>` should not be assigned mouse or keyboard event listeners
+https://svelte.dev/e/a11y_no_noninteractive_element_interactions
+28: <div class="overlay" onclick={onclose} role="dialog">
+29:   <!-- svelte-ignore a11y_click_events_have_key_events -->
+30:   <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+            ^
+31:     <h2 class="title">{t('update.title')}</h2>
+32:     <p class="version">{t('update.version', { version: info.version })}</p>
+2:04:51 PM [vite-plugin-svelte] src/components/shared/ToggleSwitch.svelte:17:0 Buttons and links should either contain text or have an `aria-label`, `aria-labelledby` or `title` attribute
+https://svelte.dev/e/a11y_consider_explicit_label
+15: </script>
+16:
+17: <button
+           ^
+18:   class="toggle"
+19:   class:active={checked}
+✓ 163 modules transformed.
+rendering chunks...
+computing gzip size...
+dist/index.html                      0.41 kB │ gzip:  0.28 kB
+dist/assets/app-icon-Bs-pS5hx.png  400.98 kB
+dist/assets/index-DAqIBjkZ.css      33.21 kB │ gzip:  5.70 kB
+dist/assets/index-B-UiAvkI.js      133.36 kB │ gzip: 44.72 kB
+✓ built in 413ms
diff --git a/02-P-NBL/round-1/prd.md b/02-P-NBL/round-1/prd.md
new file mode 100644
index 0000000..385a158
--- /dev/null
+++ b/02-P-NBL/round-1/prd.md
@@ -0,0 +1,930 @@
+# Round 1 PRD:模型 Profile 基础设施
+
+状态:v0.2 评审修订稿
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+关联文档:
+
+- [`../product-plan.md`](../product-plan.md)
+- [`../engineering-plan.md`](../engineering-plan.md)
+- [`../plan.md`](../plan.md)
+
+## 0. 本版修订结论
+
+本版根据 2 位架构师、2 位程序员、2 位 QA 对 v0.1 PRD 的评审意见修订。
+
+Round 1 的核心定位保持不变:
+
+```text
+不改变用户体验,把现有 SenseVoice 纳入可扩展的 ModelProfile 架构。
+```
+
+本版把以下事项从“建议/待确认”改为确定规则:
+
+| 事项 | Round 1 决策 |
+|---|---|
+| `models/state.json` | 本轮必须落地最小版读写;它只是索引,不能绕过真实文件校验 |
+| 新下载目录 | 新下载的 SenseVoice 统一安装到 `models/sensevoice-zh` |
+| 旧目录 | `models/sensevoice` 只读兼容,不自动迁移、不删除 |
+| `SelectedModelID` | 本轮加入隐藏配置,默认 `sensevoice-zh`,不暴露 UI |
+| Wails 公开 API | 保持旧签名兼容,不要求前端改调用 |
+| `RequiredFiles` | 需要支持 `allOf` / `anyOf`,不能只用 `[]string` |
+| EngineFactory | 只接收已解析、已校验的 `ResolvedModel` |
+| 下载器 | 必须 staging + 校验 + 安全落盘,失败不得破坏已有可用模型 |
+
+## 1. 背景
+
+当前 VoiceSnap 的离线识别链路基本围绕 SenseVoice 写死:
+
+- 模型目录固定为 `models/sensevoice`。
+- 模型文件固定查找 `model.int8.onnx` 或 `model.onnx`。
+- `tokens.txt` 固定在 SenseVoice 目录下。
+- engine 初始化固定使用 SenseVoice config。
+- 下载器只识别 SenseVoice 压缩包和 SenseVoice 目录结构。
+- 前端通过现有 Wails 方法判断模型是否存在,并触发下载。
+
+但后续产品规划已经确定要支持:
+
+| 语言版本 | 默认轻量模型 | 高级模型 |
+|---|---|---|
+| 中文 | SenseVoice | Qwen3-ASR |
+| English | Moonshine English | Parakeet |
+
+因此 Round 1 不是接入新模型,而是先把当前单模型架构改成可扩展的模型 Profile 架构,并确保现有中文 SenseVoice 用户体验不退化。
+
+## 2. Round 1 目标
+
+Round 1 完成后,系统内部应从:
+
+```text
+写死 SenseVoice 路径和 SenseVoice config
+```
+
+演进为:
+
+```text
+ModelRegistry -> ModelResolver -> ResolvedModel -> EngineFactory -> SenseVoice config
+```
+
+对用户来说,Round 1 交付后产品应基本无感:
+
+- 不出现 Moonshine。
+- 不出现 Parakeet。
+- 不出现 Qwen3-ASR。
+- 不出现语言版本设置。
+- 不出现模型选择页。
+- 当前 SenseVoice 下载、加载、识别、上屏、历史、词库能力不退化。
+
+## 3. 本轮非目标
+
+明确不做:
+
+- 不接入 Moonshine。
+- 不接入 Parakeet。
+- 不接入 Qwen3-ASR。
+- 不做语言 UI。
+- 不做模型选择 UI。
+- 不做模型删除 UI。
+- 不做模型市场。
+- 不改变首次启动语言判断逻辑。
+- 不改用户词库格式。
+- 不改历史记录格式。
+- 不做实时 streaming partial transcript。
+- 不做 `models/sensevoice` 到 `models/sensevoice-zh` 的自动大文件迁移。
+- 不做每个模型目录下的 `manifest.json`。
+- 不做 checksum、签名校验、远程模型 catalog。
+
+## 4. 用户故事
+
+### 4.1 已安装 SenseVoice 的老用户
+
+作为一个已经安装过 SenseVoice 的用户,我升级到 Round 1 版本后,应用应该继续识别到我的本地模型,并能正常离线识别。
+
+验收点:
+
+- 旧模型目录 `models/sensevoice` 可以被识别。
+- 不要求用户重新下载模型。
+- 不移动、不删除旧模型目录。
+- 不破坏用户词库、历史、设置。
+
+### 4.2 新安装用户
+
+作为一个新用户,我第一次启动应用时,仍然按当前流程准备 SenseVoice 模型,不需要理解模型 Profile 或语言版本。
+
+验收点:
+
+- 仍然能通过现有 Onboarding 流程下载 SenseVoice。
+- 新下载模型最终安装到 `models/sensevoice-zh`。
+- 下载完成后 required files 校验通过。
+- UI 流程不增加额外模型选择步骤。
+
+### 4.3 后续研发
+
+作为后续 Round 2/3/4/5 的研发人员,我可以在不重写主流程的情况下,新增 Moonshine、Parakeet、Qwen3-ASR profile,并通过 EngineFactory 生成不同 backend 的 sherpa-onnx config。
+
+验收点:
+
+- 新模型接入点清晰。
+- 业务主流程不继续复制 SenseVoice 硬编码逻辑。
+- required files、安装目录、backend kind、provider order 都能通过 profile 描述。
+
+## 5. 架构边界
+
+Round 1 采用以下职责划分:
+
+```text
+ModelRegistry
+  -> ModelResolver
+  -> ModelInstaller
+  -> EngineFactory
+  -> EngineService
+```
+
+| 模块 | 建议包/文件 | 职责 |
+|---|---|---|
+| `ModelRegistry` | `internal/model/registry.go` | 注册内置模型 profile,按 model id 查询 profile |
+| `LanguageRegistry` | `internal/model/registry.go` 或独立文件 | 注册最小语言 profile,为 Round 2 做准备,不参与 UI |
+| `ModelResolver` | `internal/model/resolver.go` | 根据 profile、state、新旧目录解析出可用模型路径 |
+| `ModelValidator` | `internal/model/validator.go` | 校验 required files,支持 `allOf` / `anyOf` |
+| `ModelInstallState` | `internal/model/install_state.go` | 读写 `models/state.json` |
+| `ModelInstaller` | `internal/model/downloader.go` 或新文件 | 下载、解压、staging、校验、安全落盘 |
+| `EngineFactory` | `internal/engine/factory.go` | 根据 `ResolvedModel` 生成 sherpa config 并初始化 engine |
+| `EngineService` | `services/engine_service.go` | 保持 Wails public API 兼容,内部映射到默认模型 |
+| `paths` | `internal/paths/paths.go` | 只提供基础路径函数,不承载模型业务判断 |
+
+关键原则:
+
+- Registry 只描述模型,不判断本地是否安装。
+- Resolver 负责路径查找和状态判断。
+- Validator 负责文件完整性。
+- EngineFactory 只接收已经 resolved 的模型,不再猜路径。
+- EngineService 对前端保持旧接口。
+
+## 6. 数据结构
+
+### 6.1 ModelProfile
+
+`ModelProfile` 表示一个可安装、可加载、可校验的识别模型。
+
+建议结构:
+
+```go
+type ModelProfile struct {
+	ID                   string
+	DisplayName          string
+	BackendKind          string
+	Tier                 string
+	SupportedLanguageIDs []string
+	RecommendedFor       []string
+	Description          string
+	ApproxSize           string
+	DownloadURLs         []string
+	InstallDirName       string
+	RequiredFiles        []RequiredFileRule
+	ProviderOrder        []string
+	NumThreads           int
+}
+```
+
+字段说明:
+
+| 字段 | 说明 |
+|---|---|
+| `ID` | 稳定技术 ID,不随显示名变化 |
+| `DisplayName` | 用户可见名称,例如 `SenseVoice` |
+| `BackendKind` | 决定使用哪类 sherpa config builder |
+| `Tier` | `default` 或 `advanced` |
+| `SupportedLanguageIDs` | 模型理论支持的语言 |
+| `RecommendedFor` | 本产品推荐该模型服务的语言 |
+| `InstallDirName` | 新安装目录名 |
+| `RequiredFiles` | 安装完整性规则 |
+| `ProviderOrder` | 例如 macOS 可为 `coreml,cpu` |
+
+注意:
+
+- 必须区分 `SupportedLanguageIDs` 和 `RecommendedFor`。
+- SenseVoice 支持多语言,但 Round 1 产品推荐仍是中文。
+- 后续 Parakeet 支持欧洲语言,不能因为 English 高级定位而丢失多语言扩展能力。
+
+### 6.2 RequiredFileRule
+
+`RequiredFiles []string` 不够表达 SenseVoice 的二选一模型文件,因此 Round 1 必须支持结构化规则。
+
+建议结构:
+
+```go
+type RequiredFileRule struct {
+	Role     string
+	AllOf    []string
+	AnyOf    []string
+	Required bool
+}
+```
+
+规则说明:
+
+- `AllOf` 中的路径必须全部存在。
+- `AnyOf` 中的路径至少存在一个。
+- 文件存在但为 0 字节,视为无效。
+- 目录类 required item 后续可扩展;Round 1 只需要文件。
+
+SenseVoice profile:
+
+```text
+id: sensevoice-zh
+displayName: SenseVoice
+backendKind: sensevoice
+tier: default
+supportedLanguageIds: zh-CN, zh-Hans, zh-Hant, yue, en, ja, ko
+recommendedFor: zh-CN
+installDirName: sensevoice-zh
+requiredFiles:
+  - role: model
+    anyOf:
+      - model.int8.onnx
+      - model.onnx
+  - role: tokens
+    allOf:
+      - tokens.txt
+providerOrder:
+  - coreml
+  - cpu
+numThreads: 4
+```
+
+模型文件优先级:
+
+1. 同时存在 `model.int8.onnx` 和 `model.onnx` 时,优先使用 `model.int8.onnx`。
+2. 只有 `model.onnx` 时,允许使用 `model.onnx`。
+3. 两者都不存在或文件为 0 字节,模型不完整。
+
+### 6.3 LanguageProfile
+
+Round 1 只做最小可编译语言 profile,不做运行时语言切换。
+
+建议结构:
+
+```go
+type LanguageProfile struct {
+	ID              string
+	DisplayName     string
+	UILocale        string
+	SystemMatchers  []string
+	DefaultModelID  string
+	UpgradeModelIDs []string
+}
+```
+
+Round 1 至少注册:
+
+```text
+id: zh-CN
+displayName: 中文
+uiLocale: zh-CN
+systemMatchers: zh, zh-CN, zh-Hans, zh-Hant
+defaultModelId: sensevoice-zh
+upgradeModelIds: []
+```
+
+Round 1 不使用它做 UI,也不做系统语言自动切换。
+
+### 6.4 ModelInstallState
+
+Round 1 必须落地最小版 `models/state.json`。
+
+文件位置:
+
+```text
+models/state.json
+```
+
+路径字段必须存相对 `models` 根目录的相对路径,避免 App Support 目录变化后绝对路径失效。
+
+建议结构:
+
+```json
+{
+  "schemaVersion": 1,
+  "selectedModelId": "sensevoice-zh",
+  "installedModels": {
+    "sensevoice-zh": {
+      "installedAt": 1780120000000,
+      "version": "unknown",
+      "path": "sensevoice-zh",
+      "sourceDirKind": "new"
+    }
+  }
+}
+```
+
+字段说明:
+
+| 字段 | 说明 |
+|---|---|
+| `schemaVersion` | 状态文件 schema 版本,本轮为 `1` |
+| `selectedModelId` | 当前选择模型,本轮默认 `sensevoice-zh` |
+| `installedModels[modelId].path` | 相对 `models` 根目录的路径 |
+| `installedModels[modelId].sourceDirKind` | `new` 或 `legacy` |
+
+状态文件原则:
+
+- `state.json` 是索引,不是真相。
+- required files 校验结果优先于 `state.json`。
+- `state.json` 不存在、为空、非法 JSON、model id 未知、路径不存在时,不得导致旧模型不可用。
+- 发现 state stale 但 fallback 找到完整模型时,可以 best-effort 修复 state;修复失败不阻塞模型加载。
+
+### 6.5 ResolvedModel
+
+`EngineFactory` 不直接接收 `ModelProfile`,也不自己查路径。它只接收 `ModelResolver` 产出的 `ResolvedModel`。
+
+建议结构:
+
+```go
+type ModelInstallStatus string
+
+const (
+	ModelNotInstalled              ModelInstallStatus = "not_installed"
+	ModelInstalledValid            ModelInstallStatus = "installed_valid"
+	ModelInstalledIncomplete       ModelInstallStatus = "installed_incomplete"
+	ModelStateInvalidFallbackFound ModelInstallStatus = "state_invalid_fallback_found"
+	ModelStateInvalidNoFallback    ModelInstallStatus = "state_invalid_no_fallback"
+)
+
+type ResolvedModel struct {
+	ModelID       string
+	Profile       ModelProfile
+	RootDir       string
+	SourceDirKind string
+	Files         map[string]string
+	BackendKind   string
+	ProviderOrder []string
+	Status        ModelInstallStatus
+	Missing       []string
+	Problems      []string
+}
+```
+
+`Files` 示例:
+
+```json
+{
+  "model": "/Users/ar/Library/Application Support/PrivateVoice Input/models/sensevoice-zh/model.int8.onnx",
+  "tokens": "/Users/ar/Library/Application Support/PrivateVoice Input/models/sensevoice-zh/tokens.txt"
+}
+```
+
+进入 engine 初始化的前置条件:
+
+```text
+Status == installed_valid
+或
+Status == state_invalid_fallback_found
+```
+
+其他状态不得进入 engine 初始化。
+
+## 7. 路径解析规则
+
+Round 1 默认当前模型为:
+
+```text
+sensevoice-zh
+```
+
+路径候选顺序:
+
+1. `models/state.json` 中 `sensevoice-zh` 记录的路径。
+2. 新目录 `models/sensevoice-zh`。
+3. 旧目录 `models/sensevoice`。
+
+每个候选目录都必须经过 required files 校验。
+
+解析算法:
+
+1. 读取 `models/state.json`。
+2. 如果 state 可读且包含 `sensevoice-zh`,把其中路径作为第一候选。
+3. 追加新目录 `models/sensevoice-zh`。
+4. 追加旧目录 `models/sensevoice`。
+5. 去重后按顺序校验。
+6. 第一个完整候选即为 resolved model。
+7. 如果 state 路径无效但后续目录找到完整模型,返回 `state_invalid_fallback_found`。
+8. 如果所有候选都不存在,返回 `not_installed`。
+9. 如果存在候选目录但文件缺失或 0 字节,返回 `installed_incomplete`,并列出缺失/异常项。
+10. 如果 state 无效且没有 fallback,返回 `state_invalid_no_fallback`。
+
+优先级规则:
+
+- 没有 state 时,新目录优先于旧目录。
+- state 指向旧目录且旧目录完整时,允许继续使用旧目录。
+- state 指向损坏目录时,不得直接失败,必须继续尝试新目录和旧目录。
+- 新旧目录都完整且没有 state 时,使用新目录。
+- 不自动迁移旧目录。
+- 不自动删除任何完整模型目录。
+
+## 8. 下载与安装规则
+
+Round 1 不接入新模型,但下载器必须向通用 `DownloadModel(profile)` 边界靠拢,内部只实现 SenseVoice。
+
+### 8.1 公开调用兼容
+
+前端现有调用必须继续可用:
+
+```go
+DownloadModel(primaryURL string, fallbackURL string) error
+```
+
+Round 1 行为:
+
+- `DownloadModel(primaryURL, fallbackURL)` 内部映射到默认模型 `sensevoice-zh`。
+- 传入的 `primaryURL` / `fallbackURL` 继续作为下载 URL 权威来源。
+- `ModelProfile.DownloadURLs` 可以作为内部默认值或后续扩展,但 Round 1 不要求前端改为传 `modelID`。
+
+### 8.2 安全安装流程
+
+下载必须使用 staging 流程:
+
+```text
+models/.downloads/sensevoice-zh/<run-id>/archive
+models/.downloads/sensevoice-zh/<run-id>/extract
+models/.staging/sensevoice-zh-<run-id>
+models/sensevoice-zh
+```
+
+流程:
+
+1. 创建本次下载临时目录。
+2. 下载到临时 archive 文件。
+3. 解压到临时 extract 目录。
+4. 在解压结果中定位 SenseVoice 模型目录。
+5. 复制或移动到 staging 目录。
+6. 对 staging 目录执行 required files 校验。
+7. 校验通过后再落到最终目录 `models/sensevoice-zh`。
+8. 写入或更新 `models/state.json`。
+9. 清理本次临时目录。
+
+失败处理:
+
+- 下载失败不得修改 `models/sensevoice`。
+- 下载失败不得修改已有完整的 `models/sensevoice-zh`。
+- 解压失败不得写入 state 为已安装。
+- 校验失败不得写入 state 为已安装。
+- 失败后再次下载不要求用户手动清理。
+- 临时目录残留允许存在,但不得被 Resolver 误判为已安装模型。
+
+目标目录处理:
+
+- 如果 `models/sensevoice-zh` 不存在,校验通过后移动 staging 到该目录。
+- 用户主动触发下载时,可以用 staging 校验通过的新模型替换 `models/sensevoice-zh`。
+- 替换 `models/sensevoice-zh` 前应先 rename 到 backup 临时名;替换失败时必须恢复 backup。
+- 如果 primary URL 下载成功但解压或校验失败,必须继续尝试 fallback URL。
+- 任何情况下不得删除完整的旧目录 `models/sensevoice`。
+
+## 9. EngineFactory 规则
+
+### 9.1 对外兼容
+
+现有调用入口必须保留:
+
+```go
+func New() (Engine, error)
+```
+
+Round 1 中:
+
+- `engine.New()` 内部使用当前 selected model。
+- 如果 `SelectedModelID` 为空,默认 `sensevoice-zh`。
+- `engine.New()` 调用 Resolver 获取 `ResolvedModel`。
+- Resolver 返回可用模型后,交给 EngineFactory 初始化。
+
+建议新增内部入口:
+
+```go
+func NewWithModelID(modelID string) (Engine, error)
+func NewWithResolvedModel(resolved model.ResolvedModel) (Engine, error)
+```
+
+平台实现应从无参:
+
+```go
+newPlatformEngine()
+```
+
+改为接收已解析路径:
+
+```go
+newPlatformEngine(resolved model.ResolvedModel)
+```
+
+### 9.2 SenseVoice config builder
+
+Round 1 只需要支持:
+
+```text
+backendKind = sensevoice
+```
+
+生成现有 sherpa-onnx config:
+
+```go
+config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
+config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
+config.ModelConfig.Tokens = resolved.Files["tokens"]
+config.ModelConfig.NumThreads = resolved.Profile.NumThreads
+config.ModelConfig.Provider = provider
+```
+
+provider 顺序:
+
+```text
+coreml -> cpu
+```
+
+用户可见硬件信息保持现状风格:
+
+```text
+SenseVoice · CoreML (Apple Neural Engine)
+SenseVoice · CPU
+```
+
+不要向用户展示 `sensevoice-zh` 这种内部 profile id。
+
+### 9.3 初始化失败
+
+如果 sherpa-onnx 初始化失败:
+
+- 不得导致 Wails 后端崩溃。
+- 不得导致主窗口卡死。
+- 不得占用麦克风资源。
+- 不得改变 selected model。
+- 不得把不完整模型写成可用状态。
+- 应通过现有 `engine:status` 事件返回错误状态。
+
+## 10. 配置兼容
+
+Round 1 在 `config.json` 中新增隐藏字段:
+
+```go
+type Config struct {
+	// existing fields...
+	SelectedModelID string `json:"SelectedModelID,omitempty"`
+}
+```
+
+兼容规则:
+
+- 老配置没有 `SelectedModelID` 时,加载后默认 `sensevoice-zh`。
+- `SelectedModelID` 为空时,默认 `sensevoice-zh`。
+- `SelectedModelID` 是未知 model id 时,回退 `sensevoice-zh`,记录日志。
+- 保存配置时不得丢失现有字段。
+- `ModelDownloadUrl` 和 `FallbackModelDownloadUrl` 本轮继续保留。
+- Round 1 不新增 `LanguageMode` / `LanguageID` 的运行时逻辑;这些留到 Round 2。
+
+## 11. Wails 与前端兼容契约
+
+Round 1 不要求前端改 UI,也不要求前端切换到新模型 API。
+
+必须保持以下 Wails public API:
+
+```go
+EngineService.ModelExists() bool
+EngineService.DownloadModel(primaryURL string, fallbackURL string) error
+ConfigService.GetConfig()
+```
+
+语义要求:
+
+- `ModelExists()` 返回“当前 selected/default model 是否完整可用”。
+- 目录存在但 required files 缺失时,`ModelExists()` 必须返回 `false`。
+- `DownloadModel(primaryURL, fallbackURL)` 继续触发现有下载进度事件。
+- `ConfigService.GetConfig()` 继续返回 `ModelDownloadUrl` 和 `FallbackModelDownloadUrl`。
+- 可以新增内部方法或新增 public 方法,但不能破坏旧方法。
+
+必须保持以下事件契约:
+
+```text
+model:download-progress
+```
+
+payload:
+
+```json
+{
+  "percent": 42.5,
+  "downloaded": 123456,
+  "total": 789012
+}
+```
+
+```text
+engine:status
+```
+
+状态语义继续支持:
+
+```text
+loading
+ready
+need_model
+error
+```
+
+`ready` 时继续带:
+
+```json
+{
+  "hardwareInfo": "SenseVoice · CoreML (Apple Neural Engine)"
+}
+```
+
+前端现有链路必须保持:
+
+```text
+App 启动
+-> EngineService.ModelExists()
+-> need_model
+-> OnboardingView
+-> EngineService.DownloadModel(primaryURL, fallbackURL)
+-> model:download-progress
+-> engine:status ready
+```
+
+## 12. UI 要求
+
+Round 1 原则上不新增 UI。
+
+允许的 UI 变化:
+
+- 错误文案更清晰。
+- 模型缺失提示仍然指向下载 SenseVoice。
+- 下载进度行为保持现状。
+
+不允许的 UI 变化:
+
+- 不展示模型列表。
+- 不展示语言版本列表。
+- 不展示 Moonshine。
+- 不展示 Parakeet。
+- 不展示 Qwen3-ASR。
+- 不引导用户选择模型。
+- 不展示内部 profile id。
+
+## 13. 离线与隐私要求
+
+Round 1 不改变隐私承诺:
+
+- 识别仍然本地离线执行。
+- 用户音频不上传。
+- 用户历史不上传。
+- 用户词库不上传。
+- replacement 规则不上传。
+
+允许联网的场景:
+
+- 用户无模型且触发模型下载。
+
+不允许联网的场景:
+
+- 已有完整模型时冷启动识别。
+- 录音识别过程中。
+- 用户词库处理过程中。
+- 历史记录写入过程中。
+- 为了判断模型是否存在而请求远程服务。
+
+## 14. 验收标准
+
+### 14.1 老用户兼容
+
+- 已存在 `models/sensevoice` 且文件完整的用户,升级后不需要重新下载模型。
+- 应用能正常初始化 SenseVoice。
+- 不自动移动旧目录。
+- 不自动删除旧目录。
+- 用户词库、历史、设置文件升级前后存在且字段不丢。
+
+### 14.2 新用户流程
+
+- 新用户无 `models` 目录时,现有 Onboarding 流程仍然可用。
+- 下载完成后模型最终位于 `models/sensevoice-zh`。
+- `models/state.json` 写入 `selectedModelId = sensevoice-zh`。
+- required files 校验通过后 engine 初始化成功。
+
+### 14.3 状态文件兼容
+
+- `state.json` 不存在时,可以通过目录探测找到模型。
+- `state.json` 非法 JSON 时,不影响完整模型加载。
+- `state.json` 指向不存在目录时,应 fallback 到新目录和旧目录。
+- `state.json` 指向不完整目录时,应 fallback 到其他完整目录。
+- fallback 成功时,可 best-effort 修复 state;修复失败不阻塞加载。
+
+### 14.4 模型完整性
+
+- 只有 `tokens.txt` 时,模型不可用。
+- 只有模型文件没有 `tokens.txt` 时,模型不可用。
+- `model.int8.onnx` 和 `model.onnx` 都不存在时,模型不可用。
+- 任何 required file 为 0 字节时,模型不可用。
+- 同时存在 `model.int8.onnx` 和 `model.onnx` 时,优先使用 `model.int8.onnx`。
+- 不完整模型不得进入 engine 初始化。
+
+### 14.5 下载失败保护
+
+- 下载 URL 失败时,已有完整模型仍可继续使用。
+- 压缩包损坏时,已有完整模型仍可继续使用。
+- 解压失败时,不写入已安装 state。
+- 校验失败时,不写入已安装 state。
+- 下载失败后再次下载可以重试。
+- 失败残留临时目录不得被误判为已安装模型。
+
+### 14.6 Wails 与 UI 回归
+
+- `EngineService.ModelExists()` 签名不变。
+- `EngineService.DownloadModel(primaryURL, fallbackURL)` 签名不变。
+- `ConfigService.GetConfig()` 继续返回旧下载 URL 字段。
+- `model:download-progress` payload 不变。
+- `engine:status` 事件语义不变。
+- 设置页、首页、下载页不出现 Moonshine、Parakeet、Qwen3-ASR、语言选择、模型选择入口。
+
+### 14.7 主流程回归
+
+至少验证:
+
+- 应用冷启动。
+- 模型不存在时进入下载流程。
+- 模型已存在时不进入下载流程。
+- 一次完整语音识别。
+- 用户词库 replacement 仍然生效。
+- 历史记录仍然写入。
+- 权限页、首页、词库页、历史页不受影响。
+- 完全断网且已有模型时,启动、录音、识别、历史写入都可用。
+
+## 15. 测试建议
+
+### 15.1 单元测试
+
+覆盖:
+
+- `ModelRegistry` 根据 ID 获取 profile。
+- 未知 model id 返回错误。
+- `LanguageRegistry` 至少包含 `zh-CN`。
+- required files 的 `allOf` 规则。
+- required files 的 `anyOf` 规则。
+- 同时存在 `model.int8.onnx` 和 `model.onnx` 时选择 int8。
+- 只有 `model.onnx` 时通过。
+- 缺少 `tokens.txt` 时失败。
+- required file 为 0 字节时失败。
+- 目录不存在时返回 `not_installed`。
+- state 缺失时 fallback 到目录探测。
+- state 非法 JSON 时 fallback 到目录探测。
+- state 指向坏目录但旧目录完整时返回可用。
+- 新旧目录都完整且无 state 时使用新目录。
+- `SelectedModelID` 缺失时默认 `sensevoice-zh`。
+- 无效 `SelectedModelID` 回退 `sensevoice-zh`。
+
+### 15.2 集成测试
+
+覆盖:
+
+- 使用旧 `models/sensevoice` 目录初始化。
+- 使用新 `models/sensevoice-zh` 目录初始化。
+- `state.json` 指向旧目录时初始化。
+- `state.json` 损坏但新目录完整时初始化。
+- 下载 SenseVoice 后写入 `models/sensevoice-zh` 和 `state.json`。
+- 下载失败不破坏已有旧目录。
+- engine 初始化失败时不破坏 selected model。
+
+### 15.3 测试 fixture
+
+建议补充固定测试数据:
+
+```text
+privatevoice.src/testdata/asr/zh_short.wav
+```
+
+建议音频内容:
+
+```text
+请帮我测试 SenseVoice 离线输入。
+```
+
+自动化识别验收不强制标点完全一致,至少要求包含关键词:
+
+```text
+测试
+离线
+输入
+```
+
+用户词库 fixture:
+
+```json
+{
+  "replacements": [
+    {
+      "from": "sins voice",
+      "to": "SenseVoice"
+    }
+  ]
+}
+```
+
+历史 fixture:
+
+```json
+{
+  "retentionDays": 0,
+  "entries": []
+}
+```
+
+### 15.4 QA 测试矩阵
+
+| 场景 | 前置数据 | 网络 | 重点验收 |
+|---|---|---|---|
+| 老用户旧目录 | 只有 `models/sensevoice`,文件完整 | 断网 | 不重新下载,能初始化和识别 |
+| 新用户无模型 | 无 `models` 目录 | 联网 | 下载到 `models/sensevoice-zh`,校验通过 |
+| 新目录优先 | 新旧目录都完整,无 state | 断网 | 使用 `models/sensevoice-zh` |
+| state 指旧目录 | state path=`sensevoice`,旧目录完整 | 断网 | 使用旧目录且不迁移 |
+| state 损坏 | `state.json` 非法 JSON,新/旧目录完整 | 断网 | fallback 到目录探测 |
+| state 指坏目录 | state 指向不完整目录,旧目录完整 | 断网 | fallback 到旧目录 |
+| 目录不完整 | 只有 `tokens.txt` 或只有模型文件 | 断网 | 显示模型不可用,不初始化 engine |
+| 两种模型文件 | 同时有 `model.int8.onnx` 和 `model.onnx` | 断网 | 使用 int8 |
+| 下载失败 | 已有旧模型,下载 URL 失败 | 联网/断网 | 旧模型仍可用,旧目录不被破坏 |
+| 解压失败 | 已有旧模型,压缩包损坏 | 联网 | 旧模型仍可用,不写入完整 state |
+| 回归主流程 | 模型完整、词库和历史已有数据 | 断网 | 录音、上屏、replacement、历史写入正常 |
+| UI 回归 | 任意模型状态 | 任意 | 不出现新模型/语言选择入口 |
+| 权限回归 | 麦克风权限未授权/已授权 | 任意 | 权限页、首页、词库页、历史页可用 |
+
+## 16. 发布前检查
+
+Round 1 发布测试包前,必须完成:
+
+- 构建升级测试样本目录:旧目录完整、旧目录损坏、新目录完整、新旧同时存在、非法 state。
+- 跑 Go 单元测试。
+- 跑前端构建或现有可用的前端检查。
+- 真机验证冷启动。
+- 真机验证首次无模型下载。
+- 真机验证已有模型识别。
+- 真机验证麦克风权限未授权/已授权。
+- 真机验证上屏文本。
+- 真机验证 replacement。
+- 真机验证历史写入。
+- 断网验证已有模型识别全程可用。
+- 断网验证无模型时不会出现异常联网重试风暴。
+- 识别期间观察网络请求,确认没有上传音频、文本、历史、词库或自动请求模型元数据。
+
+发布记录至少保留:
+
+- App 版本和 build 编号。
+- macOS 版本。
+- Apple Silicon / Intel 覆盖情况。
+- 模型目录组合。
+- 关键失败场景结果。
+- 是否断网通过。
+
+## 17. 风险与对策
+
+| 风险 | 影响 | 对策 |
+|---|---|---|
+| 抽象过度导致 Round 1 变大 | 延误后续 Round | 只实现 `sensevoice` backend,接口预留但不接新模型 |
+| 旧目录兼容遗漏 | 老用户升级后模型失效 | `models/sensevoice` 是必须兼容路径,纳入自动化和手工测试 |
+| state 和真实文件不一致 | 误判模型可用性 | required files 校验优先于 state |
+| 下载器覆盖旧模型 | 老用户可用模型被破坏 | staging + 校验 + 安全落盘,禁止删除完整旧目录 |
+| Wails API 改签名 | 前端运行时断裂 | Round 1 保持旧 public API |
+| EngineFactory 抽象不彻底 | 后续接 Moonshine/Parakeet 返工 | EngineFactory 只接收 `ResolvedModel` |
+| 不完整模型进入初始化 | 崩溃或卡死 | Resolver/Validator 阻止进入 engine |
+| 识别时触发联网 | 破坏离线承诺 | 已有模型时禁止冷启动/识别阶段请求远程 |
+
+## 18. 交付物
+
+Round 1 结束时应交付:
+
+- 可运行应用。
+- `ModelProfile` / `ModelRegistry`。
+- `LanguageProfile` / `LanguageRegistry` 最小实现。
+- `RequiredFileRule`,支持 `allOf` / `anyOf`。
+- `ModelInstallState`,读写 `models/state.json`。
+- `ModelResolver`,支持 state、新目录、旧目录 fallback。
+- `ResolvedModel`。
+- `sensevoice-zh` profile。
+- `EngineFactory` 初步实现。
+- SenseVoice 新旧路径兼容。
+- 下载器 staging + 安全落盘。
+- `SelectedModelID` 隐藏配置,默认 `sensevoice-zh`。
+- Wails public API 兼容。
+- 对应单元测试和必要集成测试。
+- 简短研发说明,说明后续新增 Moonshine profile 的接入点。
+
+## 19. 明确暂缓项
+
+以下事项不进入 Round 1:
+
+- Moonshine 真实模型接入。
+- Parakeet 真实模型接入。
+- Qwen3-ASR 真实模型接入。
+- 语言设置 UI。
+- 模型选择 UI。
+- 模型删除 UI。
+- 自动迁移旧模型目录。
+- 每个模型目录下的 `manifest.json`。
+- checksum / 签名校验。
+- 远程模型 catalog。
+- 模型 marketplace。
diff --git a/02-P-NBL/round-1/qa-handoff-current.md b/02-P-NBL/round-1/qa-handoff-current.md
new file mode 100644
index 0000000..54ba4e7
--- /dev/null
+++ b/02-P-NBL/round-1/qa-handoff-current.md
@@ -0,0 +1,130 @@
+# Round 1 QA 交接说明
+
+状态:QA 交接准备稿
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+
+## 1. 当前结论
+
+Round 1 代码级研发预检已经通过,且负责人已经确认仓库迁移基线:
+
+```text
+privatevoice.src 是正式源码目录,VoiceSnapGo 删除是预期状态。
+```
+
+当前已创建 Round 1 专用分支:
+
+```text
+codex/round1-model-profile
+```
+
+当前可进入:
+
+```text
+QA 基线提交准备 / QA 预检延续
+```
+
+暂不应进入:
+
+```text
+测试包交付
+用户验收
+发布打包
+```
+
+## 2. 已完成的研发内容
+
+- `ModelProfile` / `ModelRegistry`
+- `LanguageProfile` / `LanguageRegistry` 最小实现
+- `RequiredFileRule`,支持 `AllOf` / `AnyOf`
+- `ModelInstallState` / `models/state.json`
+- `ModelResolver` / `ResolvedModel`
+- `sensevoice-zh` profile
+- `SelectedModelID` 隐藏配置
+- `engine.New()` 兼容入口改造
+- 平台 engine 使用 `ResolvedModel`
+- 下载器 staging + 校验 + 安全落盘
+- primary 坏包时尝试 fallback URL
+- `state.json` 路径逃逸防护
+- macOS provider 使用 profile `ProviderOrder`
+- engine 初始化失败状态区分
+- `EngineService.GetStatus()` 前端补查接口
+- Onboarding 处理 `need_model + error`
+- Round 1 fake fixture
+
+## 3. 已通过的自动化验证
+
+已执行并通过:
+
+```bash
+cd privatevoice.src/frontend && npm run build
+cd privatevoice.src && go test ./internal/model -count=1
+cd privatevoice.src && go test ./... -count=1
+```
+
+说明:
+
+- 前端构建通过。
+- 前端仍有既有 Svelte a11y warning,不是本轮引入。
+- 真实模型、真机麦克风、断网识别尚未验证。
+
+## 4. 当前 git 状态处理
+
+已确认并按本轮范围暂存:
+
+- `privatevoice.src/` 正式源码。
+- 旧 `VoiceSnapGo/` 目录删除。
+- `02-P-NBL/` 子项目文档。
+- `.gitignore` 中 `privatevoice.src` 生成物忽略规则。
+
+暂未纳入本轮:
+
+- 根目录 `CHANGELOG.md` 既有改动。
+- 根目录 `TODO.md` 既有改动。
+- 根目录 `README.md` 删除状态。
+- `docs/`、`Op/`、`Release/` 等未跟踪目录。
+
+已确认未暂存:
+
+- `privatevoice.src/frontend/node_modules/`
+- `privatevoice.src/frontend/dist/`
+- `privatevoice.src/build/local/`
+- `privatevoice.src/build/appstore-local/`
+- `.dmg` / `.app` 等构建产物。
+
+## 5. 正式 QA 前需要解决
+
+正式 QA 仍需要:
+
+1. 生成固定 commit。
+2. 记录 commit id。
+3. 明确 QA 启动方式:本地命令或测试包。
+4. 执行真机真实模型验证。
+
+## 6. QA 真机验证仍需执行
+
+不管选择 A 还是 B,正式 QA 都必须执行:
+
+- 真实 SenseVoice 模型下载。
+- 真实 SenseVoice 初始化。
+- 老用户旧 `models/sensevoice` 断网升级路径。
+- 新用户 `models/sensevoice-zh` 下载路径。
+- 麦克风权限未授权 / 已授权。
+- 辅助功能或上屏权限回归。
+- 真实录音识别。
+- replacement。
+- 历史写入。
+- 已有模型时断网启动、录音、识别、写历史。
+- 识别阶段网络观察。
+- 菜单栏、Dock、窗口层级、全屏、Split View smoke。
+
+## 7. 当前建议
+
+建议当前先不要打测试包。
+
+建议下一步:
+
+```text
+固定 commit,然后进入正式 QA。测试包需在 QA 验证通过后按发布规则另行生成。
+```
diff --git a/02-P-NBL/round-1/qa-precheck-current.md b/02-P-NBL/round-1/qa-precheck-current.md
new file mode 100644
index 0000000..d716679
--- /dev/null
+++ b/02-P-NBL/round-1/qa-precheck-current.md
@@ -0,0 +1,143 @@
+# Round 1 QA 预检记录
+
+状态:QA 预检记录
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`qa-test-plan.md`](qa-test-plan.md)
+- [`dev-checklist-current.md`](dev-checklist-current.md)
+- [`todo-list.md`](todo-list.md)
+
+## 1. 预检结论
+
+当前结论:
+
+```text
+研发自动化预检通过;尚不能进入用户验收;正式 QA 仍需固定分支/commit、可运行构建或测试包、真机真实模型验证。
+```
+
+本次已处理前一轮复核指出的研发问题:
+
+- primary URL 下载到坏包时,继续尝试 fallback URL。
+- `state.json` 路径限制在 `models` 根目录内,拒绝绝对路径和 `..` 逃逸。
+- macOS engine 使用 profile 中的 `ProviderOrder`。
+- engine 初始化失败时区分缺模型和模型结构存在但不可加载。
+- Onboarding 可以显示 `need_model + error`。
+- 前端可通过 `EngineService.GetStatus()` 补查可能错过的 engine 状态。
+- 用户主动下载时可安全替换 `models/sensevoice-zh`,失败时尝试恢复 backup。
+
+## 2. 自动化验证结果
+
+| 命令 | 结果 | 说明 |
+|---|---|---|
+| `cd privatevoice.src/frontend && npm run build` | 通过 | 前端构建通过,有既有 Svelte a11y warning |
+| `cd privatevoice.src && go test ./internal/model -count=1` | 通过 | 模型包测试强制重跑 |
+| `cd privatevoice.src && go test ./... -count=1` | 通过 | Go 全量测试强制重跑;新 checkout 需先完成前端构建以生成 `frontend/dist` |
+
+前端构建 warning:
+
+- `src/components/update/UpdateDialog.svelte`:dialog focus / noninteractive listener warning。
+- `src/components/shared/ToggleSwitch.svelte`:button aria label warning。
+
+判断:
+
+```text
+这些 warning 不由 Round 1 改动引入,本轮未修改对应组件。
+```
+
+## 3. 新增测试覆盖
+
+已覆盖:
+
+- 默认 profile 为 `sensevoice-zh`。
+- 未知 model id 报错。
+- `RequiredFileRule.AnyOf`。
+- `RequiredFileRule.AllOf`。
+- `model.int8.onnx` 优先于 `model.onnx`。
+- 缺少 `tokens.txt` 失败。
+- 0 字节 required file 失败。
+- 旧 `models/sensevoice` 可解析。
+- 新旧目录都存在时优先 `models/sensevoice-zh`。
+- 非法 `state.json` fallback。
+- state 指坏目录 fallback。
+- state 指绝对路径时拒绝并 fallback。
+- state 使用 `../` 逃逸时拒绝并 fallback。
+- 不完整目录返回 `installed_incomplete`。
+- bad archive 不写 installed state。
+- primary 坏包时尝试 fallback URL。
+- 下载失败不删除旧模型。
+- 用户触发下载可替换已有 `models/sensevoice-zh`。
+
+## 4. Fixture 状态
+
+已新增:
+
+```text
+privatevoice.src/testdata/round1/
+```
+
+包含:
+
+- `models-valid-legacy/`
+- `models-valid-new/`
+- `models-incomplete-missing-tokens/`
+- `models-incomplete-missing-model/`
+- `models-zero-byte/`
+- `state-invalid-json/`
+- `state-points-to-legacy/`
+- `state-points-to-broken/`
+- `userdict.json`
+- `history.json`
+
+说明:
+
+```text
+这些 fixture 是轻量 fake model 文件,只用于路径、state、校验、fallback 的确定性测试。
+它们不能替代真实 SenseVoice 模型识别验证。
+```
+
+## 5. 仍未满足的正式 QA 准入项
+
+| 项目 | 状态 | 说明 |
+|---|---|---|
+| 固定分支 | 已完成 | `codex/round1-model-profile` |
+| 固定 commit id | 待完成 | 暂存区已收拢,提交后进入正式 QA 基线 |
+| 可运行 App 或测试包 | 未完成 | 尚未打包 |
+| 原始测试日志文件 | 未完成 | 当前结果记录在对话和本文件中,未单独落日志文件 |
+| 真实 SenseVoice 模型下载 | 未完成 | 需要真实网络和模型源 |
+| 真实 SenseVoice 初始化 | 未完成 | 需要真实模型文件 |
+| 真机麦克风验证 | 未完成 | 需要 macOS 权限和真实录音 |
+| 断网真实识别 | 未完成 | 需要真实模型和真机 |
+| 网络观察 | 未完成 | 需要真机验证识别阶段无上传 |
+
+## 6. 不可替代的真机 QA
+
+正式 QA 必须覆盖:
+
+- Apple Silicon 真机真实 SenseVoice 初始化。
+- 旧 `models/sensevoice` 断网升级路径。
+- 新 `models/sensevoice-zh` 新用户下载路径。
+- 麦克风权限未授权 / 已授权。
+- 辅助功能或上屏权限回归。
+- 真实录音识别。
+- 上屏文本。
+- replacement。
+- 历史写入。
+- 已有模型时断网启动、录音、识别、写历史。
+- 识别阶段网络观察。
+- 菜单栏、Dock 隐藏策略、窗口层级、全屏、Split View smoke。
+- 坏模型文件导致 engine 初始化失败时 UI 是否可恢复。
+
+## 7. 下一步
+
+建议顺序:
+
+1. 创建 Round 1 专用分支。
+2. 提交当前改动,得到固定 commit id。
+3. 如要进入正式 QA,先生成可运行测试包或明确本地启动命令。
+4. QA 按 [`qa-test-plan.md`](qa-test-plan.md) 执行真机场景。
+5. QA 输出 `qa-report-YYYYMMDD.md`。
+6. 如果 QA 通过,再按项目发布规则递增版本、更新 `CHANGELOG.md`、打包 `.dmg`。
diff --git a/02-P-NBL/round-1/qa-report-template.md b/02-P-NBL/round-1/qa-report-template.md
new file mode 100644
index 0000000..83a4da7
--- /dev/null
+++ b/02-P-NBL/round-1/qa-report-template.md
@@ -0,0 +1,127 @@
+# Round 1 QA 报告模板
+
+状态:v0.1 模板
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:待填写
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`qa-test-plan.md`](qa-test-plan.md)
+- [`dev-checklist.md`](dev-checklist.md)
+
+## 1. 基本信息
+
+| 项目 | 内容 |
+|---|---|
+| 分支 | 待填写 |
+| commit id | 待填写 |
+| QA 负责人 | 待填写 |
+| 测试日期 | 待填写 |
+| App 版本 / build | 待填写 |
+| macOS 版本 | 待填写 |
+| 芯片 | 待填写 |
+| 网络状态 | 联网 / 断网 / 均覆盖 |
+| 测试结论 | 通过 / 不通过 / 有条件通过 |
+
+## 2. QA 进入标准检查
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| 研发提供可运行构建或分支 | 未检查 |  |
+| 研发提供变更范围说明 | 未检查 |  |
+| 研发提供开发自查记录 | 未检查 |  |
+| Go 单元测试结果已提供 | 未检查 |  |
+| 前端构建或检查结果已提供 | 未检查 |  |
+| 测试 fixture 说明已提供 | 未检查 |  |
+| 已知风险已说明 | 未检查 |  |
+
+## 3. 自动化测试结果
+
+| 命令 | 结果 | 日志/说明 |
+|---|---|---|
+| `cd privatevoice.src && go test ./...` | 未执行 |  |
+| 前端构建/检查命令 | 未执行 |  |
+| 其他 | 未执行 |  |
+
+## 4. 测试矩阵结果
+
+| ID | 场景 | 结果 | 说明 |
+|---|---|---|---|
+| R1-QA-001 | 老用户旧目录 | 未执行 |  |
+| R1-QA-002 | 新用户无模型 | 未执行 |  |
+| R1-QA-003 | 新目录优先 | 未执行 |  |
+| R1-QA-004 | state 指旧目录 | 未执行 |  |
+| R1-QA-005 | state 非法 JSON | 未执行 |  |
+| R1-QA-006 | state 指坏目录 | 未执行 |  |
+| R1-QA-007 | 缺 tokens | 未执行 |  |
+| R1-QA-008 | 缺模型文件 | 未执行 |  |
+| R1-QA-009 | 0 字节文件 | 未执行 |  |
+| R1-QA-010 | int8 优先 | 未执行 |  |
+| R1-QA-011 | 下载失败保护 | 未执行 |  |
+| R1-QA-012 | 解压失败保护 | 未执行 |  |
+| R1-QA-013 | 重试下载 | 未执行 |  |
+| R1-QA-014 | Wails API 兼容 | 未执行 |  |
+| R1-QA-015 | UI 不暴露新能力 | 未执行 |  |
+| R1-QA-016 | replacement 回归 | 未执行 |  |
+| R1-QA-017 | 历史回归 | 未执行 |  |
+| R1-QA-018 | 权限回归 | 未执行 |  |
+| R1-QA-019 | 权限已授权 | 未执行 |  |
+| R1-QA-020 | macOS 真实使用 smoke | 未执行 |  |
+
+## 5. 断网与隐私验证
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| 已有模型时断网冷启动可用 | 未检查 |  |
+| 已有模型时断网录音识别可用 | 未检查 |  |
+| 已有模型时断网历史写入可用 | 未检查 |  |
+| 无模型断网时不出现异常重试风暴 | 未检查 |  |
+| 识别阶段未观察到音频上传 | 未检查 |  |
+| 识别阶段未观察到文本上传 | 未检查 |  |
+| 识别阶段未观察到历史/词库上传 | 未检查 |  |
+
+## 6. 缺陷记录
+
+| ID | 级别 | 标题 | 状态 | 说明 |
+|---|---|---|---|---|
+| 待填写 | P0/P1/P2/P3 | 待填写 | Open/Fixed/Accepted | 待填写 |
+
+## 7. 证据位置
+
+| 证据 | 路径/链接 |
+|---|---|
+| 自动化测试日志 | 待填写 |
+| 截图 | 待填写 |
+| 录屏 | 待填写 |
+| 构建物 | 待填写 |
+| 其他 | 待填写 |
+
+## 8. QA 退出标准检查
+
+| 检查项 | 结果 | 说明 |
+|---|---|---|
+| P0/P1 问题全部关闭 | 未检查 |  |
+| P2 问题已修复或有接受结论 | 未检查 |  |
+| 自动化测试通过 | 未检查 |  |
+| 老用户旧目录升级路径通过 | 未检查 |  |
+| 新用户下载路径通过 | 未检查 |  |
+| 下载失败保护通过 | 未检查 |  |
+| 断网已有模型识别通过 | 未检查 |  |
+| replacement 和历史回归通过 | 未检查 |  |
+| UI 不暴露新模型和语言入口 | 未检查 |  |
+| QA 证据记录完整 | 未检查 |  |
+
+## 9. QA 总结
+
+结论:
+
+```text
+待填写:通过 / 不通过 / 有条件通过
+```
+
+说明:
+
+```text
+待填写
+```
diff --git a/02-P-NBL/round-1/qa-test-plan.md b/02-P-NBL/round-1/qa-test-plan.md
new file mode 100644
index 0000000..67af545
--- /dev/null
+++ b/02-P-NBL/round-1/qa-test-plan.md
@@ -0,0 +1,266 @@
+# Round 1 QA 测试计划:模型 Profile 基础设施
+
+状态:v0.1 QA 计划稿
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+
+## 1. QA 目标
+
+Round 1 的 QA 目标不是证明“代码结构变漂亮了”,而是证明:
+
+```text
+底层模型 Profile 改造没有破坏现有用户体验,并且为后续多模型接入提供了可验证的基础。
+```
+
+重点验证:
+
+- 老用户升级后可继续使用旧模型目录。
+- 新用户仍可完成 SenseVoice 下载和初始化。
+- 不完整模型不会被误判为可用。
+- 下载失败不会破坏已有模型。
+- 用户词库、历史、设置不丢。
+- 已有模型时断网可正常识别。
+- UI 不暴露 Round 2-5 的新模型和语言能力。
+
+## 2. 测试范围
+
+### 2.1 本轮必须测
+
+- 模型 profile registry。
+- required files 校验。
+- `models/state.json` 缺失、损坏、失效。
+- 新目录 `models/sensevoice-zh`。
+- 旧目录 `models/sensevoice`。
+- 新旧目录同时存在。
+- Onboarding 下载链路。
+- Wails public API 兼容。
+- engine 初始化。
+- 用户词库 replacement。
+- 历史写入。
+- 权限页、首页、词库页、历史页基础回归。
+- 断网已有模型识别。
+
+### 2.2 本轮不测
+
+- Moonshine 真实识别效果。
+- Parakeet 真实识别效果。
+- Qwen3-ASR 真实识别效果。
+- 多语言 UI。
+- 模型选择 UI。
+- 模型删除 UI。
+- 模型 marketplace。
+
+## 3. QA 进入标准
+
+研发移交 QA 前必须提供:
+
+- 可运行构建或可运行分支。
+- 变更范围说明。
+- 开发自查记录。
+- Go 单元测试结果。
+- 前端构建或现有前端检查结果。
+- 测试 fixture 说明。
+- 已知风险说明。
+
+未满足以上条件时,QA 可以拒绝进入正式验收,只做预检。
+
+## 4. 测试环境
+
+最低环境:
+
+| 项目 | 要求 |
+|---|---|
+| OS | macOS,优先覆盖当前主要目标版本 |
+| 芯片 | Apple Silicon 必测;Intel 如仍支持则抽测 |
+| 网络 | 联网、断网两种状态 |
+| 权限 | 麦克风未授权、已授权;辅助功能/输入权限按当前产品要求覆盖 |
+| 模型目录 | 旧目录、新目录、新旧同时存在、损坏目录 |
+
+测试数据目录建议:
+
+```text
+privatevoice.src/testdata/round1/
+```
+
+建议包含:
+
+```text
+models-valid-legacy/
+models-valid-new/
+models-incomplete-missing-tokens/
+models-incomplete-missing-model/
+models-zero-byte/
+state-invalid-json/
+state-points-to-legacy/
+state-points-to-broken/
+userdict.json
+history.json
+zh_short.wav
+```
+
+如果真实模型体积过大,自动化测试可以使用小型 fake file fixture;真实识别必须在真机手工测试中使用真实模型完成。
+
+## 5. 测试矩阵
+
+| ID | 场景 | 前置数据 | 网络 | 期望结果 |
+|---|---|---|---|---|
+| R1-QA-001 | 老用户旧目录 | 只有 `models/sensevoice`,文件完整 | 断网 | 不重新下载,能初始化和识别 |
+| R1-QA-002 | 新用户无模型 | 无 `models` 目录 | 联网 | Onboarding 下载到 `models/sensevoice-zh`,校验通过 |
+| R1-QA-003 | 新目录优先 | 新旧目录都完整,无 state | 断网 | 使用 `models/sensevoice-zh` |
+| R1-QA-004 | state 指旧目录 | state path=`sensevoice`,旧目录完整 | 断网 | 使用旧目录,不迁移、不删除 |
+| R1-QA-005 | state 非法 JSON | `state.json` 非法,新/旧目录完整 | 断网 | fallback 到目录探测 |
+| R1-QA-006 | state 指坏目录 | state 指向不完整目录,旧目录完整 | 断网 | fallback 到旧目录 |
+| R1-QA-007 | 缺 tokens | 目录只有模型文件 | 断网 | `ModelExists=false`,不初始化 engine |
+| R1-QA-008 | 缺模型文件 | 目录只有 `tokens.txt` | 断网 | `ModelExists=false`,不初始化 engine |
+| R1-QA-009 | 0 字节文件 | required file 为 0 字节 | 断网 | 模型不可用 |
+| R1-QA-010 | int8 优先 | 同时有 `model.int8.onnx` 和 `model.onnx` | 断网 | 使用 int8 |
+| R1-QA-011 | 下载失败保护 | 已有旧模型,下载 URL 失败 | 联网/断网 | 旧模型仍可用,旧目录未被破坏 |
+| R1-QA-012 | 解压失败保护 | 已有旧模型,压缩包损坏 | 联网 | 不写入完整 state,旧模型仍可用 |
+| R1-QA-013 | 重试下载 | 前一次下载失败 | 联网 | 再次下载可继续,不需手动清理 |
+| R1-QA-014 | Wails API 兼容 | 任意有效模型状态 | 任意 | 旧 `ModelExists` / `DownloadModel` 调用可用 |
+| R1-QA-015 | UI 不暴露新能力 | 任意模型状态 | 任意 | 不出现 Moonshine、Parakeet、Qwen3-ASR、语言选择、模型选择 |
+| R1-QA-016 | replacement 回归 | 模型完整,词库含 replacement | 断网 | 识别结果经过 replacement |
+| R1-QA-017 | 历史回归 | 模型完整,历史为空 | 断网 | 识别后新增历史 |
+| R1-QA-018 | 权限回归 | 麦克风未授权 | 任意 | 不进入假录音状态,权限页可用 |
+| R1-QA-019 | 权限已授权 | 麦克风已授权 | 断网 | 可录音、识别、上屏 |
+| R1-QA-020 | macOS 真实使用 smoke | 模型完整 | 断网 | 菜单栏、Dock 策略、窗口层级、全屏/Split View 无明显回归 |
+
+## 6. 自动化测试建议
+
+自动化优先覆盖确定性逻辑:
+
+- `ModelRegistry` 默认模型。
+- `LanguageRegistry` 最小 `zh-CN`。
+- required files `allOf` / `anyOf`。
+- int8 优先。
+- 0 字节 required file 失败。
+- state 缺失 fallback。
+- state 非法 JSON fallback。
+- state 指坏目录 fallback。
+- 新旧目录优先级。
+- `SelectedModelID` 默认值。
+- 无效 `SelectedModelID` 回退。
+- 下载失败不删除旧目录。
+
+建议命令:
+
+```bash
+cd privatevoice.src
+go test ./...
+```
+
+如果新增前端相关改动,执行项目现有前端构建或检查命令。
+
+## 7. 手工测试步骤
+
+### 7.1 老用户升级路径
+
+1. 准备 App Support 目录,只放旧模型目录 `models/sensevoice`。
+2. 删除或不创建 `models/state.json`。
+3. 断网。
+4. 启动应用。
+5. 确认不进入模型下载页。
+6. 完成一次录音识别。
+7. 确认历史新增。
+8. 确认旧模型目录仍存在,未被移动或删除。
+
+通过标准:
+
+- 用户无需重新下载。
+- 识别可用。
+- 用户数据不丢。
+
+### 7.2 新用户下载路径
+
+1. 准备干净 App Support 目录。
+2. 联网启动应用。
+3. 确认进入 Onboarding 下载流程。
+4. 下载完成后检查:
+   - `models/sensevoice-zh` 存在。
+   - `models/state.json` 存在。
+   - state 中 `selectedModelId = sensevoice-zh`。
+5. 完成一次录音识别。
+
+通过标准:
+
+- 下载路径不再写入旧目录作为新安装目标。
+- 下载完成后 engine ready。
+
+### 7.3 下载失败保护
+
+1. 准备完整旧目录 `models/sensevoice`。
+2. 配置错误下载 URL 或断网触发下载失败。
+3. 执行下载。
+4. 确认旧目录未被删除。
+5. 重启应用。
+6. 确认仍可使用旧模型识别。
+
+通过标准:
+
+- 失败不会破坏已有模型。
+- 再次启动可恢复。
+
+### 7.4 离线承诺验证
+
+1. 准备完整模型。
+2. 断网。
+3. 启动应用。
+4. 录音识别。
+5. 检查历史写入。
+6. 观察是否出现异常网络请求或下载重试。
+
+通过标准:
+
+- 已有模型时离线可完整使用。
+- 识别阶段不请求远程服务。
+
+## 8. 证据要求
+
+QA 结论必须记录:
+
+- 分支名 / commit id。
+- App 版本和 build 编号。
+- macOS 版本。
+- 芯片类型。
+- 测试模型目录组合。
+- 执行命令和结果。
+- 自动化测试日志。
+- 手工测试矩阵结果。
+- 关键失败场景截图或日志。
+- 断网测试结果。
+- 网络观察结论。
+
+建议输出:
+
+```text
+02-P-NBL/round-1/qa-report-YYYYMMDD.md
+```
+
+## 9. QA 退出标准
+
+QA 通过必须同时满足:
+
+- P0/P1 问题全部关闭。
+- P2 问题已修复或有明确接受结论。
+- 自动化测试通过。
+- 老用户旧目录升级路径通过。
+- 新用户下载路径通过。
+- 下载失败保护通过。
+- 断网已有模型识别通过。
+- replacement 和历史回归通过。
+- UI 不暴露新模型和语言入口。
+- QA 证据记录完整。
+
+## 10. 缺陷分级
+
+| 级别 | 定义 | 示例 |
+|---|---|---|
+| P0 | 阻塞发布,核心功能不可用或数据损坏 | 老用户升级后模型不可用;下载失败删除旧模型;用户词库丢失 |
+| P1 | 主要路径失败或离线承诺破坏 | 已有模型断网不能识别;不完整模型导致崩溃;Wails 旧 API 断裂 |
+| P2 | 局部功能异常,有绕过方式 | state 修复失败但不影响加载;错误文案不够清晰 |
+| P3 | 轻微问题 | 日志不够明确;测试辅助文件残留但不影响使用 |
diff --git a/02-P-NBL/round-1/todo-list.md b/02-P-NBL/round-1/todo-list.md
new file mode 100644
index 0000000..ccc5767
--- /dev/null
+++ b/02-P-NBL/round-1/todo-list.md
@@ -0,0 +1,133 @@
+# Round 1 总 To Do List
+
+状态:v0.1
+所属子项目:`02-P-NBL`
+Round:Round 1
+日期:2026-06-02
+输入文档:
+
+- [`prd.md`](prd.md)
+- [`engineering-tasks.md`](engineering-tasks.md)
+- [`qa-test-plan.md`](qa-test-plan.md)
+- [`dev-checklist-current.md`](dev-checklist-current.md)
+
+## 1. 当前状态
+
+Round 1 已完成:
+
+- PRD v0.2 已确认。
+- 研发任务拆解已完成。
+- QA 测试计划已完成。
+- 开发自查模板已完成。
+- QA 报告模板已完成。
+- 第一轮核心代码改造已完成。
+- 代码复核发现的问题已处理一轮。
+- QA 预检记录已完成。
+- Go 单元测试已通过。
+- 前端构建已通过。
+
+当前状态:
+
+```text
+可进入 QA 预检;尚不能直接交付用户验收。
+```
+
+## 2. 产品 To Do
+
+| ID | 事项 | 状态 | 负责人 | 说明 |
+|---|---|---|---|---|
+| P-01 | 确认 Round 1 产品边界 | 已完成 | 产品 | 不接新模型,不做新 UI |
+| P-02 | 确认老用户兼容底线 | 已完成 | 产品 | 旧模型目录必须可用 |
+| P-03 | 确认离线承诺底线 | 已完成 | 产品 | 已有模型时识别不得联网 |
+| P-04 | 确认 QA 发现 P0/P1 时阻断交付 | 待确认 | 产品/QA | 进入 QA 后执行 |
+| P-05 | QA 通过后决定是否进入 Round 2 | 待确认 | 产品 | Round 1 验收后再定 |
+
+## 3. 研发 To Do
+
+| ID | 事项 | 状态 | 负责人 | 说明 |
+|---|---|---|---|---|
+| DEV-01 | `ModelProfile` / `ModelRegistry` | 已完成 | 研发 | 已注册 `sensevoice-zh` |
+| DEV-02 | `LanguageProfile` / `LanguageRegistry` 最小实现 | 已完成 | 研发 | 已注册 `zh-CN` |
+| DEV-03 | `RequiredFileRule` 支持 `AllOf` / `AnyOf` | 已完成 | 研发 | 已覆盖单元测试 |
+| DEV-04 | `ModelInstallState` / `models/state.json` | 已完成 | 研发 | 已支持读写 |
+| DEV-05 | `ModelResolver` / `ResolvedModel` | 已完成 | 研发 | 支持 state、新目录、旧目录 fallback |
+| DEV-06 | `SelectedModelID` 隐藏配置 | 已完成 | 研发 | 默认 `sensevoice-zh` |
+| DEV-07 | `EngineFactory` 改造 | 已完成 | 研发 | `engine.New()` 保持兼容 |
+| DEV-08 | 下载器 staging + 校验 + 安全落盘 | 已完成 | 研发 | 新下载进入 `models/sensevoice-zh` |
+| DEV-09 | Wails public API 兼容 | 已完成 | 研发 | 旧 `ModelExists` / `DownloadModel` 签名保留 |
+| DEV-10 | Go 单元测试 | 已完成 | 研发 | `go test ./...` 通过 |
+| DEV-11 | 前端构建验证 | 已完成 | 研发 | `npm run build` 通过,有既有 a11y warning |
+| DEV-12 | primary 坏包时尝试 fallback | 已完成 | 研发 | fallback 覆盖下载、解压、校验全流程 |
+| DEV-13 | state path 限制在 `models` 根目录内 | 已完成 | 研发 | 拒绝绝对路径和 `..` 逃逸 |
+| DEV-14 | macOS provider 使用 profile `ProviderOrder` | 已完成 | 研发 | Round 3/4 减少返工 |
+| DEV-15 | engine 初始化失败状态明确 | 已完成 | 研发 | 结构存在但不可加载时发 `error` |
+| DEV-16 | Onboarding 显示 `need_model + error` | 已完成 | 研发 | 避免坏模型初始化失败时卡住 |
+| DEV-17 | 用户触发下载可安全替换 `sensevoice-zh` | 已完成 | 研发 | 替换失败恢复 backup,不触碰旧目录 |
+| DEV-18 | 增加前端 engine status 补查 | 已完成 | 研发 | 避免 ready/error 事件早于订阅 |
+| DEV-19 | 真实模型下载 smoke | 待做 | 研发/QA | 需要真实网络和模型包 |
+| DEV-20 | 真实模型初始化 smoke | 待做 | 研发/QA | 需要本机真实模型 |
+| DEV-21 | 如 QA 发现问题,修复并回归 | 待做 | 研发 | QA 后执行 |
+
+## 4. QA To Do
+
+| ID | 事项 | 状态 | 负责人 | 说明 |
+|---|---|---|---|---|
+| QA-01 | QA 预检开发自查材料 | 已完成 | QA/研发 | 已形成 `qa-precheck-current.md` |
+| QA-02 | 准备旧目录完整模型场景 | 待做 | QA | `models/sensevoice` |
+| QA-03 | 准备新目录完整模型场景 | 待做 | QA | `models/sensevoice-zh` |
+| QA-04 | 准备 state 非法 JSON 场景 | 待做 | QA | 验证 fallback |
+| QA-05 | 准备 state 指坏目录场景 | 待做 | QA | 验证 fallback 到旧目录 |
+| QA-06 | 准备缺 tokens 场景 | 待做 | QA | 不得初始化 engine |
+| QA-07 | 准备缺模型文件场景 | 待做 | QA | 不得初始化 engine |
+| QA-08 | 准备 0 字节文件场景 | 待做 | QA | 不得判定可用 |
+| QA-09 | 验证老用户旧目录升级路径 | 待做 | QA | 断网也要可用 |
+| QA-10 | 验证新用户无模型下载路径 | 待做 | QA | 新下载到 `sensevoice-zh` |
+| QA-11 | 验证下载失败不破坏旧模型 | 待做 | QA | 失败后旧模型仍可用 |
+| QA-12 | 验证解压失败不写完整 state | 待做 | QA | 需要损坏压缩包 |
+| QA-13 | 验证 Wails API 兼容 | 待做 | QA | 前端启动链路不破 |
+| QA-14 | 验证 UI 不暴露新能力 | 待做 | QA | 不出现 Moonshine/Parakeet/Qwen3/语言选择 |
+| QA-15 | 验证真实录音识别 | 待做 | QA | 真机真实麦克风 |
+| QA-16 | 验证 replacement 生效 | 待做 | QA | 真实识别链路 |
+| QA-17 | 验证历史写入 | 待做 | QA | 真实识别链路 |
+| QA-18 | 验证麦克风权限未授权/已授权 | 待做 | QA | 权限路径回归 |
+| QA-19 | 验证断网已有模型可用 | 待做 | QA | 冷启动、识别、历史写入 |
+| QA-20 | 网络观察确认识别阶段无上传 | 待做 | QA | 离线承诺 |
+| QA-21 | macOS 菜单栏/Dock/窗口层级 smoke | 待做 | QA | 真实使用回归 |
+| QA-22 | 输出 QA 报告 | 待做 | QA | 使用 `qa-report-template.md` |
+
+## 5. 交付 To Do
+
+| ID | 事项 | 状态 | 负责人 | 说明 |
+|---|---|---|---|---|
+| REL-01 | 开发自查记录 | 已完成 | 研发 | `dev-checklist-current.md` |
+| REL-02 | QA 预检记录 | 已完成 | QA/研发 | `qa-precheck-current.md` |
+| REL-03 | QA 报告 | 待做 | QA | QA 完成后输出 |
+| REL-04 | P0/P1 缺陷清零 | 待做 | 研发/QA | 发布前必须完成 |
+| REL-05 | P2 缺陷处理结论 | 待做 | 产品/研发/QA | 修复或明确接受 |
+| REL-06 | 测试包打包 | 待做 | 研发 | QA 通过后按发布规则执行 |
+| REL-07 | 版本号和 build 编号更新 | 待做 | 研发 | 打包前执行 |
+| REL-08 | `CHANGELOG.md` 更新 | 待做 | 研发 | 打包前执行 |
+| REL-09 | 生成 macOS `.dmg` | 待做 | 研发 | QA 通过后执行 |
+| REL-10 | 用户验收 | 待做 | 产品/用户 | QA 通过并交付测试包后执行 |
+
+## 6. 当前阻塞项
+
+目前没有产品决策阻塞。
+
+当前主要阻塞在 QA 阶段:
+
+- 需要真实模型环境。
+- 需要真机麦克风权限验证。
+- 需要断网验证。
+- 需要网络观察验证离线承诺。
+- 需要真实 macOS 使用 smoke。
+
+## 7. 下一步建议
+
+按顺序执行:
+
+1. QA 按 [`qa-test-plan.md`](qa-test-plan.md) 做预检。
+2. QA 记录结果到 `qa-report-YYYYMMDD.md`。
+3. 研发修复 QA 发现的问题。
+4. QA 回归验证。
+5. QA 通过后进入测试包打包流程。
diff --git a/VoiceSnapGo/build/linux/voicesnap.desktop b/VoiceSnapGo/build/linux/voicesnap.desktop
deleted file mode 100755
index f611315..0000000
--- a/VoiceSnapGo/build/linux/voicesnap.desktop
+++ /dev/null
@@ -1,10 +0,0 @@
-[Desktop Entry]
-Type=Application
-Name=VoiceSnap
-Comment=Offline voice-to-text input tool
-Exec=voicesnap
-Icon=voicesnap
-Terminal=false
-Categories=Utility;AudioVideo;
-Keywords=voice;speech;text;input;asr;
-StartupNotify=false
diff --git a/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte b/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte
deleted file mode 100755
index c729936..0000000
--- a/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte
+++ /dev/null
@@ -1,222 +0,0 @@
-<script lang="ts">
-  import { Call } from '@wailsio/runtime'
-  import { t } from '../../lib/i18n'
-
-  let version = $state('2.1.4')
-  let updateStatus = $state('')
-  let updateColor = $state('')
-  let checking = $state(false)
-
-  const currentYear = new Date().getFullYear().toString()
-
-  async function loadVersion() {
-    try {
-      const v: any = await Call.ByName('voicesnap/services.AppService.GetVersion')
-      if (v) version = v
-    } catch {}
-  }
-  loadVersion()
-
-  async function checkUpdate() {
-    checking = true
-    updateStatus = t('about.checkingUpdate')
-    updateColor = ''
-    try {
-      const info: any = await Call.ByName('voicesnap/services.UpdaterService.CheckForUpdate')
-      if (info) {
-        updateStatus = `${t('update.title')}: v${info.Version}`
-        updateColor = 'blue'
-      } else {
-        updateStatus = t('about.isLatestVersion')
-        updateColor = 'green'
-      }
-    } catch {
-      updateStatus = t('about.checkFailed')
-      updateColor = 'red'
-    }
-    checking = false
-  }
-</script>
-
-<div class="about">
-  <div class="center-content">
-    <div class="app-icon">V</div>
-    <h2 class="app-name">VoiceSnap</h2>
-    <p class="app-version">v{version}</p>
-
-    <div class="btn-row">
-      <button class="check-btn" onclick={checkUpdate} disabled={checking}>
-        {t('about.checkUpdate')}
-      </button>
-      <button class="check-btn github-btn" onclick={() => Call.ByName('voicesnap/services.AppService.OpenURL', 'https://github.com/vorojar/VoiceSnap')}>
-        <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
-          <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/>
-        </svg>
-        {t('about.github')}
-      </button>
-    </div>
-
-    {#if updateStatus}
-      <p class="update-status" class:green={updateColor === 'green'} class:red={updateColor === 'red'} class:blue={updateColor === 'blue'}>
-        {updateStatus}
-      </p>
-    {/if}
-
-    <div class="privacy">
-      <div class="privacy-item">
-        <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
-          <path d="M7 1L2.5 3.5V6.5C2.5 9.5 4.5 12 7 13C9.5 12 11.5 9.5 11.5 6.5V3.5L7 1Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
-          <path d="M5 7L6.5 8.5L9 5.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-        </svg>
-        <span>{t('about.privacyOffline')}</span>
-      </div>
-      <div class="privacy-item">
-        <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
-          <rect x="3" y="6.5" width="8" height="6" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
-          <path d="M5 6.5V4.5C5 3.4 5.9 2.5 7 2.5C8.1 2.5 9 3.4 9 4.5V6.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
-        </svg>
-        <span>{t('about.privacyNoAccount')}</span>
-      </div>
-      <div class="privacy-item">
-        <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
-          <path d="M5 4L2 7L5 10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-          <path d="M9 4L12 7L9 10" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-        </svg>
-        <span>{t('about.privacyOpenSource')}</span>
-      </div>
-    </div>
-  </div>
-
-  <div class="bottom">
-    <div class="divider"></div>
-    <p class="copyright">{t('about.copyright', { year: currentYear })}</p>
-  </div>
-</div>
-
-<style>
-  .about {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-    min-height: calc(100vh - 40px);
-    background: var(--color-bg-grouped-secondary);
-    border-radius: var(--radius-md);
-    position: relative;
-  }
-
-  .center-content {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    margin-top: -20px;
-  }
-
-  .app-icon {
-    width: 80px;
-    height: 80px;
-    background: var(--color-blue);
-    border-radius: 18px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    color: white;
-    font-weight: 700;
-    font-size: 32px;
-    box-shadow: 0 4px 16px rgba(0, 122, 255, 0.2);
-  }
-
-  .app-name {
-    font-size: 22px;
-    font-weight: 700;
-    margin-top: 16px;
-  }
-
-  .app-version {
-    font-size: var(--font-size-sm);
-    color: var(--color-secondary-label);
-    margin-top: 4px;
-  }
-
-  .btn-row {
-    display: flex;
-    gap: 10px;
-    margin-top: 20px;
-  }
-
-  .check-btn {
-    padding: 7px 24px;
-    background: transparent;
-    border: 1.5px solid var(--color-separator);
-    border-radius: var(--radius-md);
-    font-size: var(--font-size-sm);
-    font-weight: 500;
-    color: var(--color-label);
-    cursor: pointer;
-    transition: all var(--transition-fast);
-  }
-
-  .check-btn:hover {
-    border-color: var(--color-blue);
-    color: var(--color-blue);
-  }
-
-  .check-btn:disabled {
-    opacity: 0.5;
-    cursor: not-allowed;
-  }
-
-  .github-btn {
-    display: flex;
-    align-items: center;
-    gap: 6px;
-  }
-
-  .update-status {
-    margin-top: 10px;
-    font-size: var(--font-size-sm);
-    color: var(--color-secondary-label);
-  }
-
-  .update-status.green { color: var(--color-green); }
-  .update-status.red { color: var(--color-red); }
-  .update-status.blue { color: var(--color-blue); }
-
-  .privacy {
-    display: flex;
-    flex-direction: column;
-    gap: 8px;
-    margin-top: 32px;
-  }
-
-  .privacy-item {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    color: var(--color-tertiary-label);
-    font-size: var(--font-size-xs);
-  }
-
-  .privacy-item svg {
-    flex-shrink: 0;
-  }
-
-  .bottom {
-    position: absolute;
-    bottom: 20px;
-    left: 30px;
-    right: 30px;
-    text-align: center;
-  }
-
-  .divider {
-    height: 1px;
-    background: var(--color-separator);
-    margin-bottom: 12px;
-  }
-
-  .copyright {
-    font-size: var(--font-size-xs);
-    color: var(--color-tertiary-label);
-  }
-</style>
diff --git a/VoiceSnapGo/frontend/src/components/settings/HistoryPage.svelte b/VoiceSnapGo/frontend/src/components/settings/HistoryPage.svelte
deleted file mode 100755
index de14b80..0000000
--- a/VoiceSnapGo/frontend/src/components/settings/HistoryPage.svelte
+++ /dev/null
@@ -1,447 +0,0 @@
-<script lang="ts">
-  import { Call } from '@wailsio/runtime'
-  import { t } from '../../lib/i18n'
-
-  interface HistoryEntry {
-    text: string
-    timestamp: number
-  }
-
-  let entries = $state<HistoryEntry[]>([])
-  let retentionDays = $state(30)
-  let retentionLoaded = $state(false)
-  let copiedTs = $state<number | null>(null)
-  let showConfirm = $state(false)
-  let showRetention = $state(false)
-
-  const retentionOptions = [
-    { value: 7, label: () => t('history.days7') },
-    { value: 30, label: () => t('history.days30') },
-    { value: 90, label: () => t('history.days90') },
-    { value: 0, label: () => t('history.forever') },
-  ]
-
-  function currentRetentionLabel(): string {
-    const opt = retentionOptions.find(o => o.value === retentionDays)
-    return opt ? opt.label() : t('history.days30')
-  }
-
-  async function loadHistory() {
-    try {
-      const list: any = await Call.ByName('voicesnap/services.HistoryService.GetAll')
-      entries = list || []
-    } catch {
-      entries = []
-    }
-    try {
-      const days: any = await Call.ByName('voicesnap/services.HistoryService.GetRetentionDays')
-      retentionDays = (days !== null && days !== undefined) ? days : 30
-    } catch {}
-    retentionLoaded = true
-  }
-
-  async function setRetention(days: number) {
-    retentionDays = days
-    showRetention = false
-    try {
-      await Call.ByName('voicesnap/services.HistoryService.SetRetentionDays', days)
-      await loadHistory()
-    } catch {}
-  }
-
-  async function copyText(entry: HistoryEntry) {
-    try {
-      await navigator.clipboard.writeText(entry.text)
-      copiedTs = entry.timestamp
-      setTimeout(() => { if (copiedTs === entry.timestamp) copiedTs = null }, 1500)
-    } catch {}
-  }
-
-  async function deleteEntry(timestamp: number) {
-    try {
-      await Call.ByName('voicesnap/services.HistoryService.Delete', timestamp)
-      entries = entries.filter(e => e.timestamp !== timestamp)
-    } catch {}
-  }
-
-  async function clearAll() {
-    showConfirm = false
-    try {
-      await Call.ByName('voicesnap/services.HistoryService.ClearAll')
-      entries = []
-    } catch {}
-  }
-
-  function formatTime(ts: number): string {
-    const date = new Date(ts)
-    const now = new Date()
-    const hours = date.getHours().toString().padStart(2, '0')
-    const mins = date.getMinutes().toString().padStart(2, '0')
-    const time = `${hours}:${mins}`
-
-    const isToday = date.toDateString() === now.toDateString()
-    if (isToday) return time
-
-    const yesterday = new Date(now)
-    yesterday.setDate(yesterday.getDate() - 1)
-    if (date.toDateString() === yesterday.toDateString()) {
-      return `${t('history.yesterday')} ${time}`
-    }
-
-    const month = (date.getMonth() + 1).toString()
-    const day = date.getDate().toString()
-    return `${month}/${day} ${time}`
-  }
-
-  function closeDropdowns(e: MouseEvent) {
-    const target = e.target as HTMLElement
-    if (!target.closest('.retention-selector')) {
-      showRetention = false
-    }
-  }
-
-  // Load on mount
-  loadHistory()
-</script>
-
-<!-- svelte-ignore a11y_click_events_have_key_events -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div class="page" onclick={closeDropdowns}>
-  <!-- Toolbar: retention + clear all -->
-  <div class="toolbar">
-    <div class="toolbar-left">
-      <span class="toolbar-label">{t('history.retention')}</span>
-      <div class="retention-selector">
-        {#if retentionLoaded}
-        <button class="retention-btn" onclick={() => showRetention = !showRetention}>
-          <span>{currentRetentionLabel()}</span>
-          <svg class="chevron" class:open={showRetention} width="10" height="6" viewBox="0 0 10 6" fill="none">
-            <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-          </svg>
-        </button>
-        {#if showRetention}
-          <div class="retention-dropdown">
-            {#each retentionOptions as opt}
-              <button
-                class="retention-option"
-                class:selected={retentionDays === opt.value}
-                onclick={() => setRetention(opt.value)}
-              >
-                {opt.label()}
-                {#if retentionDays === opt.value}
-                  <svg width="12" height="9" viewBox="0 0 12 9" fill="none">
-                    <path d="M1 4L4.5 7.5L11 1" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-                  </svg>
-                {/if}
-              </button>
-            {/each}
-          </div>
-        {/if}
-        {:else}
-        <span class="retention-btn"><span>...</span></span>
-        {/if}
-      </div>
-    </div>
-
-    {#if entries.length > 0}
-      <div class="toolbar-right">
-        {#if showConfirm}
-          <span class="confirm-text">{t('history.clearConfirm')}</span>
-          <button class="clear-btn confirm" onclick={clearAll}>{t('history.clearAll')}</button>
-          <button class="cancel-btn" onclick={() => showConfirm = false}>&times;</button>
-        {:else}
-          <button class="clear-btn" onclick={() => showConfirm = true}>{t('history.clearAll')}</button>
-        {/if}
-      </div>
-    {/if}
-  </div>
-
-  <!-- History list -->
-  <div class="section list-section">
-    {#if entries.length === 0}
-      <div class="empty">
-        <p class="empty-title">{t('history.empty')}</p>
-        <p class="empty-desc">{t('history.emptyDesc')}</p>
-      </div>
-    {:else}
-      {#each entries as entry, i}
-        {#if i > 0}
-          <div class="divider"></div>
-        {/if}
-        <div class="history-row">
-          <div class="history-content">
-            <span class="history-time">{formatTime(entry.timestamp)}</span>
-            <span class="history-text">{entry.text}</span>
-          </div>
-          <div class="history-actions">
-            <button class="action-btn copy-btn" onclick={() => copyText(entry)}>
-              {#if copiedTs === entry.timestamp}
-                <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
-                  <path d="M2 7L5.5 10.5L12 4" stroke="var(--color-green)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-                </svg>
-              {:else}
-                <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
-                  <rect x="5" y="5" width="7.5" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
-                  <path d="M9 5V3C9 2.17 8.33 1.5 7.5 1.5H3C2.17 1.5 1.5 2.17 1.5 3V7.5C1.5 8.33 2.17 9 3 9H5" stroke="currentColor" stroke-width="1.2"/>
-                </svg>
-              {/if}
-            </button>
-            <button class="action-btn delete-btn" onclick={() => deleteEntry(entry.timestamp)}>
-              <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
-                <path d="M3 3.5L11 11.5M11 3.5L3 11.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
-              </svg>
-            </button>
-          </div>
-        </div>
-      {/each}
-    {/if}
-  </div>
-</div>
-
-<style>
-  .page {
-    display: flex;
-    flex-direction: column;
-    height: calc(100vh - 40px);
-  }
-
-  /* Toolbar */
-  .toolbar {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    margin-bottom: var(--spacing-md);
-    padding: 0 2px;
-  }
-
-  .toolbar-left {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-  }
-
-  .toolbar-label {
-    font-size: var(--font-size-sm);
-    color: var(--color-secondary-label);
-  }
-
-  .toolbar-right {
-    display: flex;
-    align-items: center;
-    gap: 6px;
-  }
-
-  /* Retention dropdown */
-  .retention-selector {
-    position: relative;
-  }
-
-  .retention-btn {
-    display: flex;
-    align-items: center;
-    gap: 4px;
-    padding: 3px 0;
-    background: transparent;
-    border: none;
-    font-size: var(--font-size-sm);
-    font-weight: 500;
-    color: var(--color-blue);
-    cursor: pointer;
-    transition: opacity var(--transition-fast);
-  }
-
-  .retention-btn:hover { opacity: 0.65; }
-
-  .chevron {
-    color: var(--color-tertiary-label);
-    transition: transform 0.2s ease;
-    flex-shrink: 0;
-  }
-
-  .chevron.open {
-    transform: rotate(180deg);
-  }
-
-  .retention-dropdown {
-    position: absolute;
-    left: 0;
-    top: calc(100% + 4px);
-    min-width: 120px;
-    background: var(--color-bg-grouped-secondary);
-    border: 1px solid var(--color-separator);
-    border-radius: var(--radius-sm);
-    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
-    z-index: 100;
-    overflow: hidden;
-  }
-
-  .retention-option {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    width: 100%;
-    padding: 7px 12px;
-    background: transparent;
-    border: none;
-    font-size: var(--font-size-sm);
-    color: var(--color-label);
-    cursor: pointer;
-    text-align: left;
-    transition: background 0.15s ease;
-  }
-
-  .retention-option:hover {
-    background: var(--color-bg-secondary);
-  }
-
-  .retention-option.selected {
-    color: var(--color-blue);
-    font-weight: 500;
-  }
-
-  .retention-option + .retention-option {
-    border-top: 1px solid var(--color-separator);
-  }
-
-  /* Clear all */
-  .clear-btn {
-    padding: 3px 0;
-    background: transparent;
-    border: none;
-    font-size: var(--font-size-sm);
-    color: var(--color-tertiary-label);
-    cursor: pointer;
-    transition: color 0.15s ease;
-  }
-
-  .clear-btn:hover {
-    color: var(--color-red);
-  }
-
-  .clear-btn.confirm {
-    color: var(--color-red);
-    font-weight: 500;
-  }
-
-  .confirm-text {
-    font-size: var(--font-size-xs);
-    color: var(--color-secondary-label);
-  }
-
-  .cancel-btn {
-    padding: 0 4px;
-    background: transparent;
-    border: none;
-    font-size: var(--font-size-base);
-    color: var(--color-tertiary-label);
-    cursor: pointer;
-    line-height: 1;
-  }
-
-  .cancel-btn:hover { color: var(--color-label); }
-
-  /* List */
-  .section {
-    background: var(--color-bg-grouped-secondary);
-    border-radius: var(--radius-md);
-    padding: var(--spacing-lg);
-  }
-
-  .list-section {
-    flex: 1;
-    min-height: 0;
-    overflow-y: auto;
-  }
-
-  .empty {
-    text-align: center;
-    padding: 40px 0;
-  }
-
-  .empty-title {
-    font-size: var(--font-size-base);
-    color: var(--color-secondary-label);
-    font-weight: 500;
-  }
-
-  .empty-desc {
-    font-size: var(--font-size-xs);
-    color: var(--color-tertiary-label);
-    margin-top: 6px;
-  }
-
-  .history-row {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    padding: 8px 0;
-    gap: 12px;
-  }
-
-  .history-content {
-    display: flex;
-    align-items: baseline;
-    gap: 10px;
-    min-width: 0;
-    flex: 1;
-  }
-
-  .history-time {
-    font-size: var(--font-size-xs);
-    color: var(--color-tertiary-label);
-    white-space: nowrap;
-    flex-shrink: 0;
-    min-width: 40px;
-  }
-
-  .history-text {
-    font-size: var(--font-size-sm);
-    color: var(--color-label);
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-
-  .history-actions {
-    display: flex;
-    gap: 4px;
-    flex-shrink: 0;
-    opacity: 0;
-    transition: opacity 0.15s ease;
-  }
-
-  .history-row:hover .history-actions {
-    opacity: 1;
-  }
-
-  .action-btn {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    width: 28px;
-    height: 28px;
-    border: none;
-    background: transparent;
-    border-radius: var(--radius-sm);
-    color: var(--color-tertiary-label);
-    cursor: pointer;
-    transition: all 0.15s ease;
-  }
-
-  .action-btn:hover {
-    background: var(--color-bg-secondary);
-  }
-
-  .copy-btn:hover {
-    color: var(--color-blue);
-  }
-
-  .delete-btn:hover {
-    color: var(--color-red);
-  }
-
-  .divider {
-    height: 1px;
-    background: var(--color-separator);
-  }
-</style>
diff --git a/VoiceSnapGo/frontend/src/components/settings/UserDictPage.svelte b/VoiceSnapGo/frontend/src/components/settings/UserDictPage.svelte
deleted file mode 100644
index 594e803..0000000
--- a/VoiceSnapGo/frontend/src/components/settings/UserDictPage.svelte
+++ /dev/null
@@ -1,471 +0,0 @@
-<script lang="ts">
-  import { Call } from '@wailsio/runtime'
-  import { t } from '../../lib/i18n'
-
-  interface DictEntry {
-    id: number
-    from: string
-    to: string
-    enabled: boolean
-    createdAt: number
-    updatedAt: number
-  }
-
-  let entries = $state<DictEntry[]>([])
-  let fromText = $state('')
-  let toText = $state('')
-  let statusText = $state('')
-  let importInput: HTMLInputElement
-  let editingId = $state<number | null>(null)
-  let editFrom = $state('')
-  let editTo = $state('')
-
-  function newestFirst(list: DictEntry[]) {
-    return [...list].sort((a, b) => {
-      const aCreated = a.createdAt || a.id || 0
-      const bCreated = b.createdAt || b.id || 0
-      return bCreated - aCreated
-    })
-  }
-
-  async function loadEntries() {
-    try {
-      const list: any = await Call.ByName('voicesnap/services.UserDictService.GetAll')
-      entries = newestFirst(list || [])
-    } catch {
-      entries = []
-    }
-  }
-
-  function flash(message: string) {
-    statusText = message
-    setTimeout(() => {
-      if (statusText === message) statusText = ''
-    }, 1800)
-  }
-
-  async function addEntry() {
-    if (!fromText.trim()) return
-    try {
-      const entry: any = await Call.ByName('voicesnap/services.UserDictService.Add', fromText, toText)
-      entries = [entry, ...entries]
-      fromText = ''
-      toText = ''
-      flash(t('userdict.saved'))
-    } catch {
-      flash(t('userdict.saveFailed'))
-    }
-  }
-
-  function startEdit(entry: DictEntry) {
-    editingId = entry.id
-    editFrom = entry.from
-    editTo = entry.to
-  }
-
-  function cancelEdit() {
-    editingId = null
-    editFrom = ''
-    editTo = ''
-  }
-
-  async function saveEdit(entry: DictEntry) {
-    if (!editFrom.trim()) return
-    try {
-      const updated: any = await Call.ByName(
-        'voicesnap/services.UserDictService.Update',
-        entry.id,
-        editFrom,
-        editTo,
-        entry.enabled
-      )
-      entries = entries.map(e => e.id === entry.id ? updated : e)
-      cancelEdit()
-      flash(t('userdict.saved'))
-    } catch {
-      flash(t('userdict.saveFailed'))
-    }
-  }
-
-  async function toggleEntry(entry: DictEntry) {
-    try {
-      const updated: any = await Call.ByName(
-        'voicesnap/services.UserDictService.Update',
-        entry.id,
-        entry.from,
-        entry.to,
-        !entry.enabled
-      )
-      entries = entries.map(e => e.id === entry.id ? updated : e)
-    } catch {}
-  }
-
-  async function deleteEntry(id: number) {
-    try {
-      await Call.ByName('voicesnap/services.UserDictService.Delete', id)
-      entries = entries.filter(e => e.id !== id)
-    } catch {}
-  }
-
-  async function exportDict() {
-    try {
-      const path: any = await Call.ByName('voicesnap/services.UserDictService.ExportToFile')
-      if (path) {
-        flash(t('userdict.exportedTo', { path }))
-      }
-    } catch {
-      flash(t('userdict.exportFailed'))
-    }
-  }
-
-  function chooseImportFile() {
-    importInput?.click()
-  }
-
-  async function importDict(e: Event) {
-    const input = e.target as HTMLInputElement
-    const file = input.files?.[0]
-    if (!file) return
-
-    try {
-      const content = await file.text()
-      await Call.ByName('voicesnap/services.UserDictService.ImportJSON', content)
-      await loadEntries()
-      flash(t('userdict.imported'))
-    } catch {
-      flash(t('userdict.importFailed'))
-    } finally {
-      input.value = ''
-    }
-  }
-
-  loadEntries()
-</script>
-
-<div class="page">
-  <div class="header">
-    <h1 class="title">{t('userdict.title')}</h1>
-    <p class="subtitle">{t('userdict.subtitle')}</p>
-  </div>
-
-  <div class="toolbar">
-    <div class="toolbar-actions">
-      <button class="secondary-btn" onclick={chooseImportFile}>{t('userdict.import')}</button>
-      <button class="secondary-btn" onclick={exportDict}>{t('userdict.export')}</button>
-      <input
-        bind:this={importInput}
-        class="hidden-input"
-        type="file"
-        accept="application/json,.json"
-        onchange={importDict}
-      />
-    </div>
-    {#if statusText}
-      <span class="status-text">{statusText}</span>
-    {/if}
-  </div>
-
-  <div class="section add-section">
-    <div class="field">
-      <label for="from">{t('userdict.from')}</label>
-      <input id="from" bind:value={fromText} placeholder={t('userdict.fromPlaceholder')} />
-    </div>
-    <div class="field">
-      <label for="to">{t('userdict.to')}</label>
-      <input id="to" bind:value={toText} placeholder={t('userdict.toPlaceholder')} />
-    </div>
-    <button class="primary-btn" onclick={addEntry} disabled={!fromText.trim()}>
-      {t('userdict.add')}
-    </button>
-  </div>
-
-  <div class="section list-section">
-    {#if entries.length === 0}
-      <div class="empty">
-        <p class="empty-title">{t('userdict.empty')}</p>
-        <p class="empty-desc">{t('userdict.emptyDesc')}</p>
-      </div>
-    {:else}
-      {#each entries as entry, i}
-        {#if i > 0}
-          <div class="divider"></div>
-        {/if}
-        <div class="dict-row" class:disabled={!entry.enabled}>
-          <button
-            class="enable-btn"
-            class:enabled={entry.enabled}
-            title={entry.enabled ? t('userdict.disable') : t('userdict.enable')}
-            onclick={() => toggleEntry(entry)}
-          >
-            {entry.enabled ? '✓' : ''}
-          </button>
-
-          {#if editingId === entry.id}
-            <div class="edit-grid">
-              <input bind:value={editFrom} aria-label={t('userdict.from')} />
-              <input bind:value={editTo} aria-label={t('userdict.to')} />
-            </div>
-            <div class="row-actions">
-              <button class="text-btn" onclick={() => saveEdit(entry)} disabled={!editFrom.trim()}>
-                {t('userdict.save')}
-              </button>
-              <button class="icon-btn" title={t('userdict.cancel')} onclick={cancelEdit}>×</button>
-            </div>
-          {:else}
-            <div class="dict-content">
-              <span class="from-text">{entry.from}</span>
-              <span class="arrow">→</span>
-              <span class="to-text">{entry.to}</span>
-            </div>
-            <div class="row-actions">
-              <button class="text-btn" onclick={() => startEdit(entry)}>{t('userdict.edit')}</button>
-              <button class="icon-btn" title={t('userdict.delete')} onclick={() => deleteEntry(entry.id)}>×</button>
-            </div>
-          {/if}
-        </div>
-      {/each}
-    {/if}
-  </div>
-</div>
-
-<style>
-  .page {
-    display: flex;
-    flex-direction: column;
-    min-height: calc(100vh - 40px);
-  }
-
-  .header {
-    margin-bottom: var(--spacing-md);
-  }
-
-  .title {
-    font-size: 22px;
-    font-weight: 700;
-  }
-
-  .subtitle {
-    font-size: var(--font-size-sm);
-    color: var(--color-secondary-label);
-    margin-top: 6px;
-  }
-
-  .toolbar {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    min-height: 30px;
-    margin-bottom: var(--spacing-md);
-  }
-
-  .toolbar-actions,
-  .row-actions {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-  }
-
-  .status-text {
-    font-size: var(--font-size-sm);
-    color: var(--color-green);
-  }
-
-  .hidden-input {
-    display: none;
-  }
-
-  .section {
-    background: var(--color-bg-grouped-secondary);
-    border-radius: var(--radius-md);
-    padding: var(--spacing-lg);
-    margin-bottom: var(--spacing-md);
-  }
-
-  .add-section {
-    display: grid;
-    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
-    gap: var(--spacing-md);
-    align-items: end;
-  }
-
-  .field {
-    display: flex;
-    flex-direction: column;
-    gap: 6px;
-  }
-
-  label {
-    font-size: var(--font-size-sm);
-    color: var(--color-secondary-label);
-  }
-
-  input {
-    width: 100%;
-    height: 34px;
-    border: 1px solid var(--color-separator);
-    border-radius: var(--radius-sm);
-    padding: 0 10px;
-    font: inherit;
-    color: var(--color-label);
-    background: var(--color-bg-secondary);
-    outline: none;
-  }
-
-  input:focus {
-    border-color: var(--color-blue);
-    background: var(--color-bg-primary);
-  }
-
-  button {
-    font: inherit;
-    cursor: pointer;
-    transition: opacity var(--transition-fast), background var(--transition-fast);
-  }
-
-  button:disabled {
-    cursor: default;
-    opacity: 0.45;
-  }
-
-  .primary-btn,
-  .secondary-btn,
-  .text-btn {
-    border: none;
-    border-radius: var(--radius-sm);
-    font-size: var(--font-size-sm);
-    font-weight: 600;
-  }
-
-  .primary-btn {
-    height: 34px;
-    padding: 0 16px;
-    color: white;
-    background: var(--color-blue);
-  }
-
-  .secondary-btn {
-    height: 28px;
-    padding: 0 12px;
-    color: var(--color-blue);
-    background: rgba(0, 122, 255, 0.08);
-  }
-
-  .text-btn {
-    color: var(--color-blue);
-    background: transparent;
-  }
-
-  .icon-btn,
-  .enable-btn {
-    border: none;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    flex-shrink: 0;
-  }
-
-  .icon-btn {
-    width: 26px;
-    height: 26px;
-    border-radius: 50%;
-    color: var(--color-secondary-label);
-    background: transparent;
-    font-size: 20px;
-    line-height: 1;
-  }
-
-  .icon-btn:hover {
-    background: rgba(0, 0, 0, 0.05);
-    color: var(--color-red);
-  }
-
-  .enable-btn {
-    width: 20px;
-    height: 20px;
-    border-radius: 50%;
-    border: 1px solid var(--color-separator);
-    color: white;
-    background: var(--color-bg-secondary);
-    font-size: 13px;
-    font-weight: 700;
-  }
-
-  .enable-btn.enabled {
-    border-color: var(--color-green);
-    background: var(--color-green);
-  }
-
-  .list-section {
-    flex: 1;
-    overflow: auto;
-  }
-
-  .dict-row {
-    display: grid;
-    grid-template-columns: 20px minmax(0, 1fr) auto;
-    gap: var(--spacing-md);
-    align-items: center;
-    min-height: 48px;
-  }
-
-  .dict-row.disabled .dict-content {
-    opacity: 0.45;
-  }
-
-  .dict-content {
-    display: grid;
-    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
-    gap: 10px;
-    align-items: center;
-    min-width: 0;
-  }
-
-  .from-text,
-  .to-text {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    font-size: var(--font-size-base);
-  }
-
-  .from-text {
-    color: var(--color-label);
-  }
-
-  .to-text {
-    color: var(--color-blue);
-    font-weight: 500;
-  }
-
-  .arrow {
-    color: var(--color-tertiary-label);
-  }
-
-  .edit-grid {
-    display: grid;
-    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
-    gap: var(--spacing-md);
-  }
-
-  .divider {
-    height: 1px;
-    background: var(--color-separator);
-    margin: 6px 0 6px 32px;
-  }
-
-  .empty {
-    text-align: center;
-    padding: var(--spacing-xxl) 0;
-  }
-
-  .empty-title {
-    font-weight: 600;
-    color: var(--color-secondary-label);
-  }
-
-  .empty-desc {
-    font-size: var(--font-size-sm);
-    color: var(--color-tertiary-label);
-    margin-top: 6px;
-  }
-</style>
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/en.json b/VoiceSnapGo/frontend/src/lib/i18n/en.json
deleted file mode 100755
index 5dacfa2..0000000
--- a/VoiceSnapGo/frontend/src/lib/i18n/en.json
+++ /dev/null
@@ -1,173 +0,0 @@
-{
-  "app": {
-    "name": "VoiceSnap"
-  },
-  "nav": {
-    "home": "Home",
-    "userdict": "Dictionary",
-    "permissions": "Permissions",
-    "history": "History",
-    "about": "About"
-  },
-  "status": {
-    "loading": "Loading",
-    "ready": "Ready",
-    "recording": "Recording",
-    "processing": "Processing",
-    "done": "Done",
-    "cancelled": "Cancelled",
-    "noVoice": "No voice detected",
-    "noContent": "No content recognized",
-    "error": "Recognition error",
-    "engineNotReady": "Engine not ready",
-    "initializing": "Initializing..."
-  },
-  "indicator": {
-    "holdToSpeak": "Hold {key} to speak",
-    "recording": "Recording...",
-    "processing": "Processing...",
-    "done": "Typed"
-  },
-  "home": {
-    "tagline": "Snap · Voice to Text",
-    "description": "Hold {key} to speak, release to recognize and type",
-    "hotkeyLabel": "Trigger Hotkey",
-    "modeHint": "Hold to record, release to type · Tap to start free talk, tap again to stop",
-    "modeHintTap": "Tap to start speaking, tap again to stop and type",
-    "modeHintHold": "Hold to speak, release to stop and type"
-  },
-  "settings": {
-    "inputDevice": "Input Device",
-    "hotkeyMode": "Voice Input Mode",
-    "hotkeyModeTap": "Tap",
-    "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",
-    "autoHide": "Auto-hide Indicator",
-    "autoHideDesc": "Automatically hide the floating indicator after completion",
-    "soundFeedback": "Sound Feedback",
-    "soundFeedbackDesc": "Play a beep when recording starts and stops",
-    "copyToClipboard": "Save Recognition to Clipboard",
-    "copyToClipboardDesc": "Keep recognized text in the clipboard after input; off uses it only temporarily",
-    "startAtLogin": "Start at Login",
-    "startAtLoginDesc": "Launch VoiceSnap when you log in",
-    "hideDockIcon": "Hide Dock Icon",
-    "hideDockIconDesc": "Show VoiceSnap only in the menu bar"
-  },
-  "permissions": {
-    "title": "Permission Check",
-    "subtitle": "VoiceSnap needs these permissions for recording, global hotkeys, and automatic text input",
-    "refresh": "Refresh",
-    "grant": "Grant",
-    "granted": "Granted",
-    "missing": "Missing",
-    "enabled": "Enabled",
-    "disabled": "Disabled",
-    "optional": "Optional",
-    "checking": "Checking",
-    "allGranted": "All permissions are ready",
-    "needsAttention": "Some permissions need attention",
-    "summaryDesc": "This turns green automatically once permissions are granted",
-    "microphone": {
-      "title": "Microphone",
-      "desc": "Records your speech before recognition can start."
-    },
-    "accessibility": {
-      "title": "Accessibility",
-      "desc": "Types recognized text into the active field and supports simulated paste/typing."
-    },
-    "inputMonitoring": {
-      "title": "Input Monitoring",
-      "desc": "Listens for the global right Alt hotkey while other apps are in front."
-    },
-    "backgroundActivity": {
-      "title": "Background Activity / Login Item",
-      "desc": "Starts VoiceSnap after login and allows it to run as a menu bar app in the background; it does not affect the currently running voice input."
-    }
-  },
-  "hotkeys": {
-    "title": "Trigger Hotkey",
-    "current": "Current Hotkey",
-    "change": "Change",
-    "pressAnyKey": "Press any key...",
-    "updated": "Updated",
-    "hintTitle": "Two Input Modes",
-    "hintHold": "Hold to Talk",
-    "hintHoldDesc": "Hold the hotkey to record, release to recognize and type. Best for quick phrases.",
-    "hintFree": "Free Talk",
-    "hintFreeDesc": "Tap the hotkey once to start, speak freely, tap again to stop and type. Best for long dictation.",
-    "hintCombo": "Keyboard shortcuts (e.g. Ctrl+C) won't trigger recording."
-  },
-  "about": {
-    "version": "Version",
-    "engineMode": "Engine",
-    "website": "Website",
-    "checkUpdate": "Check for Updates",
-    "checkingUpdate": "Checking...",
-    "isLatestVersion": "Up to date",
-    "checkFailed": "Check failed",
-    "copyright": "© {year} VoiceSnap · MIT License",
-    "privacyOffline": "Fully offline · Voice never leaves your device",
-    "privacyNoAccount": "No account · No internet required",
-    "privacyOpenSource": "Open source · MIT License",
-    "github": "GitHub"
-  },
-  "history": {
-    "title": "Recognition History",
-    "retention": "Keep History",
-    "days7": "7 Days",
-    "days30": "30 Days",
-    "days90": "90 Days",
-    "forever": "Forever",
-    "empty": "No history yet",
-    "emptyDesc": "Recognition results will appear here",
-    "copied": "Copied",
-    "clearAll": "Clear All",
-    "clearConfirm": "Are you sure you want to clear all history?",
-    "today": "Today",
-    "yesterday": "Yesterday"
-  },
-  "userdict": {
-    "title": "User Dictionary",
-    "subtitle": "Replace commonly misrecognized words with the spelling you want to keep",
-    "from": "Recognized As",
-    "to": "Replace With",
-    "fromPlaceholder": "e.g. voice snap",
-    "toPlaceholder": "e.g. VoiceSnap",
-    "add": "Add",
-    "edit": "Edit",
-    "save": "Save",
-    "cancel": "Cancel",
-    "delete": "Delete",
-    "enable": "Enable",
-    "disable": "Disable",
-    "import": "Import",
-    "export": "Export",
-    "imported": "Imported",
-    "exported": "Exported",
-    "exportedTo": "Exported to {path}",
-    "saved": "Saved",
-    "saveFailed": "Save failed",
-    "importFailed": "Import failed",
-    "exportFailed": "Export failed",
-    "empty": "No words yet",
-    "emptyDesc": "Add names, brands, or domain terms and recognition output will be replaced automatically"
-  },
-  "onboarding": {
-    "title": "First Time Setup",
-    "syncModel": "Downloading voice model...",
-    "optimizing": "Optimizing...",
-    "extracting": "Extracting model files...",
-    "complete": "Complete",
-    "failed": "Setup failed"
-  },
-  "update": {
-    "title": "Update Available",
-    "version": "Version {version}",
-    "releaseNotes": "Release Notes",
-    "download": "Update Now",
-    "later": "Later",
-    "downloading": "Downloading...",
-    "applying": "Applying update..."
-  }
-}
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/zh.json b/VoiceSnapGo/frontend/src/lib/i18n/zh.json
deleted file mode 100755
index 38041b8..0000000
--- a/VoiceSnapGo/frontend/src/lib/i18n/zh.json
+++ /dev/null
@@ -1,173 +0,0 @@
-{
-  "app": {
-    "name": "VoiceSnap 语闪"
-  },
-  "nav": {
-    "home": "首页",
-    "userdict": "词库",
-    "permissions": "权限",
-    "history": "历史",
-    "about": "关于"
-  },
-  "status": {
-    "loading": "加载中",
-    "ready": "就绪",
-    "recording": "录音中",
-    "processing": "识别中",
-    "done": "完成",
-    "cancelled": "已取消",
-    "noVoice": "未检测到语音",
-    "noContent": "无识别结果",
-    "error": "识别出错",
-    "engineNotReady": "引擎未就绪",
-    "initializing": "初始化中..."
-  },
-  "indicator": {
-    "holdToSpeak": "按住{key}说话",
-    "recording": "正在录音...",
-    "processing": "识别中...",
-    "done": "已输入"
-  },
-  "home": {
-    "tagline": "Snap · 语音即输入",
-    "description": "按住 {key} 说话,松开即识别并输入文字",
-    "hotkeyLabel": "触发热键",
-    "modeHint": "长按录音,松开输入 · 短按开始自由说话,再按结束",
-    "modeHintTap": "点按开始说话,再点按结束并输入",
-    "modeHintHold": "长按开始说话,松开结束并输入"
-  },
-  "settings": {
-    "inputDevice": "输入设备",
-    "hotkeyMode": "语音输入方式",
-    "hotkeyModeTap": "点按",
-    "hotkeyModeHold": "长按",
-    "hotkeyModeTapDesc": "点一下快捷键开始说话,再点一下结束并输入",
-    "hotkeyModeHoldDesc": "按住快捷键开始说话,松开后结束并输入",
-    "autoHide": "自动隐藏指示器",
-    "autoHideDesc": "操作完成后自动隐藏浮动指示器",
-    "soundFeedback": "提示音",
-    "soundFeedbackDesc": "录音开始和结束时播放提示音",
-    "copyToClipboard": "识别内容保存到剪贴板",
-    "copyToClipboardDesc": "输入文字后仍保留在剪贴板,关闭后仅临时用于上屏",
-    "startAtLogin": "开机自启动",
-    "startAtLoginDesc": "登录系统时自动启动 VoiceSnap",
-    "hideDockIcon": "隐藏 Dock 图标",
-    "hideDockIconDesc": "只在菜单栏显示 VoiceSnap 图标"
-  },
-  "permissions": {
-    "title": "权限检查",
-    "subtitle": "VoiceSnap 需要这些权限才能稳定完成录音、全局热键和自动上屏",
-    "refresh": "刷新",
-    "grant": "去授权",
-    "granted": "已授权",
-    "missing": "未授权",
-    "enabled": "已启用",
-    "disabled": "未启用",
-    "optional": "可选",
-    "checking": "检查中",
-    "allGranted": "所有权限已就绪",
-    "needsAttention": "有权限需要处理",
-    "summaryDesc": "授权完成后这里会自动变成绿灯",
-    "microphone": {
-      "title": "麦克风",
-      "desc": "用于录制你的语音,这是开始识别前必须拿到的权限。"
-    },
-    "accessibility": {
-      "title": "辅助功能",
-      "desc": "用于把识别结果自动输入到当前文本框,也用于模拟粘贴和键盘输入。"
-    },
-    "inputMonitoring": {
-      "title": "输入监控",
-      "desc": "用于在 VoiceSnap 窗口不可见、其他 App 正在前台时监听右 Alt 全局热键。"
-    },
-    "backgroundActivity": {
-      "title": "后台活动 / 登录项",
-      "desc": "用于登录系统后自动启动 VoiceSnap,并允许它作为菜单栏应用在后台运行;不影响当前已经打开的语音输入。"
-    }
-  },
-  "hotkeys": {
-    "title": "触发热键",
-    "current": "当前热键",
-    "change": "修改",
-    "pressAnyKey": "请按任意键...",
-    "updated": "已更新",
-    "hintTitle": "两种输入模式",
-    "hintHold": "按住说话",
-    "hintHoldDesc": "长按热键开始录音,松开即停止识别并输入文字,适合快速短语。",
-    "hintFree": "自由说话",
-    "hintFreeDesc": "短按一下热键开始,连续说话,再短按一下停止并输入文字,适合长段口述。",
-    "hintCombo": "使用组合键(如 Ctrl+C)不会触发录音。"
-  },
-  "about": {
-    "version": "版本",
-    "engineMode": "推理引擎",
-    "website": "官方网站",
-    "checkUpdate": "检查更新",
-    "checkingUpdate": "正在检查...",
-    "isLatestVersion": "已是最新版本",
-    "checkFailed": "检查失败",
-    "copyright": "© {year} VoiceSnap · MIT License",
-    "privacyOffline": "完全离线 · 语音数据不离开设备",
-    "privacyNoAccount": "无需注册 · 无需联网",
-    "privacyOpenSource": "开源代码 · MIT 协议",
-    "github": "GitHub"
-  },
-  "history": {
-    "title": "识别历史",
-    "retention": "保留时长",
-    "days7": "7 天",
-    "days30": "30 天",
-    "days90": "90 天",
-    "forever": "永久",
-    "empty": "暂无历史记录",
-    "emptyDesc": "识别结果会自动保存在这里",
-    "copied": "已复制",
-    "clearAll": "清除所有",
-    "clearConfirm": "确定要清除所有历史记录吗?",
-    "today": "今天",
-    "yesterday": "昨天"
-  },
-  "userdict": {
-    "title": "用户词库",
-    "subtitle": "把常被识别错的词替换成你想保留的写法",
-    "from": "识别结果",
-    "to": "替换为",
-    "fromPlaceholder": "例如:维斯耐普",
-    "toPlaceholder": "例如:VoiceSnap",
-    "add": "添加",
-    "edit": "编辑",
-    "save": "保存",
-    "cancel": "取消",
-    "delete": "删除",
-    "enable": "启用",
-    "disable": "停用",
-    "import": "导入",
-    "export": "导出",
-    "imported": "已导入",
-    "exported": "已导出",
-    "exportedTo": "已导出到 {path}",
-    "saved": "已保存",
-    "saveFailed": "保存失败",
-    "importFailed": "导入失败",
-    "exportFailed": "导出失败",
-    "empty": "暂无词语",
-    "emptyDesc": "添加人名、品牌名或常用专有词后,识别结果会自动替换"
-  },
-  "onboarding": {
-    "title": "首次设置",
-    "syncModel": "正在同步语音模型...",
-    "optimizing": "正在优化...",
-    "extracting": "正在解压模型文件...",
-    "complete": "完成",
-    "failed": "设置失败"
-  },
-  "update": {
-    "title": "发现新版本",
-    "version": "版本 {version}",
-    "releaseNotes": "更新日志",
-    "download": "立即更新",
-    "later": "稍后",
-    "downloading": "正在下载...",
-    "applying": "正在应用更新..."
-  }
-}
diff --git a/VoiceSnapGo/internal/engine/engine.go b/VoiceSnapGo/internal/engine/engine.go
deleted file mode 100755
index 7a075f1..0000000
--- a/VoiceSnapGo/internal/engine/engine.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package engine
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"voicesnap/internal/logger"
-	"voicesnap/internal/paths"
-)
-
-// Engine is the interface for ASR engines.
-type Engine interface {
-	// Recognize takes float32 PCM samples (16kHz mono) and returns the recognized text.
-	Recognize(samples []float32) (string, error)
-	// HardwareInfo returns a human-readable description of the hardware backend being used.
-	HardwareInfo() string
-	// Close releases engine resources.
-	Close()
-}
-
-// ModelDir returns the path to the sensevoice model directory.
-func ModelDir() string {
-	return paths.ModelDir()
-}
-
-// ModelPath returns the path to the ONNX model file (prefers int8).
-func ModelPath() string {
-	dir := ModelDir()
-	int8Path := filepath.Join(dir, "model.int8.onnx")
-	if _, err := os.Stat(int8Path); err == nil {
-		return int8Path
-	}
-	return filepath.Join(dir, "model.onnx")
-}
-
-// TokensPath returns the path to the tokens.txt file.
-func TokensPath() string {
-	return filepath.Join(ModelDir(), "tokens.txt")
-}
-
-// ModelExists checks if both the model and tokens files exist.
-func ModelExists() bool {
-	if _, err := os.Stat(ModelPath()); err != nil {
-		return false
-	}
-	if _, err := os.Stat(TokensPath()); err != nil {
-		return false
-	}
-	return true
-}
-
-// New creates a new platform-specific ASR engine.
-// Returns an error if the model files are not found.
-func New() (Engine, error) {
-	if !ModelExists() {
-		return nil, fmt.Errorf("model files not found in %s", ModelDir())
-	}
-
-	logger.Info("Loading model from %s", ModelPath())
-	return newPlatformEngine()
-}
diff --git a/VoiceSnapGo/internal/history/history.go b/VoiceSnapGo/internal/history/history.go
deleted file mode 100755
index ec32973..0000000
--- a/VoiceSnapGo/internal/history/history.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package history
-
-import (
-	"encoding/json"
-	"os"
-	"sync"
-	"time"
-	"unicode/utf8"
-	"voicesnap/internal/logger"
-	"voicesnap/internal/paths"
-)
-
-const maxEntries = 50
-
-// Entry represents a single recognition history item.
-type Entry struct {
-	Text      string `json:"text"`
-	Timestamp int64  `json:"timestamp"` // Unix milliseconds
-}
-
-// Store manages recognition history with JSON file persistence.
-type Store struct {
-	mu            sync.Mutex
-	entries       []Entry
-	retentionDays int
-	path          string
-}
-
-type fileData struct {
-	RetentionDays *int    `json:"retentionDays"`
-	Entries       []Entry `json:"entries"`
-}
-
-// New creates a new history store, loading existing data from disk.
-func New() *Store {
-	s := &Store{
-		retentionDays: 30,
-		path:          historyPath(),
-	}
-	s.load()
-	s.prune()
-	return s
-}
-
-// Add inserts a new entry at the top and persists to disk.
-// Single-character results (e.g. "." "。") are noise and skipped.
-func (s *Store) Add(text string) {
-	if utf8.RuneCountInString(text) <= 1 {
-		return
-	}
-
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	entry := Entry{
-		Text:      text,
-		Timestamp: time.Now().UnixMilli(),
-	}
-
-	// Prepend
-	s.entries = append([]Entry{entry}, s.entries...)
-
-	// Cap at max
-	if len(s.entries) > maxEntries {
-		s.entries = s.entries[:maxEntries]
-	}
-
-	s.save()
-}
-
-// GetAll returns all history entries (newest first).
-func (s *Store) GetAll() []Entry {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	result := make([]Entry, len(s.entries))
-	copy(result, s.entries)
-	return result
-}
-
-// Delete removes an entry by its timestamp.
-func (s *Store) Delete(timestamp int64) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	for i, e := range s.entries {
-		if e.Timestamp == timestamp {
-			s.entries = append(s.entries[:i], s.entries[i+1:]...)
-			s.save()
-			return
-		}
-	}
-}
-
-// ClearAll removes all entries.
-func (s *Store) ClearAll() {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	s.entries = nil
-	s.save()
-}
-
-// GetRetentionDays returns the current retention period.
-func (s *Store) GetRetentionDays() int {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	return s.retentionDays
-}
-
-// SetRetentionDays sets the retention period and prunes old entries.
-func (s *Store) SetRetentionDays(days int) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	s.retentionDays = days
-	s.pruneUnlocked()
-	s.save()
-}
-
-// prune removes entries older than the retention period.
-func (s *Store) prune() {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	s.pruneUnlocked()
-}
-
-func (s *Store) pruneUnlocked() {
-	if s.retentionDays <= 0 {
-		return // 0 = keep forever
-	}
-
-	cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli()
-	kept := s.entries[:0]
-	for _, e := range s.entries {
-		if e.Timestamp >= cutoff {
-			kept = append(kept, e)
-		}
-	}
-	s.entries = kept
-}
-
-func (s *Store) load() {
-	data, err := os.ReadFile(s.path)
-	if err != nil {
-		return
-	}
-
-	var fd fileData
-	if err := json.Unmarshal(data, &fd); err != nil {
-		logger.Error("Failed to parse history: %v", err)
-		return
-	}
-
-	s.entries = fd.Entries
-	if fd.RetentionDays != nil {
-		s.retentionDays = *fd.RetentionDays
-	}
-}
-
-func (s *Store) save() {
-	if err := paths.Ensure(); err != nil {
-		logger.Error("Failed to create app data dir: %v", err)
-		return
-	}
-
-	days := s.retentionDays
-	fd := fileData{
-		RetentionDays: &days,
-		Entries:       s.entries,
-	}
-
-	data, err := json.MarshalIndent(fd, "", "  ")
-	if err != nil {
-		logger.Error("Failed to marshal history: %v", err)
-		return
-	}
-
-	if err := os.WriteFile(s.path, data, 0644); err != nil {
-		logger.Error("Failed to save history: %v", err)
-	}
-}
-
-func historyPath() string {
-	return paths.File("history.json")
-}
diff --git a/VoiceSnapGo/internal/model/downloader.go b/VoiceSnapGo/internal/model/downloader.go
deleted file mode 100755
index e33ce37..0000000
--- a/VoiceSnapGo/internal/model/downloader.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package model
-
-import (
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-
-	"voicesnap/internal/logger"
-)
-
-// ProgressCallback is called with download progress (percent, downloaded bytes, total bytes).
-type ProgressCallback func(percent float64, downloaded, total int64)
-
-// Download downloads and extracts the ASR model.
-// It tries the primary URL first, then falls back to the fallback URL.
-func Download(primaryURL, fallbackURL, modelsDir string, progress ProgressCallback) error {
-	if err := os.MkdirAll(modelsDir, 0755); err != nil {
-		return fmt.Errorf("failed to create models dir: %w", err)
-	}
-
-	tempFile := filepath.Join(modelsDir, "model_package.tar.bz2")
-
-	// Try primary URL first
-	logger.Info("Downloading model from primary URL: %s", primaryURL)
-	err := downloadFile(primaryURL, tempFile, progress)
-	if err != nil {
-		logger.Info("Primary URL failed: %v, trying fallback", err)
-		err = downloadFile(fallbackURL, tempFile, progress)
-		if err != nil {
-			return fmt.Errorf("download failed from both URLs: %w", err)
-		}
-	}
-
-	// Extract
-	logger.Info("Extracting model archive...")
-	if err := extractModel(tempFile, modelsDir); err != nil {
-		return fmt.Errorf("extraction failed: %w", err)
-	}
-
-	// Cleanup temp file
-	os.Remove(tempFile)
-
-	logger.Info("Model download and extraction complete")
-	return nil
-}
-
-func downloadFile(url, destPath string, progress ProgressCallback) error {
-	resp, err := http.Get(url)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
-	}
-
-	totalBytes := resp.ContentLength
-	out, err := os.Create(destPath)
-	if err != nil {
-		return err
-	}
-	defer out.Close()
-
-	buf := make([]byte, 32*1024)
-	var downloaded int64
-	lastPercent := -1.0
-
-	for {
-		n, readErr := resp.Body.Read(buf)
-		if n > 0 {
-			if _, writeErr := out.Write(buf[:n]); writeErr != nil {
-				return writeErr
-			}
-			downloaded += int64(n)
-
-			if totalBytes > 0 && progress != nil {
-				pct := float64(downloaded) / float64(totalBytes) * 100
-				if pct-lastPercent >= 0.5 {
-					lastPercent = pct
-					progress(pct, downloaded, totalBytes)
-				}
-			}
-		}
-		if readErr != nil {
-			if readErr == io.EOF {
-				break
-			}
-			return readErr
-		}
-	}
-
-	return nil
-}
-
-func extractModel(archivePath, destDir string) error {
-	// Use system tar to extract
-	cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir)
-	output, err := cmd.CombinedOutput()
-	if err != nil {
-		return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output))
-	}
-
-	// Rename extracted directory to "sensevoice"
-	entries, err := os.ReadDir(destDir)
-	if err != nil {
-		return err
-	}
-
-	for _, entry := range entries {
-		if entry.IsDir() && strings.HasPrefix(entry.Name(), "sherpa-onnx-sense-voice") {
-			targetDir := filepath.Join(destDir, "sensevoice")
-			srcDir := filepath.Join(destDir, entry.Name())
-
-			// Remove existing target
-			os.RemoveAll(targetDir)
-
-			if err := os.Rename(srcDir, targetDir); err != nil {
-				return fmt.Errorf("failed to rename model dir: %w", err)
-			}
-			break
-		}
-	}
-
-	return nil
-}
diff --git a/VoiceSnapGo/internal/overlay/overlay.go b/VoiceSnapGo/internal/overlay/overlay.go
deleted file mode 100755
index 8587b5c..0000000
--- a/VoiceSnapGo/internal/overlay/overlay.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package overlay
-
-// Status represents the indicator display state.
-type Status string
-
-const (
-	StatusHidden     Status = "hidden"
-	StatusLoading    Status = "loading"
-	StatusReady      Status = "ready"
-	StatusRecording    Status = "recording"
-	StatusFreetalking  Status = "freetalking"
-	StatusProcessing Status = "processing"
-	StatusDone       Status = "done"
-	StatusCancelled  Status = "cancelled"
-	StatusNoVoice    Status = "no_voice"
-	StatusNoContent  Status = "no_content"
-	StatusError      Status = "error"
-)
-
-// Overlay is a native floating indicator window with per-pixel alpha.
-type Overlay interface {
-	Show()
-	Hide()
-	SetStatus(status Status, text string)
-	SetVolume(vol float64)
-	SetPosition(x, y int)
-	GetPosition() (int, int)
-	OnDragged(fn func(x, y int))
-	Close()
-}
diff --git a/VoiceSnapGo/internal/overlay/overlay_darwin.go b/VoiceSnapGo/internal/overlay/overlay_darwin.go
deleted file mode 100755
index 423cb72..0000000
--- a/VoiceSnapGo/internal/overlay/overlay_darwin.go
+++ /dev/null
@@ -1,533 +0,0 @@
-//go:build darwin
-
-package overlay
-
-/*
-#cgo CFLAGS: -x objective-c
-#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices
-#import <Cocoa/Cocoa.h>
-#import <ApplicationServices/ApplicationServices.h>
-#include <dispatch/dispatch.h>
-#include <string.h>
-
-// ---- Constants (match Windows overlay_windows.go) ----
-#define CAP_W  170.0f
-#define CAP_H  48.0f
-#define CAP_R  24.0f
-#define BAR_W  5.0f
-#define BAR_GAP 4.0f
-#define BAR_R  2.5f
-#define N_BARS 5
-#define TXT_SZ 12.0f
-#define TXT_GAP 12.0f
-
-// ---- Shared state (written by Go, read on main thread) ----
-typedef struct {
-	float barHeights[N_BARS];
-	float barR, barG, barB;
-	char  text[128];
-	int   status;
-	float volumes[N_BARS];
-	float animTime;
-	float fadeProgress;
-	float fadeTarget;
-	int   posX, posY;
-	int   needsPosition;
-	int   hasPosition;
-	int   dragEnded;
-	int   dragX, dragY;
-} OverlayState;
-
-static OverlayState g_state = {
-	.barHeights = {14, 14, 14, 14, 14},
-	.barR = 0, .barG = 0.478f, .barB = 1.0f,
-};
-
-// ---- Custom NSView for CG drawing ----
-@interface VoiceSnapOverlayView : NSView
-@end
-
-@implementation VoiceSnapOverlayView
-
-- (void)drawRect:(NSRect)dirtyRect {
-	CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext];
-	float w = self.bounds.size.width;
-	float h = self.bounds.size.height;
-
-	CGContextClearRect(ctx, self.bounds);
-
-	// Capsule background (semi-transparent dark)
-	CGRect capsuleRect = CGRectMake(0, 0, w, h);
-	CGPathRef capsulePath = CGPathCreateWithRoundedRect(capsuleRect, CAP_R, CAP_R, NULL);
-	CGContextAddPath(ctx, capsulePath);
-	CGContextSetRGBFillColor(ctx, 28.0f/255, 28.0f/255, 30.0f/255, 0.85f);
-	CGContextFillPath(ctx);
-
-	// Inner border
-	CGRect borderRect = CGRectInset(capsuleRect, 0.5f, 0.5f);
-	CGPathRef borderPath = CGPathCreateWithRoundedRect(borderRect, CAP_R - 0.5f, CAP_R - 0.5f, NULL);
-	CGContextAddPath(ctx, borderPath);
-	CGContextSetRGBStrokeColor(ctx, 1, 1, 1, 32.0f/255);
-	CGContextSetLineWidth(ctx, 1);
-	CGContextStrokePath(ctx);
-	CGPathRelease(capsulePath);
-	CGPathRelease(borderPath);
-
-	// Measure text
-	float textW = 0;
-	NSString* text = nil;
-	NSDictionary* textAttrs = nil;
-	if (g_state.text[0] != '\0') {
-		text = [NSString stringWithUTF8String:g_state.text];
-		textAttrs = @{
-			NSFontAttributeName: [NSFont systemFontOfSize:TXT_SZ weight:NSFontWeightRegular],
-			NSForegroundColorAttributeName: [NSColor colorWithRed:180.0f/255 green:180.0f/255 blue:180.0f/255 alpha:1.0f]
-		};
-		textW = [text sizeWithAttributes:textAttrs].width;
-	}
-
-	// Layout: bars + text, centered
-	float barsW = N_BARS * BAR_W + (N_BARS - 1) * BAR_GAP;
-	float totalW = barsW;
-	if (textW > 0) totalW += TXT_GAP + textW;
-	float startX = (w - totalW) / 2;
-
-	// Draw bars
-	for (int i = 0; i < N_BARS; i++) {
-		float bx = startX + i * (BAR_W + BAR_GAP);
-		float bh = g_state.barHeights[i];
-		if (bh < 6) bh = 6;
-		if (bh > 40) bh = 40;
-		float by = (h - bh) / 2;
-
-		CGRect barRect = CGRectMake(bx, by, BAR_W, bh);
-		CGPathRef barPath = CGPathCreateWithRoundedRect(barRect, BAR_R, BAR_R, NULL);
-		CGContextAddPath(ctx, barPath);
-		CGContextSetRGBFillColor(ctx, g_state.barR, g_state.barG, g_state.barB, 1.0f);
-		CGContextFillPath(ctx);
-		CGPathRelease(barPath);
-	}
-
-	// Draw text
-	if (text && textAttrs) {
-		float tx = startX + barsW + TXT_GAP;
-		NSSize sz = [text sizeWithAttributes:textAttrs];
-		float ty = (h - sz.height) / 2;
-		[text drawAtPoint:NSMakePoint(tx, ty) withAttributes:textAttrs];
-	}
-}
-
-@end
-
-// ---- Custom NSPanel for drag tracking ----
-@interface VoiceSnapOverlayWindow : NSPanel {
-	NSPoint _dragStartOrigin;
-}
-@end
-
-@implementation VoiceSnapOverlayWindow
-
-- (void)sendEvent:(NSEvent *)event {
-	if (event.type == NSEventTypeLeftMouseDown) {
-		_dragStartOrigin = self.frame.origin;
-	}
-	[super sendEvent:event];
-	if (event.type == NSEventTypeLeftMouseUp) {
-		NSPoint cur = self.frame.origin;
-		if (!NSEqualPoints(_dragStartOrigin, cur)) {
-			NSScreen* screen = [NSScreen mainScreen];
-			if (screen) {
-				float screenH = screen.frame.size.height;
-				g_state.dragX = (int)cur.x;
-				g_state.dragY = (int)(screenH - cur.y - CAP_H);
-				g_state.posX = g_state.dragX;
-				g_state.posY = g_state.dragY;
-				g_state.dragEnded = 1;
-			}
-		}
-	}
-}
-
-- (BOOL)canBecomeKeyWindow { return NO; }
-- (BOOL)canBecomeMainWindow { return NO; }
-
-@end
-
-// ---- Timer target ----
-static NSWindow* g_window = nil;
-static VoiceSnapOverlayView* g_view = nil;
-static NSTimer* g_timer = nil;
-
-static void applyOverlayWindowPolicy(NSWindow* window) {
-	if (!window) return;
-	[window setLevel:CGWindowLevelForKey(kCGStatusWindowLevelKey)];
-	[window setCollectionBehavior:
-		NSWindowCollectionBehaviorCanJoinAllSpaces |
-		NSWindowCollectionBehaviorStationary |
-		NSWindowCollectionBehaviorFullScreenAuxiliary |
-		NSWindowCollectionBehaviorTransient |
-		NSWindowCollectionBehaviorIgnoresCycle];
-	[window setCanHide:NO];
-	if ([window isKindOfClass:[NSPanel class]]) {
-		[(NSPanel*)window setHidesOnDeactivate:NO];
-	}
-}
-
-static void animateBars(void) {
-	g_state.animTime += 0.033f;
-	float t = g_state.animTime;
-
-	switch (g_state.status) {
-	case 1: // loading
-		for (int i = 0; i < N_BARS; i++) {
-			float pulse = sinf(t * 2 + i * 0.3f) * 0.5f + 0.5f;
-			g_state.barHeights[i] = 10 + pulse * 20;
-		}
-		break;
-	case 2: // ready
-	case 6: // done
-		for (int i = 0; i < N_BARS; i++)
-			g_state.barHeights[i] = g_state.barHeights[i] * 0.85f + 14 * 0.15f;
-		break;
-	case 3: // recording
-	case 4: { // freetalking
-		int hasReal = 0;
-		for (int i = 0; i < N_BARS; i++)
-			if (g_state.volumes[i] > 0.01f) { hasReal = 1; break; }
-		for (int i = 0; i < N_BARS; i++) {
-			float target;
-			if (hasReal) {
-				target = 8 + g_state.volumes[i] * 32;
-			} else {
-				float wave = sinf(t * 4 + i * 0.8f) * 0.5f + 0.5f;
-				float rnd = sinf(t * 7 + i * 1.5f) * 0.3f;
-				target = 10 + (wave + rnd) * 25;
-			}
-			g_state.barHeights[i] = g_state.barHeights[i] * 0.6f + target * 0.4f;
-		}
-		break;
-	}
-	case 5: // processing
-		for (int i = 0; i < N_BARS; i++) {
-			float wave = sinf(t * 3 + i * 0.6f) * 0.5f + 0.5f;
-			g_state.barHeights[i] = 12 + wave * 18;
-		}
-		break;
-	default:
-		for (int i = 0; i < N_BARS; i++)
-			g_state.barHeights[i] = g_state.barHeights[i] * 0.85f + 14 * 0.15f;
-		break;
-	}
-}
-
-@interface OverlayTimerTarget : NSObject
-- (void)tick:(NSTimer*)timer;
-@end
-
-@implementation OverlayTimerTarget
-
-- (void)tick:(NSTimer*)timer {
-	if (!g_window) return;
-
-	// Fade
-	if (g_state.fadeProgress < g_state.fadeTarget) {
-		g_state.fadeProgress += 0.16f;
-		if (g_state.fadeProgress > 1.0f) g_state.fadeProgress = 1.0f;
-	} else if (g_state.fadeProgress > g_state.fadeTarget) {
-		g_state.fadeProgress -= 0.22f;
-		if (g_state.fadeProgress < 0) g_state.fadeProgress = 0;
-	}
-
-	float eased = g_state.fadeProgress * (2 - g_state.fadeProgress);
-	[g_window setAlphaValue:eased];
-
-	if (g_state.fadeProgress <= 0 && g_state.fadeTarget == 0) {
-		[g_window orderOut:nil];
-		return;
-	}
-
-	if (g_state.fadeTarget > 0 && ![g_window isVisible]) {
-		applyOverlayWindowPolicy(g_window);
-		[g_window orderFrontRegardless];
-	}
-
-	// Reposition
-	if (g_state.needsPosition) {
-		g_state.needsPosition = 0;
-		NSScreen* screen = [NSScreen mainScreen];
-		if (screen) {
-			float screenH = screen.frame.size.height;
-			float macY = screenH - g_state.posY - CAP_H;
-			[g_window setFrameOrigin:NSMakePoint(g_state.posX, macY)];
-		}
-	}
-
-	animateBars();
-	[g_view setNeedsDisplay:YES];
-}
-
-@end
-
-static OverlayTimerTarget* g_timerTarget = nil;
-
-// ---- C API for Go ----
-
-static void doCreateOverlay(void* ctx) {
-	(void)ctx;
-
-	VoiceSnapOverlayWindow* window = [[VoiceSnapOverlayWindow alloc]
-		initWithContentRect:NSMakeRect(0, 0, CAP_W, CAP_H)
-		styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
-		backing:NSBackingStoreBuffered
-		defer:NO];
-
-	[window setOpaque:NO];
-	[window setBackgroundColor:[NSColor clearColor]];
-	applyOverlayWindowPolicy(window);
-	[window setIgnoresMouseEvents:NO];
-	[window setMovableByWindowBackground:YES];
-	[window setHasShadow:NO];
-	[window setAlphaValue:0];
-
-	VoiceSnapOverlayView* view = [[VoiceSnapOverlayView alloc]
-		initWithFrame:NSMakeRect(0, 0, CAP_W, CAP_H)];
-	[window setContentView:view];
-
-	g_window = window;
-	g_view = view;
-
-	g_timerTarget = [[OverlayTimerTarget alloc] init];
-	g_timer = [NSTimer timerWithTimeInterval:0.033
-		target:g_timerTarget
-		selector:@selector(tick:)
-		userInfo:nil
-		repeats:YES];
-	[[NSRunLoop currentRunLoop] addTimer:g_timer forMode:NSRunLoopCommonModes];
-}
-
-static void overlayCreate(void) {
-	dispatch_async_f(dispatch_get_main_queue(), NULL, doCreateOverlay);
-}
-
-static void doAutoPosition(void* ctx) {
-	(void)ctx;
-	if (!g_window) return;
-	NSScreen* screen = [NSScreen mainScreen];
-	if (!screen) return;
-	NSRect wa = [screen visibleFrame];
-	int cx = (int)(wa.origin.x + (wa.size.width - CAP_W) / 2);
-	int cy = (int)(wa.origin.y + 100);
-	[g_window setFrameOrigin:NSMakePoint(cx, cy)];
-	g_state.hasPosition = 1;
-}
-
-static void doBringOverlayForward(void* ctx) {
-	(void)ctx;
-	if (!g_window) return;
-	applyOverlayWindowPolicy(g_window);
-	[g_window orderFrontRegardless];
-}
-
-static void overlayShow(void) {
-	g_state.fadeTarget = 1.0f;
-	if (!g_state.hasPosition) {
-		dispatch_async_f(dispatch_get_main_queue(), NULL, doAutoPosition);
-	}
-	dispatch_async_f(dispatch_get_main_queue(), NULL, doBringOverlayForward);
-}
-
-static void overlayHide(void) {
-	g_state.fadeTarget = 0.0f;
-}
-
-static void overlaySetStatus(int status, const char* text, float r, float g, float b) {
-	g_state.status = status;
-	g_state.barR = r;
-	g_state.barG = g;
-	g_state.barB = b;
-	if (text) {
-		strncpy(g_state.text, text, sizeof(g_state.text) - 1);
-		g_state.text[sizeof(g_state.text) - 1] = '\0';
-	} else {
-		g_state.text[0] = '\0';
-	}
-}
-
-static void overlaySetVolume(float vol) {
-	for (int i = 0; i < N_BARS - 1; i++)
-		g_state.volumes[i] = g_state.volumes[i + 1];
-	g_state.volumes[N_BARS - 1] = vol;
-}
-
-static void overlaySetPosition(int x, int y) {
-	g_state.posX = x;
-	g_state.posY = y;
-	g_state.hasPosition = 1;
-	g_state.needsPosition = 1;
-}
-
-static void overlayGetPosition(int* x, int* y) {
-	*x = g_state.posX;
-	*y = g_state.posY;
-}
-
-static int overlayCheckDrag(int* x, int* y) {
-	if (g_state.dragEnded) {
-		g_state.dragEnded = 0;
-		*x = g_state.dragX;
-		*y = g_state.dragY;
-		return 1;
-	}
-	return 0;
-}
-
-static void doCloseOverlay(void* ctx) {
-	(void)ctx;
-	if (g_timer) {
-		[g_timer invalidate];
-		g_timer = nil;
-	}
-	if (g_window) {
-		[g_window close];
-		g_window = nil;
-	}
-	g_view = nil;
-	g_timerTarget = nil;
-}
-
-static void overlayClose(void) {
-	dispatch_async_f(dispatch_get_main_queue(), NULL, doCloseOverlay);
-}
-*/
-import "C"
-
-import (
-	"time"
-	"unsafe"
-)
-
-// darwinOverlay uses a native NSWindow + Core Graphics to render
-// the floating indicator, matching the Windows GDI+ implementation.
-type darwinOverlay struct {
-	dragCb  func(int, int)
-	created bool
-	done    chan struct{}
-}
-
-func New() Overlay {
-	return &darwinOverlay{
-		done: make(chan struct{}),
-	}
-}
-
-func (o *darwinOverlay) ensureCreated() {
-	if !o.created {
-		C.overlayCreate()
-		o.created = true
-		time.Sleep(50 * time.Millisecond)
-		go o.pollDrag()
-	}
-}
-
-func (o *darwinOverlay) Show() {
-	o.ensureCreated()
-	C.overlayShow()
-}
-
-func (o *darwinOverlay) Hide() {
-	C.overlayHide()
-}
-
-func (o *darwinOverlay) SetStatus(status Status, text string) {
-	r, g, b := statusColorRGB(status)
-	cText := C.CString(text)
-	defer C.free(unsafe.Pointer(cText))
-	C.overlaySetStatus(statusToInt(status), cText, C.float(r), C.float(g), C.float(b))
-}
-
-func (o *darwinOverlay) SetVolume(vol float64) {
-	C.overlaySetVolume(C.float(vol))
-}
-
-func (o *darwinOverlay) SetPosition(x, y int) {
-	C.overlaySetPosition(C.int(x), C.int(y))
-}
-
-func (o *darwinOverlay) GetPosition() (int, int) {
-	var x, y C.int
-	C.overlayGetPosition(&x, &y)
-	return int(x), int(y)
-}
-
-func (o *darwinOverlay) OnDragged(fn func(int, int)) {
-	o.dragCb = fn
-}
-
-func (o *darwinOverlay) Close() {
-	select {
-	case <-o.done:
-	default:
-		close(o.done)
-	}
-	C.overlayClose()
-}
-
-func (o *darwinOverlay) pollDrag() {
-	ticker := time.NewTicker(100 * time.Millisecond)
-	defer ticker.Stop()
-	for {
-		select {
-		case <-o.done:
-			return
-		case <-ticker.C:
-			var x, y C.int
-			if C.overlayCheckDrag(&x, &y) != 0 {
-				if o.dragCb != nil {
-					o.dragCb(int(x), int(y))
-				}
-			}
-		}
-	}
-}
-
-func statusToInt(s Status) C.int {
-	switch s {
-	case StatusLoading:
-		return 1
-	case StatusReady:
-		return 2
-	case StatusRecording:
-		return 3
-	case StatusFreetalking:
-		return 4
-	case StatusProcessing:
-		return 5
-	case StatusDone:
-		return 6
-	case StatusCancelled:
-		return 7
-	case StatusNoVoice:
-		return 8
-	case StatusNoContent:
-		return 9
-	case StatusError:
-		return 10
-	default:
-		return 0
-	}
-}
-
-func statusColorRGB(s Status) (float32, float32, float32) {
-	switch s {
-	case StatusLoading, StatusFreetalking:
-		return 0, 0.478, 1.0 // #007AFF
-	case StatusReady, StatusDone:
-		return 0.204, 0.780, 0.349 // #34C759
-	case StatusRecording, StatusError:
-		return 1.0, 0.231, 0.188 // #FF3B30
-	case StatusProcessing, StatusCancelled, StatusNoVoice, StatusNoContent:
-		return 1.0, 0.584, 0.0 // #FF9500
-	default:
-		return 0, 0.478, 1.0
-	}
-}
diff --git a/VoiceSnapGo/internal/overlay/overlay_other.go b/VoiceSnapGo/internal/overlay/overlay_other.go
deleted file mode 100755
index 10b3da0..0000000
--- a/VoiceSnapGo/internal/overlay/overlay_other.go
+++ /dev/null
@@ -1,15 +0,0 @@
-//go:build !windows && !darwin
-
-package overlay
-
-type stubOverlay struct{}
-
-func New() Overlay                                { return &stubOverlay{} }
-func (o *stubOverlay) Show()                      {}
-func (o *stubOverlay) Hide()                      {}
-func (o *stubOverlay) SetStatus(Status, string)   {}
-func (o *stubOverlay) SetVolume(float64)          {}
-func (o *stubOverlay) SetPosition(int, int)       {}
-func (o *stubOverlay) GetPosition() (int, int)    { return 0, 0 }
-func (o *stubOverlay) OnDragged(func(int, int))   {}
-func (o *stubOverlay) Close()                     {}
diff --git a/VoiceSnapGo/internal/startup/startup_darwin.go b/VoiceSnapGo/internal/startup/startup_darwin.go
deleted file mode 100755
index bad3aba..0000000
--- a/VoiceSnapGo/internal/startup/startup_darwin.go
+++ /dev/null
@@ -1,67 +0,0 @@
-//go:build darwin
-
-package startup
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"text/template"
-)
-
-const plistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
-<dict>
-    <key>Label</key>
-    <string>com.voicesnap.app</string>
-    <key>ProgramArguments</key>
-    <array>
-        <string>{{.ExePath}}</string>
-    </array>
-    <key>RunAtLoad</key>
-    <true/>
-</dict>
-</plist>
-`
-
-func plistPath() string {
-	home, _ := os.UserHomeDir()
-	return filepath.Join(home, "Library", "LaunchAgents", "com.voicesnap.app.plist")
-}
-
-func isEnabled() bool {
-	_, err := os.Stat(plistPath())
-	return err == nil
-}
-
-func setEnabled(enable bool) error {
-	path := plistPath()
-
-	if !enable {
-		exec.Command("launchctl", "unload", path).Run()
-		return os.Remove(path)
-	}
-
-	exe, err := os.Executable()
-	if err != nil {
-		return err
-	}
-
-	dir := filepath.Dir(path)
-	os.MkdirAll(dir, 0755)
-
-	f, err := os.Create(path)
-	if err != nil {
-		return fmt.Errorf("failed to create plist: %w", err)
-	}
-	defer f.Close()
-
-	tmpl := template.Must(template.New("plist").Parse(plistTemplate))
-	if err := tmpl.Execute(f, struct{ ExePath string }{exe}); err != nil {
-		return err
-	}
-
-	return exec.Command("launchctl", "load", path).Run()
-}
diff --git a/VoiceSnapGo/internal/startup/startup_linux.go b/VoiceSnapGo/internal/startup/startup_linux.go
deleted file mode 100755
index 479d611..0000000
--- a/VoiceSnapGo/internal/startup/startup_linux.go
+++ /dev/null
@@ -1,52 +0,0 @@
-//go:build linux
-
-package startup
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-)
-
-const desktopEntry = `[Desktop Entry]
-Type=Application
-Name=VoiceSnap
-Exec=%s
-Hidden=false
-X-GNOME-Autostart-enabled=true
-`
-
-func autostartPath() string {
-	configDir, err := os.UserConfigDir()
-	if err != nil {
-		home, _ := os.UserHomeDir()
-		configDir = filepath.Join(home, ".config")
-	}
-	return filepath.Join(configDir, "autostart", "voicesnap.desktop")
-}
-
-func isEnabled() bool {
-	_, err := os.Stat(autostartPath())
-	return err == nil
-}
-
-func setEnabled(enable bool) error {
-	path := autostartPath()
-
-	if !enable {
-		return os.Remove(path)
-	}
-
-	exe, err := os.Executable()
-	if err != nil {
-		return err
-	}
-
-	dir := filepath.Dir(path)
-	if err := os.MkdirAll(dir, 0755); err != nil {
-		return err
-	}
-
-	content := fmt.Sprintf(desktopEntry, exe)
-	return os.WriteFile(path, []byte(content), 0644)
-}
diff --git a/VoiceSnapGo/internal/startup/startup_windows.go b/VoiceSnapGo/internal/startup/startup_windows.go
deleted file mode 100755
index a8dfe4f..0000000
--- a/VoiceSnapGo/internal/startup/startup_windows.go
+++ /dev/null
@@ -1,42 +0,0 @@
-//go:build windows
-
-package startup
-
-import (
-	"fmt"
-	"os"
-
-	"golang.org/x/sys/windows/registry"
-)
-
-const registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
-const appName = "VoiceSnap"
-
-func isEnabled() bool {
-	key, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.QUERY_VALUE)
-	if err != nil {
-		return false
-	}
-	defer key.Close()
-
-	val, _, err := key.GetStringValue(appName)
-	return err == nil && val != ""
-}
-
-func setEnabled(enable bool) error {
-	key, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE)
-	if err != nil {
-		return fmt.Errorf("failed to open registry key: %w", err)
-	}
-	defer key.Close()
-
-	if enable {
-		exe, err := os.Executable()
-		if err != nil {
-			return err
-		}
-		return key.SetStringValue(appName, fmt.Sprintf(`"%s"`, exe))
-	}
-
-	return key.DeleteValue(appName)
-}
diff --git a/VoiceSnapGo/internal/userdict/userdict.go b/VoiceSnapGo/internal/userdict/userdict.go
deleted file mode 100644
index 4b64c9e..0000000
--- a/VoiceSnapGo/internal/userdict/userdict.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package userdict
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"sort"
-	"strings"
-	"sync"
-	"time"
-	"voicesnap/internal/logger"
-	"voicesnap/internal/paths"
-)
-
-// Entry describes one user dictionary replacement rule.
-type Entry struct {
-	ID        int64  `json:"id"`
-	From      string `json:"from"`
-	To        string `json:"to"`
-	Enabled   bool   `json:"enabled"`
-	CreatedAt int64  `json:"createdAt"`
-	UpdatedAt int64  `json:"updatedAt"`
-}
-
-type fileData struct {
-	Replacements []Entry `json:"replacements"`
-}
-
-type exportEntry struct {
-	From string `json:"from"`
-	To   string `json:"to"`
-}
-
-type exportData struct {
-	Replacements []exportEntry `json:"replacements"`
-}
-
-type importEntry struct {
-	ID        int64  `json:"id,omitempty"`
-	From      string `json:"from"`
-	To        string `json:"to"`
-	Enabled   *bool  `json:"enabled,omitempty"`
-	CreatedAt int64  `json:"createdAt,omitempty"`
-	UpdatedAt int64  `json:"updatedAt,omitempty"`
-}
-
-type importData struct {
-	Replacements []importEntry `json:"replacements"`
-}
-
-// Store manages user dictionary rules persisted in userdict.json.
-type Store struct {
-	mu      sync.RWMutex
-	entries []Entry
-	path    string
-}
-
-// New creates a user dictionary store and creates userdict.json if needed.
-func New() *Store {
-	s := &Store{
-		path: userDictPath(),
-	}
-	s.load()
-	s.save()
-	return s
-}
-
-func userDictPath() string {
-	return paths.File("userdict.json")
-}
-
-// Path returns the backing JSON file path.
-func (s *Store) Path() string {
-	return s.path
-}
-
-// GetAll returns all dictionary entries newest first for display.
-func (s *Store) GetAll() []Entry {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
-
-	result := make([]Entry, len(s.entries))
-	copy(result, s.entries)
-	sort.SliceStable(result, func(i, j int) bool {
-		if result[i].CreatedAt == result[j].CreatedAt {
-			return result[i].ID > result[j].ID
-		}
-		return result[i].CreatedAt > result[j].CreatedAt
-	})
-	return result
-}
-
-// Add creates a new enabled replacement rule.
-func (s *Store) Add(from, to string) (Entry, error) {
-	from = strings.TrimSpace(from)
-	to = strings.TrimSpace(to)
-	if from == "" {
-		return Entry{}, fmt.Errorf("source text is required")
-	}
-
-	now := time.Now().UnixMilli()
-	entry := Entry{
-		ID:        now,
-		From:      from,
-		To:        to,
-		Enabled:   true,
-		CreatedAt: now,
-		UpdatedAt: now,
-	}
-
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	for s.hasIDLocked(entry.ID) {
-		entry.ID++
-	}
-	s.entries = append(s.entries, entry)
-	s.saveLocked()
-	return entry, nil
-}
-
-// Update modifies an existing replacement rule.
-func (s *Store) Update(id int64, from, to string, enabled bool) (Entry, error) {
-	from = strings.TrimSpace(from)
-	to = strings.TrimSpace(to)
-	if from == "" {
-		return Entry{}, fmt.Errorf("source text is required")
-	}
-
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	for i := range s.entries {
-		if s.entries[i].ID == id {
-			s.entries[i].From = from
-			s.entries[i].To = to
-			s.entries[i].Enabled = enabled
-			s.entries[i].UpdatedAt = time.Now().UnixMilli()
-			s.saveLocked()
-			return s.entries[i], nil
-		}
-	}
-	return Entry{}, fmt.Errorf("entry not found")
-}
-
-// Delete removes one entry by id.
-func (s *Store) Delete(id int64) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	for i, entry := range s.entries {
-		if entry.ID == id {
-			s.entries = append(s.entries[:i], s.entries[i+1:]...)
-			s.saveLocked()
-			return
-		}
-	}
-}
-
-// ClearAll removes all entries.
-func (s *Store) ClearAll() {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	s.entries = nil
-	s.saveLocked()
-}
-
-// Apply replaces text using all enabled user dictionary rules.
-func (s *Store) Apply(text string) string {
-	if text == "" {
-		return text
-	}
-
-	s.mu.RLock()
-	entries := make([]Entry, len(s.entries))
-	copy(entries, s.entries)
-	s.mu.RUnlock()
-
-	for _, entry := range entries {
-		if !entry.Enabled || entry.From == "" {
-			continue
-		}
-		text = strings.ReplaceAll(text, entry.From, entry.To)
-	}
-	return text
-}
-
-// ExportJSON returns the dictionary file contents as formatted JSON.
-func (s *Store) ExportJSON() string {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
-
-	replacements := make([]exportEntry, 0, len(s.entries))
-	for _, entry := range s.entries {
-		replacements = append(replacements, exportEntry{
-			From: entry.From,
-			To:   entry.To,
-		})
-	}
-
-	data, err := json.MarshalIndent(exportData{Replacements: replacements}, "", "  ")
-	if err != nil {
-		logger.Error("Failed to export user dictionary: %v", err)
-		return "{\n  \"replacements\": []\n}"
-	}
-	return string(data)
-}
-
-// ImportJSON replaces the dictionary with entries from JSON.
-func (s *Store) ImportJSON(content string) error {
-	var fd importData
-	if err := json.Unmarshal([]byte(content), &fd); err != nil {
-		return err
-	}
-
-	now := time.Now().UnixMilli()
-	seen := map[int64]bool{}
-	entries := make([]Entry, 0, len(fd.Replacements))
-	for i := range fd.Replacements {
-		imported := fd.Replacements[i]
-		from := strings.TrimSpace(imported.From)
-		to := strings.TrimSpace(imported.To)
-		if from == "" {
-			return fmt.Errorf("entry %d source text is required", i+1)
-		}
-		id := imported.ID
-		if id <= 0 || seen[id] {
-			id = now + int64(i)
-		}
-		seen[id] = true
-
-		enabled := true
-		if imported.Enabled != nil {
-			enabled = *imported.Enabled
-		}
-
-		createdAt := imported.CreatedAt
-		if createdAt <= 0 {
-			createdAt = now
-		}
-
-		entries = append(entries, Entry{
-			ID:        id,
-			From:      from,
-			To:        to,
-			Enabled:   enabled,
-			CreatedAt: createdAt,
-			UpdatedAt: now,
-		})
-	}
-
-	s.mu.Lock()
-	defer s.mu.Unlock()
-
-	s.entries = entries
-	s.saveLocked()
-	return nil
-}
-
-func (s *Store) load() {
-	data, err := os.ReadFile(s.path)
-	if err != nil {
-		return
-	}
-
-	var fd fileData
-	if err := json.Unmarshal(data, &fd); err != nil {
-		logger.Error("Failed to parse user dictionary: %v", err)
-		return
-	}
-	s.entries = fd.Replacements
-}
-
-func (s *Store) save() {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	s.saveLocked()
-}
-
-func (s *Store) saveLocked() {
-	if err := paths.Ensure(); err != nil {
-		logger.Error("Failed to create app data dir: %v", err)
-		return
-	}
-
-	data, err := json.MarshalIndent(fileData{Replacements: s.entries}, "", "  ")
-	if err != nil {
-		logger.Error("Failed to marshal user dictionary: %v", err)
-		return
-	}
-	if err := os.WriteFile(s.path, data, 0644); err != nil {
-		logger.Error("Failed to save user dictionary: %v", err)
-	}
-}
-
-func (s *Store) hasIDLocked(id int64) bool {
-	for _, entry := range s.entries {
-		if entry.ID == id {
-			return true
-		}
-	}
-	return false
-}
diff --git a/VoiceSnapGo/services/history_service.go b/VoiceSnapGo/services/history_service.go
deleted file mode 100755
index 01b0238..0000000
--- a/VoiceSnapGo/services/history_service.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package services
-
-import (
-	"voicesnap/internal/history"
-)
-
-// HistoryService provides recognition history to the frontend.
-type HistoryService struct {
-	store *history.Store
-}
-
-func NewHistoryService(store *history.Store) *HistoryService {
-	return &HistoryService{store: store}
-}
-
-// GetAll returns all history entries (newest first).
-func (s *HistoryService) GetAll() []history.Entry {
-	return s.store.GetAll()
-}
-
-// Add adds a new recognition result to history.
-func (s *HistoryService) Add(text string) {
-	s.store.Add(text)
-}
-
-// Delete removes a single entry by timestamp.
-func (s *HistoryService) Delete(timestamp int64) {
-	s.store.Delete(timestamp)
-}
-
-// ClearAll removes all history entries.
-func (s *HistoryService) ClearAll() {
-	s.store.ClearAll()
-}
-
-// GetRetentionDays returns the current retention period in days.
-func (s *HistoryService) GetRetentionDays() int {
-	return s.store.GetRetentionDays()
-}
-
-// SetRetentionDays sets how long to keep history (0 = forever).
-func (s *HistoryService) SetRetentionDays(days int) {
-	s.store.SetRetentionDays(days)
-}
diff --git a/VoiceSnapGo/Taskfile.yml b/privatevoice.src/Taskfile.yml
similarity index 93%
rename from VoiceSnapGo/Taskfile.yml
rename to privatevoice.src/Taskfile.yml
index a4db637..8ce9be7 100755
--- a/VoiceSnapGo/Taskfile.yml
+++ b/privatevoice.src/Taskfile.yml
@@ -6,7 +6,7 @@
   darwin: ./build/darwin/Taskfile.yml
 
 vars:
-  APP_NAME: "voicesnap"
+  APP_NAME: "privatevoice-input"
   BIN_DIR: "bin"
   VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
 
diff --git a/VoiceSnapGo/app.go b/privatevoice.src/app.go
similarity index 78%
rename from VoiceSnapGo/app.go
rename to privatevoice.src/app.go
index dd957bc..54bbc93 100755
--- a/VoiceSnapGo/app.go
+++ b/privatevoice.src/app.go
@@ -26,14 +26,16 @@
 )
 
 const (
-	appVersion        = "2.1.4"
-	appBuild          = "20260518.1748"
+	appVersion        = "2.1.18"
+	appBuild          = "20260601.1905"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
-	appName           = "VoiceSnap"
+	appName           = "PrivateVoice Input"
 
-	silenceThreshold       = 0.05            // RMS below this = silence (matches HasVoiceActivity)
-	silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk
-	silenceGracePeriod     = 2 * time.Second // don't auto-stop within first 2s of free-talk
+	silenceThreshold         = 0.05            // RMS below this = silence (matches HasVoiceActivity)
+	silenceTimeoutDuration   = 3 * time.Second // auto-stop after this much silence in free-talk
+	silenceGracePeriod       = 2 * time.Second // don't auto-stop within first 2s of free-talk
+	holdActivationDelay      = 180 * time.Millisecond
+	doneIndicatorHideDelayMs = 250
 )
 
 // App holds all application state and orchestration logic.
@@ -50,6 +52,7 @@
 	history  *history.Store
 	userdict *userdict.Store
 
+	engineService  *services.EngineService
 	wailsApp       *application.App
 	settingsWindow *application.WebviewWindow
 	indicator      overlay.Overlay
@@ -61,6 +64,7 @@
 	hotkeyActive      bool
 	hotkeyPressTime   time.Time
 	isCombination     bool
+	tapStopOnPress    bool
 	isRecordingHotkey bool
 	hideGen           atomic.Uint64
 	lastStopTime      time.Time
@@ -107,12 +111,14 @@
 	appService := services.NewAppService(app.cfg, appDisplayVersion)
 	configService := services.NewConfigService(app.cfg)
 	engineService := services.NewEngineService()
+	app.engineService = engineService
 	hotkeyService := services.NewHotkeyService(app.cfg)
 	permissionService := services.NewPermissionService()
 	updaterService := services.NewUpdaterService(appVersion)
 	audioService := services.NewAudioService(app.recorder, app.cfg)
 	historyService := services.NewHistoryService(app.history)
 	userDictService := services.NewUserDictService(app.userdict)
+	correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
 
 	// Create Wails application
 	wailsApp := application.New(application.Options{
@@ -128,6 +134,7 @@
 			application.NewService(audioService),
 			application.NewService(historyService),
 			application.NewService(userDictService),
+			application.NewService(correctionCSVService),
 		},
 		Assets: application.AssetOptions{
 			Handler: application.AssetFileServerFS(assets),
@@ -145,7 +152,7 @@
 	// Create settings window (visible on startup, close → hide to tray)
 	app.settingsWindow = wailsApp.Window.NewWithOptions(application.WebviewWindowOptions{
 		Name:             "settings",
-		Title:            "VoiceSnap",
+		Title:            appName,
 		Width:            820,
 		Height:           580,
 		URL:              "/",
@@ -162,10 +169,13 @@
 		app.settingsWindow.Hide()
 	})
 	userDictService.SetApp(wailsApp, app.settingsWindow)
+	correctionCSVService.SetApp(wailsApp, app.settingsWindow)
 
 	// Create overlay indicator (native Win32 on Windows, native AppKit on macOS)
 	app.indicator = overlay.New()
 	app.indicator.OnDragged(func(x, y int) {
+		x, y = app.clampIndicatorPosition(x, y)
+		app.indicator.SetPosition(x, y)
 		app.cfg.IndicatorX = x
 		app.cfg.IndicatorY = y
 		config.Save(app.cfg)
@@ -318,8 +328,8 @@
 				}
 			}
 
-			// If held > 300ms without combo, start hold-to-talk recording
-			if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond {
+			// If held long enough without combo, start hold-to-talk recording.
+			if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond {
 				logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
 				a.startRecordingLocked()
 			}
@@ -342,8 +352,14 @@
 		if !a.hotkeyActive {
 			a.hotkeyActive = true
 			a.isCombination = false
+			a.tapStopOnPress = false
 			a.hotkeyPressTime = time.Now()
 			logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s recording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeTap, a.isFreetalking)
+			if a.isFreetalking {
+				logger.Info("Hotkey action: stop tap recording on press")
+				a.tapStopOnPress = true
+				a.stopTapRecordingLocked()
+			}
 			return
 		}
 
@@ -363,6 +379,11 @@
 	a.hotkeyActive = false
 
 	if a.isCombination {
+		return
+	}
+
+	if a.tapStopOnPress {
+		a.tapStopOnPress = false
 		return
 	}
 
@@ -409,9 +430,9 @@
 	a.lastStopTime = time.Now()
 
 	if cancel {
-		a.recorder.Stop()
 		logger.Info("Recording cancelled (combination key)")
 		a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+		a.recorder.Stop()
 		if a.cfg.SoundFeedback {
 			sound.PlayCancel()
 		}
@@ -420,10 +441,11 @@
 	}
 
 	hasVoice := a.recorder.HasVoiceActivity()
-	samples := a.recorder.StopAndGetSamples()
-
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
-	go a.recognizeAndPaste(hasVoice, samples)
+	go func() {
+		samples := a.recorder.StopAndGetSamples()
+		a.recognizeAndPaste(hasVoice, samples)
+	}()
 }
 
 func (a *App) startTapRecordingLocked() {
@@ -487,11 +509,12 @@
 	a.lastStopTime = time.Now()
 
 	hasVoice := a.recorder.HasVoiceActivity()
-	samples := a.recorder.StopAndGetSamples()
-
 	logger.Info("Tap recording stopped")
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
-	go a.recognizeAndPaste(hasVoice, samples)
+	go func() {
+		samples := a.recorder.StopAndGetSamples()
+		a.recognizeAndPaste(hasVoice, samples)
+	}()
 }
 
 // checkSilenceTimeout monitors legacy free-talk recordings. The explicit tap
@@ -609,7 +632,7 @@
 	if soundFeedback {
 		sound.PlayDone()
 	}
-	a.delayedHideIf(autoHide, 2000)
+	a.delayedHideIf(autoHide, doneIndicatorHideDelayMs)
 }
 
 // waitForHotkeyRelease polls until the hotkey is released (max 500ms),
@@ -652,15 +675,26 @@
 
 func (a *App) initEngine() {
 	logger.Info("Initializing ASR engine...")
+	if a.engineService != nil {
+		a.engineService.SetStatus("loading", "", "")
+	}
 	a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
 		"status": "loading",
 	})
 
+	modelExists := engine.ModelExists()
 	eng, err := engine.New()
 	if err != nil {
 		logger.Error("Engine initialization failed: %v", err)
+		status := "need_model"
+		if modelExists {
+			status = "error"
+		}
+		if a.engineService != nil {
+			a.engineService.SetStatus(status, "", err.Error())
+		}
 		a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
-			"status": "need_model",
+			"status": status,
 			"error":  err.Error(),
 		})
 		return
@@ -671,6 +705,9 @@
 	a.mu.Unlock()
 
 	logger.Info("ASR engine ready: %s", eng.HardwareInfo())
+	if a.engineService != nil {
+		a.engineService.SetStatus("ready", eng.HardwareInfo(), "")
+	}
 	a.wailsApp.Event.Emit("engine:status", map[string]interface{}{
 		"status":       "ready",
 		"hardwareInfo": eng.HardwareInfo(),
@@ -707,20 +744,143 @@
 
 // positionIndicatorAt places the indicator at (x, y), or bottom-center if both are 0.
 func (a *App) positionIndicatorAt(x, y int) {
-	if x != 0 || y != 0 {
+	indicatorW, indicatorH := a.indicator.Size()
+	if indicatorW <= 0 {
+		indicatorW = 170
+	}
+	if indicatorH <= 0 {
+		indicatorH = 48
+	}
+
+	// On macOS, place the overlay near the currently focused input window each
+	// time instead of reusing a stale saved position from another display.
+	if autoPositioner, ok := a.indicator.(interface{ AutoPosition() }); ok {
+		autoPositioner.AutoPosition()
+		return
+	}
+
+	screen := a.wailsApp.Screen.GetPrimary()
+	if screen == nil {
+		if x != 0 || y != 0 {
+			a.indicator.SetPosition(x, y)
+		}
+		return
+	}
+
+	wa := screen.WorkArea
+	if x == 0 && y == 0 {
+		x = wa.X + (wa.Width-indicatorW)/2
+		y = wa.Y + wa.Height - indicatorH - 100
+		x, y = clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height)
 		a.indicator.SetPosition(x, y)
 		return
 	}
-	// Try Screen.GetPrimary(); on macOS this may return nil during early startup.
-	// The darwin overlay has its own autoPosition fallback in Show().
-	screen := a.wailsApp.Screen.GetPrimary()
+	x, y = a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH)
+	a.indicator.SetPosition(x, y)
+}
+
+func (a *App) clampIndicatorPosition(x, y int) (int, int) {
+	if a.wailsApp == nil || a.indicator == nil {
+		return x, y
+	}
+	indicatorW, indicatorH := a.indicator.Size()
+	if indicatorW <= 0 {
+		indicatorW = 170
+	}
+	if indicatorH <= 0 {
+		indicatorH = 48
+	}
+	return a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH)
+}
+
+func (a *App) clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH int) (int, int) {
+	screen := a.bestScreenForOverlay(x, y, indicatorW, indicatorH)
 	if screen == nil {
-		return
+		return x, y
 	}
 	wa := screen.WorkArea
-	cx := wa.X + (wa.Width-170)/2
-	cy := wa.Y + wa.Height - 48 - 100
-	a.indicator.SetPosition(cx, cy)
+	return clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height)
+}
+
+func (a *App) bestScreenForOverlay(x, y, overlayW, overlayH int) *application.Screen {
+	if a.wailsApp == nil {
+		return nil
+	}
+
+	screens := a.wailsApp.Screen.GetAll()
+	if len(screens) == 0 {
+		if primary := a.wailsApp.Screen.GetPrimary(); primary != nil {
+			return primary
+		}
+		return nil
+	}
+
+	centerX := x + overlayW/2
+	centerY := y + overlayH/2
+	for _, screen := range screens {
+		wa := screen.WorkArea
+		if pointInRect(centerX, centerY, wa.X, wa.Y, wa.Width, wa.Height) {
+			return screen
+		}
+	}
+
+	var best *application.Screen
+	bestScore := 0
+	bestDistance := 0
+	for _, screen := range screens {
+		wa := screen.WorkArea
+		intersection := intersectionArea(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height)
+		distance := rectDistanceSquared(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height)
+		if best == nil || intersection > bestScore || (intersection == bestScore && distance < bestDistance) {
+			best = screen
+			bestScore = intersection
+			bestDistance = distance
+		}
+	}
+	return best
+}
+
+func clampOverlayPosition(x, y, overlayW, overlayH, areaX, areaY, areaW, areaH int) (int, int) {
+	return clampOverlayAxis(x, areaX, areaW, overlayW), clampOverlayAxis(y, areaY, areaH, overlayH)
+}
+
+func clampOverlayAxis(pos, areaStart, areaSize, overlaySize int) int {
+	if areaSize <= 0 || overlaySize <= 0 {
+		return pos
+	}
+	if overlaySize >= areaSize {
+		return areaStart + (areaSize-overlaySize)/2
+	}
+	minPos := areaStart
+	maxPos := areaStart + areaSize - overlaySize
+	if pos < minPos {
+		return minPos
+	}
+	if pos > maxPos {
+		return maxPos
+	}
+	return pos
+}
+
+func pointInRect(x, y, rx, ry, rw, rh int) bool {
+	return x >= rx && x < rx+rw && y >= ry && y < ry+rh
+}
+
+func intersectionArea(ax, ay, aw, ah, bx, by, bw, bh int) int {
+	left := max(ax, bx)
+	top := max(ay, by)
+	right := min(ax+aw, bx+bw)
+	bottom := min(ay+ah, by+bh)
+	if right <= left || bottom <= top {
+		return 0
+	}
+	return (right - left) * (bottom - top)
+}
+
+func rectDistanceSquared(ax, ay, aw, ah, bx, by, bw, bh int) int {
+	dx := max(0, max(bx-(ax+aw), ax-(bx+bw)))
+	dy := max(0, max(by-(ay+ah), ay-(by+bh)))
+	return dx*dx + dy*dy
 }
 
 func (a *App) showSettings() {
diff --git a/VoiceSnapGo/build/Taskfile.yml b/privatevoice.src/build/Taskfile.yml
similarity index 100%
rename from VoiceSnapGo/build/Taskfile.yml
rename to privatevoice.src/build/Taskfile.yml
diff --git a/VoiceSnapGo/build/config.yml b/privatevoice.src/build/config.yml
similarity index 69%
rename from VoiceSnapGo/build/config.yml
rename to privatevoice.src/build/config.yml
index a88bd4f..defa93c 100755
--- a/VoiceSnapGo/build/config.yml
+++ b/privatevoice.src/build/config.yml
@@ -1,13 +1,13 @@
 version: '3'
 
 info:
-  companyName: "VoiceSnap"
-  productName: "VoiceSnap"
-  productIdentifier: "com.voicesnap.app"
-  description: "Offline voice to text tool"
-  copyright: "(c) 2025, VoiceSnap"
-  comments: "Press and hold hotkey to record, release to transcribe"
-  version: "2.1.4"
+  companyName: "PrivateVoice"
+  productName: "PrivateVoice Input"
+  productIdentifier: "com.privatevoice.input"
+  description: "Private offline voice input tool"
+  copyright: "(c) 2026, PrivateVoice"
+  comments: "Private.Offline"
+  version: "2.1.18"
 
 dev_mode:
   root_path: .
diff --git a/VoiceSnapGo/build/darwin/Info.plist b/privatevoice.src/build/darwin/Info.plist
similarity index 69%
rename from VoiceSnapGo/build/darwin/Info.plist
rename to privatevoice.src/build/darwin/Info.plist
index 9c149fc..fd418b9 100755
--- a/VoiceSnapGo/build/darwin/Info.plist
+++ b/privatevoice.src/build/darwin/Info.plist
@@ -3,25 +3,25 @@
 <plist version="1.0">
 <dict>
     <key>CFBundleExecutable</key>
-    <string>VoiceSnap</string>
+    <string>PrivateVoice Input</string>
     <key>CFBundleIconFile</key>
     <string>icon.icns</string>
     <key>CFBundleIdentifier</key>
-    <string>com.voicesnap.app</string>
+    <string>com.privatevoice.input</string>
     <key>CFBundleName</key>
-    <string>VoiceSnap</string>
+    <string>PrivateVoice Input</string>
     <key>CFBundleDisplayName</key>
-    <string>VoiceSnap</string>
+    <string>PrivateVoice Input</string>
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
-    <string>2.1.4</string>
+    <string>2.1.18</string>
     <key>CFBundleVersion</key>
-    <string>20260518.1748</string>
+    <string>20260601.1905</string>
     <key>LSMinimumSystemVersion</key>
     <string>11.0</string>
     <key>NSMicrophoneUsageDescription</key>
-    <string>VoiceSnap needs microphone access for voice-to-text input.</string>
+    <string>PrivateVoice Input needs microphone access for voice-to-text input.</string>
     <key>NSHighResolutionCapable</key>
     <true/>
     <key>LSUIElement</key>
diff --git a/VoiceSnapGo/build/darwin/Taskfile.yml b/privatevoice.src/build/darwin/Taskfile.yml
similarity index 100%
rename from VoiceSnapGo/build/darwin/Taskfile.yml
rename to privatevoice.src/build/darwin/Taskfile.yml
diff --git a/VoiceSnapGo/build/darwin/VoiceSnap-approved.icns b/privatevoice.src/build/darwin/VoiceSnap-approved.icns
similarity index 100%
rename from VoiceSnapGo/build/darwin/VoiceSnap-approved.icns
rename to privatevoice.src/build/darwin/VoiceSnap-approved.icns
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/VoiceSnap-macos26-mic.icns b/privatevoice.src/build/darwin/VoiceSnap-macos26-mic.icns
similarity index 100%
rename from VoiceSnapGo/build/darwin/VoiceSnap-macos26-mic.icns
rename to privatevoice.src/build/darwin/VoiceSnap-macos26-mic.icns
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/VoiceSnap-original.icns b/privatevoice.src/build/darwin/VoiceSnap-original.icns
similarity index 100%
rename from VoiceSnapGo/build/darwin/VoiceSnap-original.icns
rename to privatevoice.src/build/darwin/VoiceSnap-original.icns
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/VoiceSnap-refined-mic.icns b/privatevoice.src/build/darwin/VoiceSnap-refined-mic.icns
similarity index 100%
rename from VoiceSnapGo/build/darwin/VoiceSnap-refined-mic.icns
rename to privatevoice.src/build/darwin/VoiceSnap-refined-mic.icns
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/VoiceSnap-selected-B-shadow.icns b/privatevoice.src/build/darwin/VoiceSnap-selected-B-shadow.icns
similarity index 100%
rename from VoiceSnapGo/build/darwin/VoiceSnap-selected-B-shadow.icns
rename to privatevoice.src/build/darwin/VoiceSnap-selected-B-shadow.icns
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/app_icon.png b/privatevoice.src/build/darwin/app_icon.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/app_icon.png
rename to privatevoice.src/build/darwin/app_icon.png
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/app_icon_source.png b/privatevoice.src/build/darwin/app_icon_source.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/app_icon_source.png
rename to privatevoice.src/build/darwin/app_icon_source.png
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/approved_app_icon.png b/privatevoice.src/build/darwin/approved_app_icon.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/approved_app_icon.png
rename to privatevoice.src/build/darwin/approved_app_icon.png
Binary files differ
diff --git a/privatevoice.src/build/darwin/entitlements-appstore.plist b/privatevoice.src/build/darwin/entitlements-appstore.plist
new file mode 100644
index 0000000..2de7645
--- /dev/null
+++ b/privatevoice.src/build/darwin/entitlements-appstore.plist
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.security.app-sandbox</key>
+    <true/>
+    <key>com.apple.security.device.audio-input</key>
+    <true/>
+    <key>com.apple.security.network.client</key>
+    <true/>
+    <key>com.apple.security.files.user-selected.read-write</key>
+    <true/>
+</dict>
+</plist>
diff --git a/VoiceSnapGo/build/darwin/entitlements.plist b/privatevoice.src/build/darwin/entitlements.plist
similarity index 100%
rename from VoiceSnapGo/build/darwin/entitlements.plist
rename to privatevoice.src/build/darwin/entitlements.plist
diff --git a/VoiceSnapGo/build/darwin/icon.icns b/privatevoice.src/build/darwin/icon.icns
similarity index 100%
rename from VoiceSnapGo/build/darwin/icon.icns
rename to privatevoice.src/build/darwin/icon.icns
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/macos26_mic_app_icon.png b/privatevoice.src/build/darwin/macos26_mic_app_icon.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/macos26_mic_app_icon.png
rename to privatevoice.src/build/darwin/macos26_mic_app_icon.png
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/original_app_icon.png b/privatevoice.src/build/darwin/original_app_icon.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/original_app_icon.png
rename to privatevoice.src/build/darwin/original_app_icon.png
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/refined_mic_app_icon.png b/privatevoice.src/build/darwin/refined_mic_app_icon.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/refined_mic_app_icon.png
rename to privatevoice.src/build/darwin/refined_mic_app_icon.png
Binary files differ
diff --git a/VoiceSnapGo/build/darwin/tray_icon.png b/privatevoice.src/build/darwin/tray_icon.png
similarity index 100%
rename from VoiceSnapGo/build/darwin/tray_icon.png
rename to privatevoice.src/build/darwin/tray_icon.png
Binary files differ
diff --git a/privatevoice.src/build/linux/privatevoice-input.desktop b/privatevoice.src/build/linux/privatevoice-input.desktop
new file mode 100644
index 0000000..d2ded66
--- /dev/null
+++ b/privatevoice.src/build/linux/privatevoice-input.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=PrivateVoice Input
+Comment=Private offline voice input tool
+Exec=privatevoice-input
+Icon=privatevoice-input
+Terminal=false
+Categories=Utility;AudioVideo;
+Keywords=voice;speech;text;input;asr;
+StartupNotify=false
diff --git a/VoiceSnapGo/build/windows/Taskfile.yml b/privatevoice.src/build/windows/Taskfile.yml
similarity index 100%
rename from VoiceSnapGo/build/windows/Taskfile.yml
rename to privatevoice.src/build/windows/Taskfile.yml
diff --git a/VoiceSnapGo/build/windows/icon.ico b/privatevoice.src/build/windows/icon.ico
similarity index 100%
rename from VoiceSnapGo/build/windows/icon.ico
rename to privatevoice.src/build/windows/icon.ico
Binary files differ
diff --git a/VoiceSnapGo/build/windows/wails.exe.manifest b/privatevoice.src/build/windows/wails.exe.manifest
similarity index 93%
rename from VoiceSnapGo/build/windows/wails.exe.manifest
rename to privatevoice.src/build/windows/wails.exe.manifest
index f1ec4bc..28bf3f3 100755
--- a/VoiceSnapGo/build/windows/wails.exe.manifest
+++ b/privatevoice.src/build/windows/wails.exe.manifest
@@ -2,8 +2,8 @@
 <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
   <assemblyIdentity
     type="win32"
-    name="VoiceSnap"
-    version="2.1.4.0"
+    name="PrivateVoice.Input"
+    version="2.1.18.0"
     processorArchitecture="*"/>
   <dependency>
     <dependentAssembly>
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_A_pure-blue-mic.svg
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_shadow_preview.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_B_shadow_preview.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_shadow_preview.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_B_shadow_preview.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_shadow_preview.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_B_shadow_preview.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_shadow_preview.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_B_shadow_preview.svg
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_B_thin-blue-glyph.svg
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_C_taglauncher-style.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_C_taglauncher-style.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_C_taglauncher-style.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_C_taglauncher-style.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_C_taglauncher-style.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_C_taglauncher-style.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_C_taglauncher-style.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_C_taglauncher-style.svg
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_D_glass-circle.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_D_glass-circle.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_D_glass-circle.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_D_glass-circle.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_D_glass-circle.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_D_glass-circle.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_D_glass-circle.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_D_glass-circle.svg
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_E_compact-liquid.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_E_compact-liquid.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_E_compact-liquid.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_E_compact-liquid.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_E_compact-liquid.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_E_compact-liquid.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_E_compact-liquid.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_E_compact-liquid.svg
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_candidates_preview.png b/privatevoice.src/design/icon-candidates/voicesnap_icon_candidates_preview.png
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_candidates_preview.png
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_candidates_preview.png
Binary files differ
diff --git a/VoiceSnapGo/design/icon-candidates/voicesnap_icon_candidates_preview.svg b/privatevoice.src/design/icon-candidates/voicesnap_icon_candidates_preview.svg
similarity index 100%
rename from VoiceSnapGo/design/icon-candidates/voicesnap_icon_candidates_preview.svg
rename to privatevoice.src/design/icon-candidates/voicesnap_icon_candidates_preview.svg
diff --git a/VoiceSnapGo/embed.go b/privatevoice.src/embed.go
similarity index 100%
rename from VoiceSnapGo/embed.go
rename to privatevoice.src/embed.go
diff --git a/VoiceSnapGo/frontend/index.html b/privatevoice.src/frontend/index.html
similarity index 89%
rename from VoiceSnapGo/frontend/index.html
rename to privatevoice.src/frontend/index.html
index a04f9a3..e90ccab 100755
--- a/VoiceSnapGo/frontend/index.html
+++ b/privatevoice.src/frontend/index.html
@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>VoiceSnap</title>
+    <title>PrivateVoice Input</title>
     <link rel="stylesheet" href="/src/styles/global.css" />
   </head>
   <body>
diff --git a/VoiceSnapGo/frontend/instructions.md b/privatevoice.src/frontend/instructions.md
similarity index 100%
rename from VoiceSnapGo/frontend/instructions.md
rename to privatevoice.src/frontend/instructions.md
diff --git a/VoiceSnapGo/frontend/package-lock.json b/privatevoice.src/frontend/package-lock.json
similarity index 99%
rename from VoiceSnapGo/frontend/package-lock.json
rename to privatevoice.src/frontend/package-lock.json
index 0b6dd60..51530ba 100755
--- a/VoiceSnapGo/frontend/package-lock.json
+++ b/privatevoice.src/frontend/package-lock.json
@@ -1,12 +1,12 @@
 {
-  "name": "voicesnap-frontend",
-  "version": "2.1.4",
+  "name": "privatevoice-input-frontend",
+  "version": "2.1.18",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "voicesnap-frontend",
-      "version": "2.1.4",
+      "name": "privatevoice-input-frontend",
+      "version": "2.1.18",
       "dependencies": {
         "@wailsio/runtime": "latest"
       },
diff --git a/VoiceSnapGo/frontend/package.json b/privatevoice.src/frontend/package.json
similarity index 86%
rename from VoiceSnapGo/frontend/package.json
rename to privatevoice.src/frontend/package.json
index 017e1ea..83eef6d 100755
--- a/VoiceSnapGo/frontend/package.json
+++ b/privatevoice.src/frontend/package.json
@@ -1,7 +1,7 @@
 {
-  "name": "voicesnap-frontend",
+  "name": "privatevoice-input-frontend",
   "private": true,
-  "version": "2.1.4",
+  "version": "2.1.18",
   "type": "module",
   "scripts": {
     "dev": "vite dev",
diff --git a/VoiceSnapGo/frontend/src/App.svelte b/privatevoice.src/frontend/src/App.svelte
similarity index 91%
rename from VoiceSnapGo/frontend/src/App.svelte
rename to privatevoice.src/frontend/src/App.svelte
index a9aeb30..31f69cd 100755
--- a/VoiceSnapGo/frontend/src/App.svelte
+++ b/privatevoice.src/frontend/src/App.svelte
@@ -66,6 +66,15 @@
     })
 
     // Query engine status on mount (events emitted before frontend load are lost)
+    Call.ByName('voicesnap/services.EngineService.GetStatus').then((data: any) => {
+      if (data?.status) {
+        engineStatus.set(data.status as any)
+      }
+      if (data?.hardwareInfo) {
+        engineHardwareInfo.set(data.hardwareInfo)
+      }
+    })
+
     Call.ByName('voicesnap/services.EngineService.ModelExists').then((exists: any) => {
       if (!exists) {
         engineStatus.set('need_model')
diff --git a/VoiceSnapGo/build/darwin/app_icon.png b/privatevoice.src/frontend/src/assets/app-icon.png
similarity index 100%
copy from VoiceSnapGo/build/darwin/app_icon.png
copy to privatevoice.src/frontend/src/assets/app-icon.png
Binary files differ
diff --git a/VoiceSnapGo/frontend/src/components/indicator/FloatingIndicator.svelte b/privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte
similarity index 100%
rename from VoiceSnapGo/frontend/src/components/indicator/FloatingIndicator.svelte
rename to privatevoice.src/frontend/src/components/indicator/FloatingIndicator.svelte
diff --git a/VoiceSnapGo/frontend/src/components/onboarding/OnboardingView.svelte b/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
similarity index 92%
rename from VoiceSnapGo/frontend/src/components/onboarding/OnboardingView.svelte
rename to privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
index bbb9e5a..566b118 100755
--- a/VoiceSnapGo/frontend/src/components/onboarding/OnboardingView.svelte
+++ b/privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
@@ -3,6 +3,7 @@
   import { Events, Call } from '@wailsio/runtime'
   import { t } from '../../lib/i18n'
   import { engineStatus } from '../../lib/stores/app'
+  import AppIcon from '../shared/AppIcon.svelte'
 
   let statusText = $state(t('onboarding.syncModel'))
   let detailText = $state('')
@@ -25,6 +26,10 @@
       if (data?.status === 'ready') {
         statusText = t('onboarding.complete')
         progress = 100
+      } else if (data?.status === 'need_model') {
+        statusText = t('onboarding.failed')
+        detailText = data?.error || ''
+        failed = true
       } else if (data?.status === 'error') {
         statusText = t('onboarding.failed')
         detailText = data?.error || ''
@@ -56,7 +61,9 @@
 
 <div class="onboarding">
   <div class="card">
-    <div class="icon">V</div>
+    <div class="icon">
+      <AppIcon size={56} elevated />
+    </div>
     <h1 class="title">{t('onboarding.title')}</h1>
 
     <div class="status">
@@ -96,14 +103,9 @@
   .icon {
     width: 56px;
     height: 56px;
-    background: var(--color-blue);
-    border-radius: var(--radius-lg);
     display: flex;
     align-items: center;
     justify-content: center;
-    color: white;
-    font-weight: 700;
-    font-size: 24px;
     margin: 0 auto var(--spacing-lg);
   }
 
diff --git a/privatevoice.src/frontend/src/components/settings/AboutPage.svelte b/privatevoice.src/frontend/src/components/settings/AboutPage.svelte
new file mode 100644
index 0000000..78ddffd
--- /dev/null
+++ b/privatevoice.src/frontend/src/components/settings/AboutPage.svelte
@@ -0,0 +1,157 @@
+<script lang="ts">
+  import { Call } from '@wailsio/runtime'
+  import { t } from '../../lib/i18n'
+  import AppIcon from '../shared/AppIcon.svelte'
+
+  let version = $state('2.1.12')
+  const currentYear = new Date().getFullYear().toString()
+
+  async function loadVersion() {
+    try {
+      const v: any = await Call.ByName('voicesnap/services.AppService.GetVersion')
+      if (v) version = v
+    } catch {}
+  }
+  loadVersion()
+
+  function openSupport() {
+    Call.ByName('voicesnap/services.AppService.OpenURL', t('about.supportUrl'))
+  }
+</script>
+
+<div class="about">
+  <div class="center-content">
+    <AppIcon size={80} elevated />
+    <h2 class="app-name">{t('app.name')}</h2>
+    <p class="app-secondary-name">{t('app.secondaryName')}</p>
+    <p class="app-version">v{version}</p>
+    <p class="app-tagline">{t('about.tagline')}</p>
+    <p class="app-slogan">{t('about.slogan')}</p>
+
+    <div class="legal">
+      <p>{t('about.legalText')}</p>
+      <p>{t('about.legalDetails')}</p>
+      <button type="button" class="support-link" onclick={openSupport}>
+        {t('about.supportUrl')}
+      </button>
+    </div>
+
+    <button type="button" class="support-btn" onclick={openSupport}>
+      {t('about.openSupport')}
+    </button>
+  </div>
+
+  <div class="bottom">
+    <div class="divider"></div>
+    <p class="copyright">{t('about.copyright', { year: currentYear })}</p>
+  </div>
+</div>
+
+<style>
+  .about {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    min-height: calc(100vh - 40px);
+    background: var(--color-bg-grouped-secondary);
+    border-radius: var(--radius-md);
+    position: relative;
+  }
+
+  .center-content {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    width: min(420px, 100%);
+    margin-top: -20px;
+  }
+
+  .app-name {
+    font-size: 22px;
+    font-weight: 700;
+    margin-top: 16px;
+  }
+
+  .app-secondary-name {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+    margin-top: 3px;
+  }
+
+  .app-version {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+    margin-top: 8px;
+  }
+
+  .app-tagline {
+    margin-top: 18px;
+    font-size: var(--font-size-base);
+    font-weight: 600;
+    color: var(--color-label);
+  }
+
+  .app-slogan {
+    margin-top: 4px;
+    font-size: var(--font-size-sm);
+    color: var(--color-tertiary-label);
+  }
+
+  .legal {
+    width: 100%;
+    margin-top: 28px;
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-sm);
+    line-height: 1.65;
+    text-align: center;
+  }
+
+  .support-link {
+    display: inline;
+    padding: 0;
+    border: none;
+    background: transparent;
+    color: var(--color-blue);
+    font: inherit;
+    cursor: pointer;
+    word-break: break-all;
+  }
+
+  .support-btn {
+    margin-top: 20px;
+    padding: 8px 22px;
+    background: transparent;
+    border: 1.5px solid var(--color-separator);
+    border-radius: var(--radius-md);
+    font-size: var(--font-size-sm);
+    font-weight: 500;
+    color: var(--color-label);
+    cursor: pointer;
+    transition: all var(--transition-fast);
+  }
+
+  .support-btn:hover {
+    border-color: var(--color-blue);
+    color: var(--color-blue);
+  }
+
+  .bottom {
+    position: absolute;
+    bottom: 20px;
+    left: 30px;
+    right: 30px;
+    text-align: center;
+  }
+
+  .divider {
+    height: 1px;
+    background: var(--color-separator);
+    margin-bottom: 12px;
+  }
+
+  .copyright {
+    font-size: var(--font-size-xs);
+    color: var(--color-tertiary-label);
+  }
+</style>
diff --git a/privatevoice.src/frontend/src/components/settings/CorrectionImportPreviewDialog.svelte b/privatevoice.src/frontend/src/components/settings/CorrectionImportPreviewDialog.svelte
new file mode 100644
index 0000000..966e03b
--- /dev/null
+++ b/privatevoice.src/frontend/src/components/settings/CorrectionImportPreviewDialog.svelte
@@ -0,0 +1,325 @@
+<script lang="ts">
+  import { t } from '../../lib/i18n'
+
+  interface Summary {
+    new: number
+    csvDuplicate: number
+    dictDuplicate: number
+    conflict: number
+    invalid: number
+    empty: number
+    limit: number
+  }
+
+  interface PreviewItem {
+    row: number
+    slot: number
+    from: string
+    to: string
+    status: string
+  }
+
+  interface Preview {
+    previewId: string
+    fileName: string
+    summary: Summary
+    items: PreviewItem[]
+    totalSlots: number
+    visibleLimit: number
+    detailsTruncated: boolean
+  }
+
+  interface Props {
+    preview: Preview
+    confirming: boolean
+    oncancel: () => void
+    onconfirm: () => void
+  }
+
+  let { preview, confirming, oncancel, onconfirm }: Props = $props()
+
+  const summaryRows = [
+    { key: 'new', label: () => t('userdict.previewWillAdd'), tone: 'new' },
+    { key: 'csvDuplicate', label: () => t('userdict.previewCsvDuplicate'), tone: 'skip' },
+    { key: 'dictDuplicate', label: () => t('userdict.previewDictDuplicate'), tone: 'skip' },
+    { key: 'conflict', label: () => t('userdict.previewConflict'), tone: 'warn' },
+    { key: 'invalid', label: () => t('userdict.previewInvalid'), tone: 'warn' },
+    { key: 'empty', label: () => t('userdict.previewEmpty'), tone: 'skip' },
+    { key: 'limit', label: () => t('userdict.previewLimitExceeded'), tone: 'warn' },
+  ]
+
+  function countFor(key: string): number {
+    return (preview.summary as any)[key] || 0
+  }
+
+  function statusLabel(status: string): string {
+    switch (status) {
+      case 'new': return t('userdict.statusNew')
+      case 'csv_duplicate': return t('userdict.statusCsvDuplicate')
+      case 'dict_duplicate': return t('userdict.statusDictDuplicate')
+      case 'conflict': return t('userdict.statusConflict')
+      case 'invalid': return t('userdict.statusInvalid')
+      case 'empty': return t('userdict.statusEmpty')
+      case 'limit': return t('userdict.statusLimit')
+      default: return status
+    }
+  }
+</script>
+
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<div class="overlay" onclick={oncancel} role="dialog" tabindex="-1">
+  <!-- svelte-ignore a11y_click_events_have_key_events -->
+  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
+  <div class="dialog" onclick={(e) => e.stopPropagation()} role="document">
+    <div class="header">
+      <div>
+        <h2>{t('userdict.previewTitle')}</h2>
+        <p>{t('userdict.previewFile', { file: preview.fileName })}</p>
+      </div>
+      <button class="close-btn" onclick={oncancel} disabled={confirming} aria-label={t('userdict.cancel')}>×</button>
+    </div>
+
+    <div class="summary-grid">
+      {#each summaryRows as row}
+        <div class="summary-card" class:is-new={row.tone === 'new'} class:warn={row.tone === 'warn'}>
+          <span class="summary-label">{row.label()}</span>
+          <strong>{countFor(row.key)}</strong>
+        </div>
+      {/each}
+    </div>
+
+    {#if preview.detailsTruncated}
+      <p class="notice">{t('userdict.previewTruncated', { count: String(preview.visibleLimit) })}</p>
+    {/if}
+
+    <div class="details">
+      {#if preview.items.length === 0}
+        <p class="empty">{t('userdict.previewNoDetails')}</p>
+      {:else}
+        {#each preview.items as item}
+          <div class="detail-row">
+            <span class="row-index">{t('userdict.previewRowSlot', { row: String(item.row), slot: String(item.slot) })}</span>
+            <span class="rule-text" title={`${item.from} → ${item.to}`}>
+              {item.from || '—'} <span>→</span> {item.to || '—'}
+            </span>
+            <span class="status-pill" class:is-new={item.status === 'new'} class:warn={item.status === 'conflict' || item.status === 'invalid' || item.status === 'limit'}>
+              {statusLabel(item.status)}
+            </span>
+          </div>
+        {/each}
+      {/if}
+    </div>
+
+    {#if preview.summary.new === 0}
+      <p class="notice warning">{t('userdict.previewNoNewRules')}</p>
+    {/if}
+
+    <div class="actions">
+      <button class="btn secondary" onclick={oncancel} disabled={confirming}>{t('userdict.cancel')}</button>
+      <button class="btn primary" onclick={onconfirm} disabled={confirming || preview.summary.new === 0}>
+        {confirming ? t('userdict.confirmingImport') : t('userdict.confirmImport')}
+      </button>
+    </div>
+  </div>
+</div>
+
+<style>
+  .overlay {
+    position: fixed;
+    inset: 0;
+    z-index: 10000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(0, 0, 0, 0.35);
+    backdrop-filter: blur(4px);
+  }
+
+  .dialog {
+    width: min(720px, 92vw);
+    max-height: 86vh;
+    display: flex;
+    flex-direction: column;
+    padding: var(--spacing-xl);
+    border-radius: var(--radius-lg);
+    background: var(--color-bg-primary);
+    box-shadow: 0 12px 44px rgba(0, 0, 0, 0.18);
+  }
+
+  .header,
+  .actions {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: var(--spacing-md);
+  }
+
+  h2 {
+    font-size: var(--font-size-xl);
+    font-weight: 700;
+  }
+
+  p {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+  }
+
+  .close-btn {
+    width: 28px;
+    height: 28px;
+    border: none;
+    border-radius: 50%;
+    color: var(--color-secondary-label);
+    background: transparent;
+    font-size: 22px;
+    line-height: 1;
+  }
+
+  .close-btn:hover {
+    color: var(--color-label);
+    background: var(--color-bg-secondary);
+  }
+
+  .summary-grid {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    gap: 8px;
+    margin: var(--spacing-lg) 0 var(--spacing-sm);
+  }
+
+  .summary-card {
+    min-width: 0;
+    padding: 10px 12px;
+    border-radius: var(--radius-sm);
+    background: var(--color-bg-secondary);
+  }
+
+  .summary-card.is-new {
+    background: rgba(52, 199, 89, 0.12);
+  }
+
+  .summary-card.warn {
+    background: rgba(255, 149, 0, 0.12);
+  }
+
+  .summary-label {
+    display: block;
+    font-size: var(--font-size-xs);
+    color: var(--color-secondary-label);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .summary-card strong {
+    display: block;
+    margin-top: 4px;
+    font-size: 20px;
+  }
+
+  .notice {
+    margin-top: 6px;
+    font-size: var(--font-size-xs);
+    color: var(--color-secondary-label);
+  }
+
+  .notice.warning {
+    color: var(--color-orange);
+  }
+
+  .details {
+    min-height: 120px;
+    overflow: auto;
+    margin-top: var(--spacing-md);
+    border: 1px solid var(--color-separator);
+    border-radius: var(--radius-sm);
+  }
+
+  .detail-row {
+    display: grid;
+    grid-template-columns: 92px minmax(0, 1fr) 96px;
+    gap: var(--spacing-sm);
+    align-items: center;
+    min-height: 36px;
+    padding: 7px 10px;
+  }
+
+  .detail-row + .detail-row {
+    border-top: 1px solid var(--color-separator);
+  }
+
+  .row-index {
+    font-size: var(--font-size-xs);
+    color: var(--color-tertiary-label);
+  }
+
+  .rule-text {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: var(--font-size-sm);
+  }
+
+  .rule-text span {
+    color: var(--color-tertiary-label);
+  }
+
+  .status-pill {
+    justify-self: end;
+    padding: 2px 8px;
+    border-radius: 999px;
+    font-size: var(--font-size-xs);
+    color: var(--color-secondary-label);
+    background: var(--color-bg-secondary);
+  }
+
+  .status-pill.is-new {
+    color: var(--color-green);
+    background: rgba(52, 199, 89, 0.12);
+  }
+
+  .status-pill.warn {
+    color: var(--color-orange);
+    background: rgba(255, 149, 0, 0.12);
+  }
+
+  .empty {
+    padding: var(--spacing-xl);
+    text-align: center;
+  }
+
+  .actions {
+    justify-content: flex-end;
+    margin-top: var(--spacing-lg);
+  }
+
+  .btn {
+    height: 32px;
+    padding: 0 16px;
+    border: none;
+    border-radius: var(--radius-sm);
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+  }
+
+  .btn.secondary {
+    color: var(--color-label);
+    background: var(--color-bg-secondary);
+  }
+
+  .btn.primary {
+    color: white;
+    background: var(--color-blue);
+  }
+
+  button {
+    font: inherit;
+    cursor: pointer;
+  }
+
+  button:disabled {
+    cursor: default;
+    opacity: 0.45;
+  }
+</style>
diff --git a/VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
similarity index 100%
rename from VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte
rename to privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
diff --git a/privatevoice.src/frontend/src/components/settings/HistoryPage.svelte b/privatevoice.src/frontend/src/components/settings/HistoryPage.svelte
new file mode 100644
index 0000000..d4cc345
--- /dev/null
+++ b/privatevoice.src/frontend/src/components/settings/HistoryPage.svelte
@@ -0,0 +1,1129 @@
+<script lang="ts">
+  import { Call } from '@wailsio/runtime'
+  import { t } from '../../lib/i18n'
+
+  interface HistoryEntry {
+    text: string
+    timestamp: number
+    exportedAt?: number
+  }
+
+  interface HistoryPageResult {
+    entries: HistoryEntry[]
+    total: number
+    page: number
+    pageSize: number
+    totalPages: number
+  }
+
+  interface HistoryStatus {
+    retentionDays: number
+    storagePath: string
+    storageSizeBytes: number
+    storageExists: boolean
+    storageSizeAvailable: boolean
+    totalCount: number
+    exportedCount: number
+    unexportedTodayCount: number
+    unexportedLast7DaysCount: number
+    unexportedAllCount: number
+  }
+
+  interface CorrectionCSVExportResult {
+    path?: string
+    canceled?: boolean
+    noEntries?: boolean
+  }
+
+  interface ClearExportedResult {
+    deletedCount: number
+    remainingCount: number
+  }
+
+  type ExportScope = 'today' | 'last7Days' | 'all'
+  type ConfirmMode = 'clearAll' | 'clearExported' | null
+
+  const PAGE_SIZE = 100
+  const MB = 1024 * 1024
+
+  let entries = $state<HistoryEntry[]>([])
+  let page = $state(1)
+  let pageInput = $state('1')
+  let pageSize = $state(PAGE_SIZE)
+  let total = $state(0)
+  let totalPages = $state(0)
+  let pageLoaded = $state(false)
+  let isPageLoading = $state(false)
+
+  let retentionDays = $state(30)
+  let statusLoaded = $state(false)
+  let storagePath = $state('')
+  let storageExists = $state(false)
+  let storageSizeAvailable = $state(false)
+  let storageSizeBytes = $state(0)
+  let totalCount = $state(0)
+  let exportedCount = $state(0)
+  let unexportedTodayCount = $state(0)
+  let unexportedLast7DaysCount = $state(0)
+  let unexportedAllCount = $state(0)
+
+  let copiedTs = $state<number | null>(null)
+  let confirmMode = $state<ConfirmMode>(null)
+  let showRetention = $state(false)
+  let showExportMenu = $state(false)
+  let showClearMenu = $state(false)
+  let storagePathCopied = $state(false)
+  let statusText = $state('')
+  let statusTone = $state<'success' | 'error' | 'info'>('info')
+  let isExportingCorrectionCsv = $state(false)
+  let isClearingExported = $state(false)
+  let isClearingAll = $state(false)
+  let isOpeningFolder = $state(false)
+  let deletingTs = $state<number | null>(null)
+
+  let pageRequestSeq = 0
+
+  const retentionOptions = [
+    { value: 7, label: () => t('history.days7') },
+    { value: 30, label: () => t('history.days30') },
+    { value: 90, label: () => t('history.days90') },
+    { value: 0, label: () => t('history.forever') },
+  ]
+
+  const exportScopeOptions: { value: ExportScope; label: () => string; count: () => number }[] = [
+    { value: 'today', label: () => t('history.exportTodayUnprocessed'), count: () => unexportedTodayCount },
+    { value: 'last7Days', label: () => t('history.exportLast7DaysUnprocessed'), count: () => unexportedLast7DaysCount },
+    { value: 'all', label: () => t('history.exportAllUnprocessed'), count: () => unexportedAllCount },
+  ]
+
+  function currentRetentionLabel(): string {
+    const opt = retentionOptions.find(o => o.value === retentionDays)
+    return opt ? opt.label() : t('history.days30')
+  }
+
+  function hasHistory(): boolean {
+    return totalCount > 0
+  }
+
+  function hasPagination(): boolean {
+    return totalPages > 1
+  }
+
+  function canExportAny(): boolean {
+    return unexportedAllCount > 0
+  }
+
+  function canGoPrev(): boolean {
+    return page > 1
+  }
+
+  function canGoNext(): boolean {
+    return totalPages > 0 && page < totalPages
+  }
+
+  async function loadPage(targetPage = page) {
+    const seq = ++pageRequestSeq
+    isPageLoading = true
+    try {
+      const result: HistoryPageResult = await Call.ByName(
+        'voicesnap/services.HistoryService.GetPage',
+        targetPage,
+        PAGE_SIZE,
+      )
+      if (seq !== pageRequestSeq) return
+      entries = result?.entries || []
+      total = result?.total || 0
+      page = result?.page || 1
+      pageInput = String(page)
+      pageSize = result?.pageSize || PAGE_SIZE
+      totalPages = result?.totalPages || 0
+      pageLoaded = true
+    } catch {
+      if (seq !== pageRequestSeq) return
+      entries = []
+      total = 0
+      page = 1
+      pageInput = '1'
+      totalPages = 0
+      pageLoaded = true
+      flash(t('history.loadFailed'), 'error')
+    } finally {
+      if (seq === pageRequestSeq) {
+        isPageLoading = false
+      }
+    }
+  }
+
+  async function loadStatus() {
+    try {
+      const status: HistoryStatus = await Call.ByName('voicesnap/services.HistoryService.GetStatus')
+      retentionDays = status?.retentionDays ?? 30
+      storagePath = status?.storagePath || ''
+      storageExists = !!status?.storageExists
+      storageSizeAvailable = !!status?.storageSizeAvailable
+      storageSizeBytes = status?.storageSizeBytes || 0
+      totalCount = status?.totalCount || 0
+      exportedCount = status?.exportedCount || 0
+      unexportedTodayCount = status?.unexportedTodayCount || 0
+      unexportedLast7DaysCount = status?.unexportedLast7DaysCount || 0
+      unexportedAllCount = status?.unexportedAllCount || 0
+    } catch {
+      storageSizeAvailable = false
+      totalCount = total
+    }
+    statusLoaded = true
+  }
+
+  async function refresh(targetPage = page) {
+    await loadStatus()
+    await loadPage(targetPage)
+    if (totalPages > 0 && page > totalPages) {
+      await loadPage(totalPages)
+    }
+  }
+
+  async function loadInitial() {
+    await loadStatus()
+    await loadPage(1)
+  }
+
+  async function setRetention(days: number) {
+    const previous = retentionDays
+    retentionDays = days
+    showRetention = false
+    try {
+      await Call.ByName('voicesnap/services.HistoryService.SetRetentionDays', days)
+      await refresh(1)
+    } catch {
+      retentionDays = previous
+      flash(t('history.retentionFailed'), 'error')
+    }
+  }
+
+  async function copyStoragePath() {
+    if (!storagePath) return
+    try {
+      await navigator.clipboard.writeText(storagePath)
+      storagePathCopied = true
+      setTimeout(() => { storagePathCopied = false }, 1500)
+    } catch {}
+  }
+
+  async function openStorageFolder() {
+    if (isOpeningFolder) return
+    isOpeningFolder = true
+    try {
+      await Call.ByName('voicesnap/services.HistoryService.OpenStorageFolder')
+    } catch {
+      flash(t('history.openFolderFailed'), 'error')
+    } finally {
+      isOpeningFolder = false
+    }
+  }
+
+  async function copyText(entry: HistoryEntry) {
+    try {
+      await navigator.clipboard.writeText(entry.text)
+      copiedTs = entry.timestamp
+      setTimeout(() => { if (copiedTs === entry.timestamp) copiedTs = null }, 1500)
+    } catch {}
+  }
+
+  function flash(message: string, tone: 'success' | 'error' | 'info' = 'info') {
+    statusText = message
+    statusTone = tone
+    setTimeout(() => {
+      if (statusText === message) statusText = ''
+    }, 2600)
+  }
+
+  async function exportCorrectionCsv(scope: ExportScope) {
+    const option = exportScopeOptions.find(opt => opt.value === scope)
+    if (!option || option.count() === 0) {
+      flash(t('history.exportCorrectionCsvNoEntries'), 'info')
+      return
+    }
+    if (isExportingCorrectionCsv) return
+
+    showExportMenu = false
+    isExportingCorrectionCsv = true
+    try {
+      const result: CorrectionCSVExportResult = await Call.ByName(
+        'voicesnap/services.CorrectionCSVService.ExportHistoryCorrectionCSVToFile',
+        scope,
+      )
+      if (result?.noEntries) {
+        flash(t('history.exportCorrectionCsvNoEntries'), 'info')
+      } else if (result?.path) {
+        flash(t('history.exportCorrectionCsvSuccess', { path: result.path }), 'success')
+        await refresh(page)
+      }
+    } catch {
+      flash(t('history.exportCorrectionCsvFailed'), 'error')
+    } finally {
+      isExportingCorrectionCsv = false
+    }
+  }
+
+  async function deleteEntry(timestamp: number) {
+    if (deletingTs !== null) return
+    deletingTs = timestamp
+    try {
+      await Call.ByName('voicesnap/services.HistoryService.Delete', timestamp)
+      await refresh(page)
+    } catch {
+      flash(t('history.deleteFailed'), 'error')
+    } finally {
+      deletingTs = null
+    }
+  }
+
+  async function clearAll() {
+    if (isClearingAll) return
+    confirmMode = null
+    showClearMenu = false
+    isClearingAll = true
+    try {
+      await Call.ByName('voicesnap/services.HistoryService.ClearAll')
+      await refresh(1)
+      flash(t('history.clearAllSuccess'), 'success')
+    } catch {
+      flash(t('history.clearFailed'), 'error')
+    } finally {
+      isClearingAll = false
+    }
+  }
+
+  async function clearExported() {
+    if (isClearingExported) return
+    confirmMode = null
+    showClearMenu = false
+    isClearingExported = true
+    try {
+      const result: ClearExportedResult = await Call.ByName('voicesnap/services.HistoryService.ClearExported')
+      await refresh(page)
+      if ((result?.deletedCount || 0) === 0) {
+        flash(t('history.clearExportedNoEntries'), 'info')
+      } else {
+        flash(t('history.clearExportedSuccess', { count: String(result.deletedCount) }), 'success')
+      }
+    } catch {
+      flash(t('history.clearFailed'), 'error')
+    } finally {
+      isClearingExported = false
+    }
+  }
+
+  function goToPage(targetPage: number) {
+    if (isPageLoading) return
+    loadPage(targetPage)
+  }
+
+  function commitPageInput() {
+    const parsed = Number.parseInt(pageInput.trim(), 10)
+    if (!Number.isFinite(parsed)) {
+      pageInput = String(page)
+      return
+    }
+    const max = totalPages > 0 ? totalPages : 1
+    const target = Math.min(Math.max(parsed, 1), max)
+    pageInput = String(target)
+    if (target !== page) {
+      goToPage(target)
+    }
+  }
+
+  function formatTime(ts: number): string {
+    const date = new Date(ts)
+    const now = new Date()
+    const hours = date.getHours().toString().padStart(2, '0')
+    const mins = date.getMinutes().toString().padStart(2, '0')
+    const time = `${hours}:${mins}`
+
+    const isToday = date.toDateString() === now.toDateString()
+    if (isToday) return time
+
+    const yesterday = new Date(now)
+    yesterday.setDate(yesterday.getDate() - 1)
+    if (date.toDateString() === yesterday.toDateString()) {
+      return `${t('history.yesterday')} ${time}`
+    }
+
+    const month = (date.getMonth() + 1).toString()
+    const day = date.getDate().toString()
+    return `${month}/${day} ${time}`
+  }
+
+  function displayPath(path: string): string {
+    if (!path || path.length <= 64) return path
+    const head = path.slice(0, 24)
+    const tail = path.slice(-34)
+    return `${head}......${tail}`
+  }
+
+  function formatSize(): string {
+    if (!storageExists || !storageSizeAvailable) return t('history.sizeUnknown')
+    if (storageSizeBytes < 1024) return `${storageSizeBytes} B`
+    if (storageSizeBytes < MB) return `${(storageSizeBytes / 1024).toFixed(1)} KB`
+    return `${(storageSizeBytes / MB).toFixed(1)} MB`
+  }
+
+  function storageSizeTone(): 'normal' | 'warning' | 'danger' {
+    if (!storageExists || !storageSizeAvailable) return 'normal'
+    if (storageSizeBytes >= 100 * MB) return 'danger'
+    if (storageSizeBytes >= 10 * MB) return 'warning'
+    return 'normal'
+  }
+
+  function closeDropdowns(e: MouseEvent) {
+    const target = e.target as HTMLElement
+    if (!target.closest('.retention-selector')) {
+      showRetention = false
+    }
+    if (!target.closest('.export-selector')) {
+      showExportMenu = false
+    }
+    if (!target.closest('.clear-selector')) {
+      showClearMenu = false
+    }
+  }
+
+  loadInitial()
+</script>
+
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div class="page" onclick={closeDropdowns}>
+  <div class="toolbar">
+    <div class="toolbar-left">
+      <span class="toolbar-label">{t('history.retention')}</span>
+      <div class="retention-selector">
+        {#if statusLoaded}
+          <button class="retention-btn" onclick={() => showRetention = !showRetention}>
+            <span>{currentRetentionLabel()}</span>
+            <svg class="chevron" class:open={showRetention} width="10" height="6" viewBox="0 0 10 6" fill="none">
+              <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+          </button>
+          {#if showRetention}
+            <div class="retention-dropdown">
+              {#each retentionOptions as opt}
+                <button
+                  class="retention-option"
+                  class:selected={retentionDays === opt.value}
+                  onclick={() => setRetention(opt.value)}
+                >
+                  {opt.label()}
+                  {#if retentionDays === opt.value}
+                    <svg width="12" height="9" viewBox="0 0 12 9" fill="none">
+                      <path d="M1 4L4.5 7.5L11 1" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                    </svg>
+                  {/if}
+                </button>
+              {/each}
+            </div>
+          {/if}
+        {:else}
+          <span class="retention-btn"><span>...</span></span>
+        {/if}
+      </div>
+    </div>
+
+    {#if storagePath}
+      <div class="storage-path" title={storagePath}>
+        <span class="storage-label">{t('history.storageLocation')}</span>
+        <button class="storage-link" onclick={copyStoragePath} title={storagePath}>
+          <span>{displayPath(storagePath)}</span>
+        </button>
+        <span
+          class="storage-size"
+          class:warning={storageSizeTone() === 'warning'}
+          class:danger={storageSizeTone() === 'danger'}
+          title={storageSizeAvailable ? `${storageSizeBytes} bytes` : t('history.sizeUnknown')}
+        >
+          {t('history.storageSize', { size: formatSize() })}
+        </span>
+        <button
+          class="storage-icon-btn"
+          onclick={copyStoragePath}
+          title={storagePathCopied ? t('history.pathCopied') : t('history.copyPath')}
+          aria-label={storagePathCopied ? t('history.pathCopied') : t('history.copyPath')}
+        >
+          {#if storagePathCopied}
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
+              <path d="M2 7L5.5 10.5L12 4" stroke="var(--color-green)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+          {:else}
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
+              <rect x="5" y="5" width="7.5" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
+              <path d="M9 5V3C9 2.17 8.33 1.5 7.5 1.5H3C2.17 1.5 1.5 2.17 1.5 3V7.5C1.5 8.33 2.17 9 3 9H5" stroke="currentColor" stroke-width="1.2"/>
+            </svg>
+          {/if}
+        </button>
+        <button
+          class="storage-icon-btn"
+          onclick={openStorageFolder}
+          disabled={isOpeningFolder}
+          title={t('history.openFolder')}
+          aria-label={t('history.openFolder')}
+        >
+          <svg width="15" height="14" viewBox="0 0 15 14" fill="none">
+            <path d="M1.5 4.5H13.5V10.5C13.5 11.33 12.83 12 12 12H3C2.17 12 1.5 11.33 1.5 10.5V4.5Z" stroke="currentColor" stroke-width="1.2"/>
+            <path d="M1.5 4.8V3.5C1.5 2.67 2.17 2 3 2H5.4L6.6 3.2H12C12.83 3.2 13.5 3.87 13.5 4.7V4.8" stroke="currentColor" stroke-width="1.2"/>
+          </svg>
+        </button>
+      </div>
+    {/if}
+
+    <div class="toolbar-right">
+      <div class="export-selector">
+        <button
+          class="export-btn"
+          onclick={() => canExportAny() ? showExportMenu = !showExportMenu : flash(t('history.exportCorrectionCsvNoEntries'), 'info')}
+          disabled={isExportingCorrectionCsv || !statusLoaded}
+          title={canExportAny() ? t('history.exportCorrectionCsvPrivacy') : t('history.exportCorrectionCsvNoEntries')}
+        >
+          <span>{isExportingCorrectionCsv ? t('history.exportingCorrectionCsv') : t('history.exportCorrectionCsv')}</span>
+          <svg class="chevron" class:open={showExportMenu} width="10" height="6" viewBox="0 0 10 6" fill="none">
+            <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+          </svg>
+        </button>
+        {#if showExportMenu}
+          <div class="export-dropdown">
+            {#each exportScopeOptions as opt}
+              <button
+                class="export-option"
+                disabled={isExportingCorrectionCsv || opt.count() === 0}
+                title={opt.count() === 0 ? t('history.exportCorrectionCsvNoEntries') : ''}
+                onclick={() => exportCorrectionCsv(opt.value)}
+              >
+                <span>{opt.label()}</span>
+                <span class="option-count">{opt.count()}</span>
+              </button>
+            {/each}
+          </div>
+        {/if}
+      </div>
+    </div>
+  </div>
+
+  {#if statusText}
+    <div class="status-line" class:success={statusTone === 'success'} class:error={statusTone === 'error'} title={statusText}>
+      {statusText}
+    </div>
+  {/if}
+
+  <div class="section list-section">
+    {#if !pageLoaded || isPageLoading}
+      <div class="empty">
+        <p class="empty-title">{t('history.loading')}</p>
+      </div>
+    {:else if totalCount === 0}
+      <div class="empty">
+        <p class="empty-title">{t('history.empty')}</p>
+        <p class="empty-desc">{t('history.emptyDesc')}</p>
+      </div>
+    {:else if entries.length === 0}
+      <div class="empty">
+        <p class="empty-title">{t('history.pageEmpty')}</p>
+      </div>
+    {:else}
+      {#each entries as entry, i}
+        {#if i > 0}
+          <div class="divider"></div>
+        {/if}
+        <div class="history-row">
+          <div class="history-content">
+            <span class="history-time">{formatTime(entry.timestamp)}</span>
+            <span class="history-text">{entry.text}</span>
+          </div>
+          <div class="history-actions">
+            <button
+              class="action-btn copy-btn"
+              onclick={() => copyText(entry)}
+              title={copiedTs === entry.timestamp ? t('history.copied') : t('history.copy')}
+              aria-label={copiedTs === entry.timestamp ? t('history.copied') : t('history.copy')}
+            >
+              {#if copiedTs === entry.timestamp}
+                <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
+                  <path d="M2 7L5.5 10.5L12 4" stroke="var(--color-green)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
+              {:else}
+                <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
+                  <rect x="5" y="5" width="7.5" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
+                  <path d="M9 5V3C9 2.17 8.33 1.5 7.5 1.5H3C2.17 1.5 1.5 2.17 1.5 3V7.5C1.5 8.33 2.17 9 3 9H5" stroke="currentColor" stroke-width="1.2"/>
+                </svg>
+              {/if}
+            </button>
+            <button
+              class="action-btn delete-btn"
+              onclick={() => deleteEntry(entry.timestamp)}
+              disabled={deletingTs !== null}
+              title={t('history.delete')}
+              aria-label={t('history.delete')}
+            >
+              <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
+                <path d="M3 3.5L11 11.5M11 3.5L3 11.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
+              </svg>
+            </button>
+          </div>
+        </div>
+      {/each}
+    {/if}
+  </div>
+
+  {#if hasHistory()}
+    <div class="bottom-bar">
+      <div class="bottom-left">
+        <div class="clear-selector">
+          <button class="clear-menu-btn" onclick={() => showClearMenu = !showClearMenu}>
+            <span>{t('history.clearMenu')}</span>
+            <svg class="chevron" class:open={showClearMenu} width="10" height="6" viewBox="0 0 10 6" fill="none">
+              <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+          </button>
+          {#if showClearMenu}
+            <div class="clear-dropdown">
+              <button
+                class="clear-option"
+                disabled={exportedCount === 0 || isClearingExported}
+                onclick={() => { confirmMode = 'clearExported'; showClearMenu = false }}
+              >
+                <span>{t('history.clearExported')}</span>
+                <span class="option-count">{exportedCount}</span>
+              </button>
+              <button
+                class="clear-option danger-option"
+                disabled={isClearingAll}
+                onclick={() => { confirmMode = 'clearAll'; showClearMenu = false }}
+              >
+                {t('history.clearAll')}
+              </button>
+            </div>
+          {/if}
+        </div>
+
+        {#if confirmMode}
+          <div class="confirm-line">
+            <span>
+              {confirmMode === 'clearExported' ? t('history.clearExportedConfirm') : t('history.clearConfirm')}
+            </span>
+            <button
+              class="confirm-action"
+              onclick={confirmMode === 'clearExported' ? clearExported : clearAll}
+            >
+              {confirmMode === 'clearExported' ? t('history.clearExported') : t('history.clearAll')}
+            </button>
+            <button class="cancel-btn" onclick={() => confirmMode = null}>&times;</button>
+          </div>
+        {/if}
+      </div>
+
+      {#if hasPagination()}
+        <div class="pagination">
+          <button class="page-btn" onclick={() => goToPage(1)} disabled={!canGoPrev() || isPageLoading}>{t('history.firstPage')}</button>
+          <button class="page-btn" onclick={() => goToPage(page - 1)} disabled={!canGoPrev() || isPageLoading}>{t('history.prevPage')}</button>
+          <span class="page-input-wrap">
+            <input
+              class="page-input"
+              value={pageInput}
+              oninput={(e) => pageInput = (e.currentTarget as HTMLInputElement).value}
+              onkeydown={(e) => { if (e.key === 'Enter') commitPageInput() }}
+              onblur={commitPageInput}
+              aria-label={t('history.pageInput')}
+            />
+            <span>{t('history.totalPages', { total: String(totalPages) })}</span>
+          </span>
+          <span class="page-total">{t('history.totalEntries', { total: String(total) })}</span>
+          <button class="page-btn" onclick={() => goToPage(page + 1)} disabled={!canGoNext() || isPageLoading}>{t('history.nextPage')}</button>
+          <button class="page-btn" onclick={() => goToPage(totalPages)} disabled={!canGoNext() || isPageLoading}>{t('history.lastPage')}</button>
+        </div>
+      {/if}
+    </div>
+  {/if}
+</div>
+
+<style>
+  .page {
+    display: flex;
+    flex-direction: column;
+    height: calc(100vh - 40px);
+  }
+
+  .toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: var(--spacing-md);
+    padding: 0 2px;
+  }
+
+  .toolbar-left,
+  .toolbar-right {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+  }
+
+  .toolbar-label {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+  }
+
+  .storage-path {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    min-width: 0;
+    flex: 1;
+  }
+
+  .storage-label,
+  .storage-size {
+    font-size: var(--font-size-xs);
+    color: var(--color-tertiary-label);
+    white-space: nowrap;
+    flex-shrink: 0;
+  }
+
+  .storage-size.warning {
+    color: var(--color-orange);
+  }
+
+  .storage-size.danger {
+    color: var(--color-red);
+    font-weight: 600;
+  }
+
+  .storage-link {
+    display: block;
+    min-width: 96px;
+    max-width: 420px;
+    padding: 3px 0;
+    background: transparent;
+    border: none;
+    font-size: var(--font-size-xs);
+    color: var(--color-tertiary-label);
+    cursor: pointer;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    text-align: left;
+  }
+
+  .storage-link:hover {
+    color: var(--color-blue);
+  }
+
+  .storage-icon-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 24px;
+    height: 24px;
+    border: none;
+    background: transparent;
+    border-radius: var(--radius-sm);
+    color: var(--color-tertiary-label);
+    cursor: pointer;
+    flex-shrink: 0;
+  }
+
+  .storage-icon-btn:hover:not(:disabled) {
+    color: var(--color-blue);
+    background: var(--color-bg-secondary);
+  }
+
+  .storage-icon-btn:disabled {
+    opacity: 0.5;
+    cursor: default;
+  }
+
+  .retention-selector,
+  .export-selector,
+  .clear-selector {
+    position: relative;
+  }
+
+  .retention-btn,
+  .export-btn,
+  .clear-menu-btn {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    padding: 3px 0;
+    background: transparent;
+    border: none;
+    font-size: var(--font-size-sm);
+    font-weight: 500;
+    color: var(--color-blue);
+    cursor: pointer;
+    transition: opacity var(--transition-fast);
+  }
+
+  .clear-menu-btn {
+    color: var(--color-tertiary-label);
+  }
+
+  .retention-btn:hover,
+  .export-btn:hover,
+  .clear-menu-btn:hover {
+    opacity: 0.65;
+  }
+
+  .export-btn:disabled {
+    color: var(--color-tertiary-label);
+    cursor: default;
+    opacity: 0.5;
+  }
+
+  .chevron {
+    color: var(--color-tertiary-label);
+    transition: transform 0.2s ease;
+    flex-shrink: 0;
+  }
+
+  .chevron.open {
+    transform: rotate(180deg);
+  }
+
+  .retention-dropdown,
+  .export-dropdown,
+  .clear-dropdown {
+    position: absolute;
+    top: calc(100% + 4px);
+    min-width: 120px;
+    background: var(--color-bg-grouped-secondary);
+    border: 1px solid var(--color-separator);
+    border-radius: var(--radius-sm);
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+    z-index: 100;
+    overflow: hidden;
+  }
+
+  .retention-dropdown {
+    left: 0;
+  }
+
+  .export-dropdown,
+  .clear-dropdown {
+    right: 0;
+  }
+
+  .export-dropdown {
+    min-width: 210px;
+  }
+
+  .clear-dropdown {
+    min-width: 245px;
+  }
+
+  .retention-option,
+  .export-option,
+  .clear-option {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    width: 100%;
+    padding: 7px 12px;
+    background: transparent;
+    border: none;
+    font-size: var(--font-size-sm);
+    color: var(--color-label);
+    cursor: pointer;
+    text-align: left;
+    transition: background 0.15s ease;
+  }
+
+  .retention-option:hover,
+  .export-option:hover:not(:disabled),
+  .clear-option:hover:not(:disabled) {
+    background: var(--color-bg-secondary);
+  }
+
+  .export-option:disabled,
+  .clear-option:disabled {
+    cursor: default;
+    opacity: 0.5;
+  }
+
+  .clear-option {
+    white-space: nowrap;
+  }
+
+  .danger-option {
+    color: var(--color-red);
+  }
+
+  .retention-option.selected {
+    color: var(--color-blue);
+    font-weight: 500;
+  }
+
+  .retention-option + .retention-option,
+  .export-option + .export-option,
+  .clear-option + .clear-option {
+    border-top: 1px solid var(--color-separator);
+  }
+
+  .option-count {
+    color: var(--color-tertiary-label);
+    font-size: var(--font-size-xs);
+    font-variant-numeric: tabular-nums;
+  }
+
+  .confirm-line {
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
+    gap: 8px;
+    min-height: 28px;
+    min-width: 0;
+    font-size: var(--font-size-xs);
+    color: var(--color-secondary-label);
+  }
+
+  .confirm-line span {
+    max-width: 520px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .confirm-action {
+    padding: 3px 0;
+    background: transparent;
+    border: none;
+    color: var(--color-red);
+    font-size: var(--font-size-xs);
+    font-weight: 600;
+    cursor: pointer;
+    white-space: nowrap;
+  }
+
+  .cancel-btn {
+    padding: 0 4px;
+    background: transparent;
+    border: none;
+    font-size: var(--font-size-base);
+    color: var(--color-tertiary-label);
+    cursor: pointer;
+    line-height: 1;
+  }
+
+  .cancel-btn:hover {
+    color: var(--color-label);
+  }
+
+  .status-line {
+    min-height: 18px;
+    margin: -6px 2px var(--spacing-sm);
+    font-size: var(--font-size-xs);
+    color: var(--color-secondary-label);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .status-line.success {
+    color: var(--color-green);
+  }
+
+  .status-line.error {
+    color: var(--color-red);
+  }
+
+  .section {
+    background: var(--color-bg-grouped-secondary);
+    border-radius: var(--radius-md);
+    padding: var(--spacing-lg);
+  }
+
+  .list-section {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+  }
+
+  .empty {
+    text-align: center;
+    padding: 40px 0;
+  }
+
+  .empty-title {
+    font-size: var(--font-size-base);
+    color: var(--color-secondary-label);
+    font-weight: 500;
+  }
+
+  .empty-desc {
+    font-size: var(--font-size-xs);
+    color: var(--color-tertiary-label);
+    margin-top: 6px;
+  }
+
+  .history-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 8px 0;
+    gap: 12px;
+  }
+
+  .history-content {
+    display: flex;
+    align-items: baseline;
+    gap: 10px;
+    min-width: 0;
+    flex: 1;
+  }
+
+  .history-time {
+    font-size: var(--font-size-xs);
+    color: var(--color-tertiary-label);
+    white-space: nowrap;
+    flex-shrink: 0;
+    min-width: 40px;
+  }
+
+  .history-text {
+    font-size: var(--font-size-sm);
+    color: var(--color-label);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .history-actions {
+    display: flex;
+    gap: 4px;
+    flex-shrink: 0;
+    opacity: 0;
+    transition: opacity 0.15s ease;
+  }
+
+  .history-row:hover .history-actions {
+    opacity: 1;
+  }
+
+  .action-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 28px;
+    height: 28px;
+    border: none;
+    background: transparent;
+    border-radius: var(--radius-sm);
+    color: var(--color-tertiary-label);
+    cursor: pointer;
+    transition: all 0.15s ease;
+  }
+
+  .action-btn:hover:not(:disabled) {
+    background: var(--color-bg-secondary);
+  }
+
+  .action-btn:disabled {
+    opacity: 0.45;
+    cursor: default;
+  }
+
+  .copy-btn:hover {
+    color: var(--color-blue);
+  }
+
+  .delete-btn:hover {
+    color: var(--color-red);
+  }
+
+  .divider {
+    height: 1px;
+    background: var(--color-separator);
+  }
+
+  .bottom-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    padding: 10px 4px 0;
+    flex-shrink: 0;
+  }
+
+  .bottom-left {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    min-width: 0;
+  }
+
+  .bottom-bar .clear-dropdown {
+    top: auto;
+    bottom: calc(100% + 4px);
+    left: 0;
+    right: auto;
+  }
+
+  .pagination {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 8px;
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-xs);
+    flex-shrink: 0;
+  }
+
+  .page-btn {
+    height: 26px;
+    padding: 0 8px;
+    border: none;
+    border-radius: var(--radius-sm);
+    background: transparent;
+    color: var(--color-blue);
+    cursor: pointer;
+    font-size: var(--font-size-xs);
+  }
+
+  .page-btn:hover:not(:disabled) {
+    background: var(--color-bg-secondary);
+  }
+
+  .page-btn:disabled {
+    color: var(--color-tertiary-label);
+    cursor: default;
+    opacity: 0.55;
+  }
+
+  .page-input-wrap {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+  }
+
+  .page-input {
+    width: 42px;
+    height: 24px;
+    padding: 0 6px;
+    border: 1px solid var(--color-separator);
+    border-radius: var(--radius-sm);
+    background: var(--color-bg-grouped-secondary);
+    color: var(--color-label);
+    font-size: var(--font-size-xs);
+    text-align: center;
+  }
+
+  .page-input:focus {
+    outline: none;
+    border-color: var(--color-blue);
+  }
+
+  .page-total {
+    color: var(--color-tertiary-label);
+  }
+</style>
diff --git a/VoiceSnapGo/frontend/src/components/settings/HotkeysPage.svelte b/privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
similarity index 100%
rename from VoiceSnapGo/frontend/src/components/settings/HotkeysPage.svelte
rename to privatevoice.src/frontend/src/components/settings/HotkeysPage.svelte
diff --git a/VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte b/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
similarity index 96%
rename from VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte
rename to privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
index 57f3850..e2b9d09 100644
--- a/VoiceSnapGo/frontend/src/components/settings/PermissionsPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/PermissionsPage.svelte
@@ -127,7 +127,7 @@
   }
 
   .page-title {
-    font-size: 24px;
+    font-size: 22px;
     font-weight: 700;
     letter-spacing: 0;
   }
@@ -175,8 +175,8 @@
   }
 
   .summary-title {
-    font-size: var(--font-size-lg);
-    font-weight: 650;
+    font-size: var(--font-size-base);
+    font-weight: 500;
   }
 
   .summary-desc {
@@ -192,7 +192,7 @@
     color: var(--color-blue);
     cursor: pointer;
     font-size: var(--font-size-sm);
-    font-weight: 600;
+    font-weight: 500;
     padding: 8px 12px;
     transition: opacity var(--transition-fast);
     white-space: nowrap;
@@ -260,8 +260,8 @@
     display: inline-flex;
     align-items: center;
     gap: 8px;
-    font-size: var(--font-size-lg);
-    font-weight: 650;
+    font-size: var(--font-size-base);
+    font-weight: 500;
   }
 
   .optional-badge {
@@ -269,14 +269,14 @@
     background: var(--color-bg-secondary);
     color: var(--color-secondary-label);
     font-size: var(--font-size-xs);
-    font-weight: 600;
+    font-weight: 500;
     padding: 2px 7px;
   }
 
   .permission-state {
     color: var(--color-red);
     font-size: var(--font-size-sm);
-    font-weight: 600;
+    font-weight: 500;
     white-space: nowrap;
   }
 
diff --git a/VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte b/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
similarity index 93%
rename from VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte
rename to privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
index 10ee80c..b454ca9 100755
--- a/VoiceSnapGo/frontend/src/components/settings/SettingsWindow.svelte
+++ b/privatevoice.src/frontend/src/components/settings/SettingsWindow.svelte
@@ -6,6 +6,7 @@
   import HistoryPage from './HistoryPage.svelte'
   import UserDictPage from './UserDictPage.svelte'
   import AboutPage from './AboutPage.svelte'
+  import AppIcon from '../shared/AppIcon.svelte'
 
   const navItems = [
     { id: 'general', label: () => t('nav.home') },
@@ -27,8 +28,8 @@
   <!-- Sidebar -->
   <nav class="sidebar">
     <div class="sidebar-header">
-      <div class="app-icon">V</div>
-      <span class="app-name">VoiceSnap</span>
+      <AppIcon size={24} />
+      <span class="app-name">{t('app.name')}</span>
     </div>
 
     <div class="nav-items">
@@ -114,19 +115,6 @@
     gap: var(--spacing-sm);
     margin-bottom: var(--spacing-xl);
     -webkit-app-region: no-drag;
-  }
-
-  .app-icon {
-    width: 24px;
-    height: 24px;
-    background: var(--color-blue);
-    border-radius: 6px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    color: white;
-    font-weight: 700;
-    font-size: 11px;
   }
 
   .app-name {
diff --git a/privatevoice.src/frontend/src/components/settings/UserDictPage.svelte b/privatevoice.src/frontend/src/components/settings/UserDictPage.svelte
new file mode 100644
index 0000000..8ff1ebe
--- /dev/null
+++ b/privatevoice.src/frontend/src/components/settings/UserDictPage.svelte
@@ -0,0 +1,704 @@
+<script lang="ts">
+  import { Call } from '@wailsio/runtime'
+  import { t } from '../../lib/i18n'
+  import CorrectionImportPreviewDialog from './CorrectionImportPreviewDialog.svelte'
+
+  interface DictEntry {
+    id: number
+    from: string
+    to: string
+    enabled: boolean
+    createdAt: number
+    updatedAt: number
+  }
+
+  interface DictPageResult {
+    entries: DictEntry[]
+    total: number
+    page: number
+    pageSize: number
+    totalPages: number
+  }
+
+  const PAGE_SIZE = 100
+
+  let entries = $state<DictEntry[]>([])
+  let page = $state(1)
+  let pageInput = $state('1')
+  let pageSize = $state(PAGE_SIZE)
+  let total = $state(0)
+  let totalPages = $state(0)
+  let pageLoaded = $state(false)
+  let isPageLoading = $state(false)
+  let fromText = $state('')
+  let toText = $state('')
+  let statusText = $state('')
+  let statusTone = $state<'success' | 'error' | 'info'>('info')
+  let jsonImportInput: HTMLInputElement
+  let editingId = $state<number | null>(null)
+  let editFrom = $state('')
+  let editTo = $state('')
+  let correctionPreview = $state<any>(null)
+  let isPreviewingCorrectionCsv = $state(false)
+  let isConfirmingCorrectionImport = $state(false)
+  let pageRequestSeq = 0
+
+  function hasPagination(): boolean {
+    return totalPages > 1
+  }
+
+  function canGoPrev(): boolean {
+    return page > 1
+  }
+
+  function canGoNext(): boolean {
+    return totalPages > 0 && page < totalPages
+  }
+
+  async function loadPage(targetPage = page) {
+    const seq = ++pageRequestSeq
+    isPageLoading = true
+    try {
+      const result: DictPageResult = await Call.ByName(
+        'voicesnap/services.UserDictService.GetPage',
+        targetPage,
+        PAGE_SIZE,
+      )
+      if (seq !== pageRequestSeq) return
+      entries = result?.entries || []
+      total = result?.total || 0
+      page = result?.page || 1
+      pageInput = String(page)
+      pageSize = result?.pageSize || PAGE_SIZE
+      totalPages = result?.totalPages || 0
+      pageLoaded = true
+    } catch {
+      entries = []
+      total = 0
+      page = 1
+      pageInput = '1'
+      pageSize = PAGE_SIZE
+      totalPages = 0
+      pageLoaded = true
+      flash(t('userdict.loadFailed'), 'error')
+    } finally {
+      if (seq === pageRequestSeq) {
+        isPageLoading = false
+      }
+    }
+  }
+
+  function flash(message: string, tone: 'success' | 'error' | 'info' = 'success') {
+    statusText = message
+    statusTone = tone
+    setTimeout(() => {
+      if (statusText === message) statusText = ''
+    }, 1800)
+  }
+
+  async function addEntry() {
+    if (!fromText.trim()) return
+    try {
+      await Call.ByName('voicesnap/services.UserDictService.Add', fromText, toText)
+      fromText = ''
+      toText = ''
+      await loadPage(1)
+      flash(t('userdict.saved'), 'success')
+    } catch {
+      flash(t('userdict.saveFailed'), 'error')
+    }
+  }
+
+  function startEdit(entry: DictEntry) {
+    editingId = entry.id
+    editFrom = entry.from
+    editTo = entry.to
+  }
+
+  function cancelEdit() {
+    editingId = null
+    editFrom = ''
+    editTo = ''
+  }
+
+  async function saveEdit(entry: DictEntry) {
+    if (!editFrom.trim()) return
+    try {
+      const updated: any = await Call.ByName(
+        'voicesnap/services.UserDictService.Update',
+        entry.id,
+        editFrom,
+        editTo,
+        entry.enabled
+      )
+      entries = entries.map(e => e.id === entry.id ? updated : e)
+      await loadPage(page)
+      cancelEdit()
+      flash(t('userdict.saved'), 'success')
+    } catch {
+      flash(t('userdict.saveFailed'), 'error')
+    }
+  }
+
+  async function toggleEntry(entry: DictEntry) {
+    try {
+      const updated: any = await Call.ByName(
+        'voicesnap/services.UserDictService.Update',
+        entry.id,
+        entry.from,
+        entry.to,
+        !entry.enabled
+      )
+      entries = entries.map(e => e.id === entry.id ? updated : e)
+      await loadPage(page)
+    } catch {}
+  }
+
+  async function deleteEntry(id: number) {
+    try {
+      await Call.ByName('voicesnap/services.UserDictService.Delete', id)
+      await loadPage(page)
+    } catch {}
+  }
+
+  async function exportDict() {
+    try {
+      const path: any = await Call.ByName('voicesnap/services.UserDictService.ExportToFile')
+      if (path) {
+        flash(t('userdict.exportedTo', { path }))
+      }
+    } catch {
+      flash(t('userdict.exportFailed'), 'error')
+    }
+  }
+
+  function chooseImportFile() {
+    jsonImportInput?.click()
+  }
+
+  async function previewCorrectionCsv() {
+    if (isPreviewingCorrectionCsv) return
+
+    isPreviewingCorrectionCsv = true
+    try {
+      const preview: any = await Call.ByName('voicesnap/services.CorrectionCSVService.PreviewCorrectionCSVFromFile')
+      if (preview?.canceled) return
+      correctionPreview = preview
+    } catch {
+      flash(t('userdict.importCorrectionCsvFailed'), 'error')
+    } finally {
+      isPreviewingCorrectionCsv = false
+    }
+  }
+
+  async function confirmCorrectionImport() {
+    if (!correctionPreview?.previewId || isConfirmingCorrectionImport) return
+
+    isConfirmingCorrectionImport = true
+    try {
+      const result: any = await Call.ByName(
+        'voicesnap/services.CorrectionCSVService.ConfirmCorrectionCSVImport',
+        correctionPreview.previewId
+      )
+      correctionPreview = null
+      await loadPage(1)
+      flash(t('userdict.importCorrectionCsvSuccess', { count: String(result?.added || 0) }), 'success')
+    } catch {
+      flash(t('userdict.importCorrectionCsvFailed'), 'error')
+    } finally {
+      isConfirmingCorrectionImport = false
+    }
+  }
+
+  async function importDict(e: Event) {
+    const input = e.target as HTMLInputElement
+    const file = input.files?.[0]
+    if (!file) return
+
+    try {
+      const content = await file.text()
+      await Call.ByName('voicesnap/services.UserDictService.ImportJSON', content)
+      await loadPage(1)
+      flash(t('userdict.importJsonSuccess'), 'success')
+    } catch {
+      flash(t('userdict.importJsonFailed'), 'error')
+    } finally {
+      input.value = ''
+    }
+  }
+
+  function goToPage(targetPage: number) {
+    if (isPageLoading) return
+    loadPage(targetPage)
+  }
+
+  function commitPageInput() {
+    const parsed = Number.parseInt(pageInput.trim(), 10)
+    if (!Number.isFinite(parsed)) {
+      pageInput = String(page)
+      return
+    }
+    const max = totalPages > 0 ? totalPages : 1
+    const target = Math.min(Math.max(parsed, 1), max)
+    pageInput = String(target)
+    if (target !== page) {
+      goToPage(target)
+    }
+  }
+
+  loadPage(1)
+</script>
+
+<div class="page">
+  <div class="header">
+    <h1 class="title">{t('userdict.title')}</h1>
+    <p class="subtitle">{t('userdict.subtitle')}</p>
+  </div>
+
+  <div class="toolbar">
+    <div class="toolbar-actions userdict-actions">
+      <button class="secondary-btn" onclick={chooseImportFile}>{t('userdict.importJson')}</button>
+      <button class="secondary-btn" onclick={exportDict}>{t('userdict.export')}</button>
+      <input
+        bind:this={jsonImportInput}
+        class="hidden-input"
+        type="file"
+        accept="application/json,.json"
+        onchange={importDict}
+      />
+    </div>
+    <div class="toolbar-right">
+      {#if statusText}
+        <span class="status-text" class:error={statusTone === 'error'} title={statusText}>{statusText}</span>
+      {/if}
+      <button class="secondary-btn correction-btn" onclick={previewCorrectionCsv} disabled={isPreviewingCorrectionCsv}>
+        {isPreviewingCorrectionCsv ? t('userdict.readingCorrectionCsv') : t('userdict.importCorrectionCsv')}
+      </button>
+    </div>
+  </div>
+
+  {#if correctionPreview}
+    <CorrectionImportPreviewDialog
+      preview={correctionPreview}
+      confirming={isConfirmingCorrectionImport}
+      oncancel={() => correctionPreview = null}
+      onconfirm={confirmCorrectionImport}
+    />
+  {/if}
+
+  <div class="section add-section">
+    <div class="field">
+      <label for="from">{t('userdict.from')}</label>
+      <input id="from" bind:value={fromText} placeholder={t('userdict.fromPlaceholder')} />
+    </div>
+    <div class="field">
+      <label for="to">{t('userdict.to')}</label>
+      <input id="to" bind:value={toText} placeholder={t('userdict.toPlaceholder')} />
+    </div>
+    <button class="primary-btn" onclick={addEntry} disabled={!fromText.trim()}>
+      {t('userdict.add')}
+    </button>
+  </div>
+
+  <div class="section list-section">
+    {#if !pageLoaded || isPageLoading}
+      <div class="empty">
+        <p class="empty-title">{t('userdict.loading')}</p>
+      </div>
+    {:else if total === 0}
+      <div class="empty">
+        <p class="empty-title">{t('userdict.empty')}</p>
+        <p class="empty-desc">{t('userdict.emptyDesc')}</p>
+      </div>
+    {:else if entries.length === 0}
+      <div class="empty">
+        <p class="empty-title">{t('userdict.pageEmpty')}</p>
+      </div>
+    {:else}
+      {#each entries as entry, i}
+        {#if i > 0}
+          <div class="divider"></div>
+        {/if}
+        <div class="dict-row" class:disabled={!entry.enabled}>
+          <button
+            class="enable-btn"
+            class:enabled={entry.enabled}
+            title={entry.enabled ? t('userdict.disable') : t('userdict.enable')}
+            onclick={() => toggleEntry(entry)}
+          >
+            {entry.enabled ? '✓' : ''}
+          </button>
+
+          {#if editingId === entry.id}
+            <div class="edit-grid">
+              <input bind:value={editFrom} aria-label={t('userdict.from')} />
+              <input bind:value={editTo} aria-label={t('userdict.to')} />
+            </div>
+            <div class="row-actions">
+              <button class="text-btn" onclick={() => saveEdit(entry)} disabled={!editFrom.trim()}>
+                {t('userdict.save')}
+              </button>
+              <button class="icon-btn" title={t('userdict.cancel')} onclick={cancelEdit}>×</button>
+            </div>
+          {:else}
+            <div class="dict-content">
+              <span class="from-text">{entry.from}</span>
+              <span class="arrow">→</span>
+              <span class="to-text">{entry.to}</span>
+            </div>
+            <div class="row-actions">
+              <button class="text-btn" onclick={() => startEdit(entry)}>{t('userdict.edit')}</button>
+              <button class="icon-btn" title={t('userdict.delete')} onclick={() => deleteEntry(entry.id)}>×</button>
+            </div>
+          {/if}
+        </div>
+      {/each}
+    {/if}
+  </div>
+
+  {#if hasPagination()}
+    <div class="pagination">
+      <button class="page-btn" onclick={() => goToPage(1)} disabled={!canGoPrev() || isPageLoading}>{t('history.firstPage')}</button>
+      <button class="page-btn" onclick={() => goToPage(page - 1)} disabled={!canGoPrev() || isPageLoading}>{t('history.prevPage')}</button>
+      <span class="page-input-wrap">
+        <input
+          class="page-input"
+          value={pageInput}
+          oninput={(e) => pageInput = (e.currentTarget as HTMLInputElement).value}
+          onkeydown={(e) => { if (e.key === 'Enter') commitPageInput() }}
+          onblur={commitPageInput}
+          aria-label={t('history.pageInput')}
+        />
+        <span>{t('history.totalPages', { total: String(totalPages) })}</span>
+      </span>
+      <span class="page-total">{t('history.totalEntries', { total: String(total) })}</span>
+      <button class="page-btn" onclick={() => goToPage(page + 1)} disabled={!canGoNext() || isPageLoading}>{t('history.nextPage')}</button>
+      <button class="page-btn" onclick={() => goToPage(totalPages)} disabled={!canGoNext() || isPageLoading}>{t('history.lastPage')}</button>
+    </div>
+  {/if}
+</div>
+
+<style>
+  .page {
+    display: flex;
+    flex-direction: column;
+    min-height: calc(100vh - 40px);
+  }
+
+  .header {
+    margin-bottom: var(--spacing-md);
+  }
+
+  .title {
+    font-size: 22px;
+    font-weight: 700;
+  }
+
+  .subtitle {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+    margin-top: 6px;
+  }
+
+  .toolbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: var(--spacing-md);
+    min-height: 30px;
+    margin-bottom: var(--spacing-md);
+  }
+
+  .toolbar-actions,
+  .toolbar-right,
+  .row-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .toolbar-right {
+    margin-left: auto;
+    min-width: 0;
+  }
+
+  .userdict-actions {
+    flex-shrink: 0;
+  }
+
+  .correction-btn {
+    flex-shrink: 0;
+  }
+
+  .status-text {
+    font-size: var(--font-size-sm);
+    color: var(--color-green);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    min-width: 0;
+    text-align: right;
+  }
+
+  .status-text.error {
+    color: var(--color-red);
+  }
+
+  .hidden-input {
+    display: none;
+  }
+
+  .section {
+    background: var(--color-bg-grouped-secondary);
+    border-radius: var(--radius-md);
+    padding: var(--spacing-lg);
+    margin-bottom: var(--spacing-md);
+  }
+
+  .add-section {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
+    gap: var(--spacing-md);
+    align-items: end;
+  }
+
+  .field {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+  }
+
+  label {
+    font-size: var(--font-size-sm);
+    color: var(--color-secondary-label);
+  }
+
+  input {
+    width: 100%;
+    height: 34px;
+    border: 1px solid var(--color-separator);
+    border-radius: var(--radius-sm);
+    padding: 0 10px;
+    font: inherit;
+    color: var(--color-label);
+    background: var(--color-bg-secondary);
+    outline: none;
+  }
+
+  input:focus {
+    border-color: var(--color-blue);
+    background: var(--color-bg-primary);
+  }
+
+  button {
+    font: inherit;
+    cursor: pointer;
+    transition: opacity var(--transition-fast), background var(--transition-fast);
+  }
+
+  button:disabled {
+    cursor: default;
+    opacity: 0.45;
+  }
+
+  .primary-btn,
+  .secondary-btn,
+  .text-btn {
+    border: none;
+    border-radius: var(--radius-sm);
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+  }
+
+  .primary-btn {
+    height: 34px;
+    padding: 0 16px;
+    color: white;
+    background: var(--color-blue);
+  }
+
+  .secondary-btn {
+    height: 28px;
+    padding: 0 12px;
+    color: var(--color-blue);
+    background: rgba(0, 122, 255, 0.08);
+  }
+
+  .text-btn {
+    color: var(--color-blue);
+    background: transparent;
+  }
+
+  .icon-btn,
+  .enable-btn {
+    border: none;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+  }
+
+  .icon-btn {
+    width: 26px;
+    height: 26px;
+    border-radius: 50%;
+    color: var(--color-secondary-label);
+    background: transparent;
+    font-size: 20px;
+    line-height: 1;
+  }
+
+  .icon-btn:hover {
+    background: rgba(0, 0, 0, 0.05);
+    color: var(--color-red);
+  }
+
+  .enable-btn {
+    width: 20px;
+    height: 20px;
+    border-radius: 50%;
+    border: 1px solid var(--color-separator);
+    color: white;
+    background: var(--color-bg-secondary);
+    font-size: 13px;
+    font-weight: 700;
+  }
+
+  .enable-btn.enabled {
+    border-color: var(--color-green);
+    background: var(--color-green);
+  }
+
+  .list-section {
+    flex: 1;
+    overflow: auto;
+  }
+
+  .dict-row {
+    display: grid;
+    grid-template-columns: 20px minmax(0, 1fr) auto;
+    gap: var(--spacing-md);
+    align-items: center;
+    min-height: 48px;
+  }
+
+  .dict-row.disabled .dict-content {
+    opacity: 0.45;
+  }
+
+  .dict-content {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+    gap: 10px;
+    align-items: center;
+    min-width: 0;
+  }
+
+  .from-text,
+  .to-text {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: var(--font-size-base);
+  }
+
+  .from-text {
+    color: var(--color-label);
+  }
+
+  .to-text {
+    color: var(--color-blue);
+    font-weight: 500;
+  }
+
+  .arrow {
+    color: var(--color-tertiary-label);
+  }
+
+  .edit-grid {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+    gap: var(--spacing-md);
+  }
+
+  .divider {
+    height: 1px;
+    background: var(--color-separator);
+    margin: 6px 0 6px 32px;
+  }
+
+  .empty {
+    text-align: center;
+    padding: var(--spacing-xxl) 0;
+  }
+
+  .empty-title {
+    font-weight: 600;
+    color: var(--color-secondary-label);
+  }
+
+  .empty-desc {
+    font-size: var(--font-size-sm);
+    color: var(--color-tertiary-label);
+    margin-top: 6px;
+  }
+
+  .pagination {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 8px;
+    padding: 0 4px;
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-xs);
+    flex-shrink: 0;
+  }
+
+  .page-btn {
+    height: 26px;
+    padding: 0 8px;
+    border: none;
+    border-radius: var(--radius-sm);
+    background: transparent;
+    color: var(--color-blue);
+    cursor: pointer;
+    font-size: var(--font-size-xs);
+  }
+
+  .page-btn:hover:not(:disabled) {
+    background: var(--color-bg-secondary);
+  }
+
+  .page-btn:disabled {
+    color: var(--color-tertiary-label);
+    cursor: default;
+    opacity: 0.55;
+  }
+
+  .page-input-wrap {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+  }
+
+  .page-input {
+    width: 42px;
+    height: 24px;
+    padding: 0 6px;
+    border: 1px solid var(--color-separator);
+    border-radius: var(--radius-sm);
+    background: var(--color-bg-grouped-secondary);
+    color: var(--color-label);
+    font-size: var(--font-size-xs);
+    text-align: center;
+  }
+
+  .page-input:focus {
+    outline: none;
+    border-color: var(--color-blue);
+  }
+
+  .page-total {
+    color: var(--color-tertiary-label);
+  }
+</style>
diff --git a/privatevoice.src/frontend/src/components/shared/AppIcon.svelte b/privatevoice.src/frontend/src/components/shared/AppIcon.svelte
new file mode 100644
index 0000000..5e09c71
--- /dev/null
+++ b/privatevoice.src/frontend/src/components/shared/AppIcon.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+  import appIconUrl from '../../assets/app-icon.png'
+
+  interface Props {
+    size?: number
+    label?: string
+    elevated?: boolean
+  }
+
+  let {
+    size = 56,
+    label = 'PrivateVoice Input',
+    elevated = false,
+  }: Props = $props()
+</script>
+
+<img
+  class="app-icon-image"
+  class:elevated
+  src={appIconUrl}
+  alt={label}
+  style={`width: ${size}px; height: ${size}px;`}
+  draggable="false"
+/>
+
+<style>
+  .app-icon-image {
+    display: block;
+    object-fit: contain;
+    flex-shrink: 0;
+  }
+
+  .app-icon-image.elevated {
+    filter: drop-shadow(0 10px 24px rgba(0, 96, 180, 0.18));
+  }
+</style>
diff --git a/VoiceSnapGo/frontend/src/components/shared/ToggleSwitch.svelte b/privatevoice.src/frontend/src/components/shared/ToggleSwitch.svelte
similarity index 100%
rename from VoiceSnapGo/frontend/src/components/shared/ToggleSwitch.svelte
rename to privatevoice.src/frontend/src/components/shared/ToggleSwitch.svelte
diff --git a/VoiceSnapGo/frontend/src/components/update/UpdateDialog.svelte b/privatevoice.src/frontend/src/components/update/UpdateDialog.svelte
similarity index 100%
rename from VoiceSnapGo/frontend/src/components/update/UpdateDialog.svelte
rename to privatevoice.src/frontend/src/components/update/UpdateDialog.svelte
diff --git a/privatevoice.src/frontend/src/lib/i18n/en.json b/privatevoice.src/frontend/src/lib/i18n/en.json
new file mode 100755
index 0000000..94b25f3
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/en.json
@@ -0,0 +1,245 @@
+{
+  "app": {
+    "name": "PrivateVoice Input",
+    "secondaryName": "私语输入"
+  },
+  "nav": {
+    "home": "Home",
+    "userdict": "Dictionary",
+    "permissions": "Permissions",
+    "history": "History",
+    "about": "About"
+  },
+  "status": {
+    "loading": "Loading",
+    "ready": "Ready",
+    "recording": "Recording",
+    "processing": "Processing",
+    "done": "Done",
+    "cancelled": "Cancelled",
+    "noVoice": "No voice detected",
+    "noContent": "No content recognized",
+    "error": "Recognition error",
+    "engineNotReady": "Engine not ready",
+    "initializing": "Initializing..."
+  },
+  "indicator": {
+    "holdToSpeak": "Hold {key} to speak",
+    "recording": "Recording...",
+    "processing": "Processing...",
+    "done": "Typed"
+  },
+  "home": {
+    "tagline": "Private.Offline",
+    "description": "Hold {key} to speak, release to recognize and type",
+    "hotkeyLabel": "Trigger Hotkey",
+    "modeHint": "Hold to record, release to type · Tap to start free talk, tap again to stop",
+    "modeHintTap": "Tap to start speaking, tap again to stop and type",
+    "modeHintHold": "Hold to speak, release to stop and type"
+  },
+  "settings": {
+    "inputDevice": "Input Device",
+    "hotkeyMode": "Voice Input Mode",
+    "hotkeyModeTap": "Tap",
+    "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",
+    "autoHide": "Auto-hide Indicator",
+    "autoHideDesc": "Automatically hide the floating indicator after completion",
+    "soundFeedback": "Sound Feedback",
+    "soundFeedbackDesc": "Play a beep when recording starts and stops",
+    "copyToClipboard": "Save Recognition to Clipboard",
+    "copyToClipboardDesc": "Keep recognized text in the clipboard after input; off uses it only temporarily",
+    "startAtLogin": "Start at Login",
+    "startAtLoginDesc": "Launch PrivateVoice Input when you log in",
+    "hideDockIcon": "Hide Dock Icon",
+    "hideDockIconDesc": "Show PrivateVoice Input only in the menu bar"
+  },
+  "permissions": {
+    "title": "Permission Check",
+    "subtitle": "PrivateVoice Input needs these permissions for recording, global hotkeys, and automatic text input",
+    "refresh": "Refresh",
+    "grant": "Grant",
+    "granted": "Granted",
+    "missing": "Missing",
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "optional": "Optional",
+    "checking": "Checking",
+    "allGranted": "All permissions are ready",
+    "needsAttention": "Some permissions need attention",
+    "summaryDesc": "This turns green automatically once permissions are granted",
+    "microphone": {
+      "title": "Microphone",
+      "desc": "Records your speech before recognition can start."
+    },
+    "accessibility": {
+      "title": "Accessibility",
+      "desc": "Types recognized text into the active field and supports simulated paste/typing."
+    },
+    "inputMonitoring": {
+      "title": "Input Monitoring",
+      "desc": "Listens for the global right Alt hotkey while other apps are in front."
+    },
+    "backgroundActivity": {
+      "title": "Background Activity / Login Item",
+      "desc": "Starts PrivateVoice Input after login and allows it to run as a menu bar app in the background; it does not affect the currently running voice input."
+    }
+  },
+  "hotkeys": {
+    "title": "Trigger Hotkey",
+    "current": "Current Hotkey",
+    "change": "Change",
+    "pressAnyKey": "Press any key...",
+    "updated": "Updated",
+    "hintTitle": "Two Input Modes",
+    "hintHold": "Hold to Talk",
+    "hintHoldDesc": "Hold the hotkey to record, release to recognize and type. Best for quick phrases.",
+    "hintFree": "Free Talk",
+    "hintFreeDesc": "Tap the hotkey once to start, speak freely, tap again to stop and type. Best for long dictation.",
+    "hintCombo": "Keyboard shortcuts (e.g. Ctrl+C) won't trigger recording."
+  },
+  "about": {
+    "version": "Version",
+    "engineMode": "Engine",
+    "website": "Website",
+    "checkUpdate": "Check for Updates",
+    "checkingUpdate": "Checking...",
+    "isLatestVersion": "Up to date",
+    "checkFailed": "Check failed",
+    "tagline": "Private.Offline",
+    "slogan": "Private.Offline",
+    "legalText": "This app is a secondary development based on the open-source VoiceSnap project and follows the MIT License.",
+    "legalDetails": "For complete copyright notices, license text, and improvement details, visit:",
+    "supportUrl": "https://shanghai3168.github.io/privatevoice-support/",
+    "openSupport": "Open copyright and license details",
+    "copyright": "© {year} PrivateVoice Input · MIT License"
+  },
+  "history": {
+    "title": "Recognition History",
+    "retention": "Keep History",
+    "days7": "7 Days",
+    "days30": "30 Days",
+    "days90": "90 Days",
+    "forever": "Forever",
+    "empty": "No history yet",
+    "emptyDesc": "Recognition results will appear here",
+    "loading": "Loading history",
+    "loadFailed": "Failed to load history",
+    "pageEmpty": "No history on this page",
+    "copied": "Copied",
+    "copy": "Copy",
+    "delete": "Delete",
+    "deleteFailed": "Delete failed",
+    "storageLocation": "Saved At",
+    "storageSize": "Size {size}",
+    "sizeUnknown": "--",
+    "copyPath": "Copy Path",
+    "pathCopied": "Path Copied",
+    "openFolder": "Open Folder",
+    "openFolderFailed": "Failed to open folder",
+    "exportCorrectionCsv": "Export Correction CSV",
+    "exportTodayUnprocessed": "Export Today Unprocessed",
+    "exportLast7DaysUnprocessed": "Export Last 7 Days Unprocessed",
+    "exportAllUnprocessed": "Export All Unprocessed",
+    "exportingCorrectionCsv": "Exporting...",
+    "exportCorrectionCsvSuccess": "Exported to {path}",
+    "exportCorrectionCsvFailed": "Export failed",
+    "exportCorrectionCsvNoEntries": "No matching unprocessed history to export",
+    "exportCorrectionCsvPrivacy": "The exported CSV contains local recognition history. Please keep it safe.",
+    "clearMenu": "Clear",
+    "clearAll": "Clear All",
+    "clearConfirm": "Are you sure you want to clear all history?",
+    "clearAllSuccess": "All history cleared",
+    "clearExported": "Clear Exported CSV History",
+    "clearExportedConfirm": "Clear history already exported to correction CSV? Exported only means a CSV was written successfully. It does not mean the CSV was reviewed or imported. Unexported history will be kept. This cannot be undone, so make sure the CSV is saved.",
+    "clearExportedSuccess": "Cleared {count} exported entries",
+    "clearExportedNoEntries": "No exported CSV history to clear",
+    "clearFailed": "Clear failed",
+    "retentionFailed": "Failed to update retention",
+    "firstPage": "First",
+    "prevPage": "Previous",
+    "nextPage": "Next",
+    "lastPage": "Last",
+    "pageInput": "Page",
+    "totalPages": "{total} pages",
+    "totalEntries": "{total} entries",
+    "today": "Today",
+    "yesterday": "Yesterday"
+  },
+  "userdict": {
+    "title": "User Dictionary",
+    "subtitle": "Replace commonly misrecognized words with the spelling you want to keep",
+    "from": "Recognized As",
+    "to": "Replace With",
+    "fromPlaceholder": "e.g. voice snap",
+    "toPlaceholder": "e.g. PrivateVoice Input",
+    "add": "Add",
+    "edit": "Edit",
+    "save": "Save",
+    "cancel": "Cancel",
+    "delete": "Delete",
+    "enable": "Enable",
+    "disable": "Disable",
+    "import": "Import",
+    "importJson": "Import User Dictionary",
+    "importJsonSuccess": "Dictionary JSON imported",
+    "importJsonFailed": "Dictionary JSON import failed",
+    "export": "Export User Dictionary",
+    "importCorrectionCsv": "Import Correction CSV",
+    "readingCorrectionCsv": "Reading...",
+    "previewTitle": "Correction CSV Preview",
+    "previewFile": "File: {file}",
+    "previewWillAdd": "Will Add",
+    "previewCsvDuplicate": "CSV Duplicate",
+    "previewDictDuplicate": "Dictionary Duplicate",
+    "previewConflict": "Conflict",
+    "previewInvalid": "Invalid",
+    "previewEmpty": "Empty",
+    "previewLimitExceeded": "Limit",
+    "previewTruncated": "Prioritizing the first {count} key details. Summary includes all candidates.",
+    "previewNoNewRules": "No new rules to import",
+    "previewNoDetails": "No details to show",
+    "previewRowSlot": "Row {row} · Slot {slot}",
+    "confirmImport": "Import New Rules",
+    "confirmingImport": "Importing...",
+    "importCorrectionCsvSuccess": "Imported {count} new rules",
+    "importCorrectionCsvFailed": "Correction CSV import failed",
+    "statusNew": "New",
+    "statusCsvDuplicate": "CSV Duplicate",
+    "statusDictDuplicate": "Dictionary Duplicate",
+    "statusConflict": "Conflict",
+    "statusInvalid": "Invalid",
+    "statusEmpty": "Empty",
+    "statusLimit": "Limit",
+    "imported": "Imported",
+    "exported": "Exported",
+    "exportedTo": "Exported to {path}",
+    "saved": "Saved",
+    "saveFailed": "Save failed",
+    "importFailed": "Import failed",
+    "exportFailed": "Export failed",
+    "empty": "No words yet",
+    "emptyDesc": "Add names, brands, or domain terms and recognition output will be replaced automatically",
+    "loading": "Loading dictionary",
+    "loadFailed": "Failed to load dictionary",
+    "pageEmpty": "No dictionary rules on this page"
+  },
+  "onboarding": {
+    "title": "First Time Setup",
+    "syncModel": "Downloading voice model...",
+    "optimizing": "Optimizing...",
+    "extracting": "Extracting model files...",
+    "complete": "Complete",
+    "failed": "Setup failed"
+  },
+  "update": {
+    "title": "Update Available",
+    "version": "Version {version}",
+    "releaseNotes": "Release Notes",
+    "download": "Update Now",
+    "later": "Later",
+    "downloading": "Downloading...",
+    "applying": "Applying update..."
+  }
+}
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/index.ts b/privatevoice.src/frontend/src/lib/i18n/index.ts
similarity index 100%
rename from VoiceSnapGo/frontend/src/lib/i18n/index.ts
rename to privatevoice.src/frontend/src/lib/i18n/index.ts
diff --git a/privatevoice.src/frontend/src/lib/i18n/zh.json b/privatevoice.src/frontend/src/lib/i18n/zh.json
new file mode 100755
index 0000000..3b7e8f5
--- /dev/null
+++ b/privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -0,0 +1,245 @@
+{
+  "app": {
+    "name": "私语输入",
+    "secondaryName": "PrivateVoice Input"
+  },
+  "nav": {
+    "home": "首页",
+    "userdict": "词库",
+    "permissions": "权限",
+    "history": "历史",
+    "about": "关于"
+  },
+  "status": {
+    "loading": "加载中",
+    "ready": "就绪",
+    "recording": "录音中",
+    "processing": "识别中",
+    "done": "完成",
+    "cancelled": "已取消",
+    "noVoice": "未检测到语音",
+    "noContent": "无识别结果",
+    "error": "识别出错",
+    "engineNotReady": "引擎未就绪",
+    "initializing": "初始化中..."
+  },
+  "indicator": {
+    "holdToSpeak": "按住{key}说话",
+    "recording": "正在录音...",
+    "processing": "识别中...",
+    "done": "已输入"
+  },
+  "home": {
+    "tagline": "永远离线·稳定极速",
+    "description": "按住 {key} 说话,松开即识别并输入文字",
+    "hotkeyLabel": "触发热键",
+    "modeHint": "长按录音,松开输入 · 短按开始自由说话,再按结束",
+    "modeHintTap": "点按开始说话,再点按结束并输入",
+    "modeHintHold": "长按开始说话,松开结束并输入"
+  },
+  "settings": {
+    "inputDevice": "输入设备",
+    "hotkeyMode": "语音输入方式",
+    "hotkeyModeTap": "点按",
+    "hotkeyModeHold": "长按",
+    "hotkeyModeTapDesc": "点一下快捷键开始说话,再点一下结束并输入",
+    "hotkeyModeHoldDesc": "按住快捷键开始说话,松开后结束并输入",
+    "autoHide": "自动隐藏指示器",
+    "autoHideDesc": "操作完成后自动隐藏浮动指示器",
+    "soundFeedback": "提示音",
+    "soundFeedbackDesc": "录音开始和结束时播放提示音",
+    "copyToClipboard": "识别内容保存到剪贴板",
+    "copyToClipboardDesc": "输入文字后仍保留在剪贴板,关闭后仅临时用于上屏",
+    "startAtLogin": "开机自启动",
+    "startAtLoginDesc": "登录系统时自动启动私语输入",
+    "hideDockIcon": "隐藏 Dock 图标",
+    "hideDockIconDesc": "只在菜单栏显示私语输入图标"
+  },
+  "permissions": {
+    "title": "权限检查",
+    "subtitle": "私语输入需要这些权限才能稳定完成录音、全局热键和自动上屏",
+    "refresh": "刷新",
+    "grant": "去授权",
+    "granted": "已授权",
+    "missing": "未授权",
+    "enabled": "已启用",
+    "disabled": "未启用",
+    "optional": "可选",
+    "checking": "检查中",
+    "allGranted": "所有权限已就绪",
+    "needsAttention": "有权限需要处理",
+    "summaryDesc": "授权完成后这里会自动变成绿灯",
+    "microphone": {
+      "title": "麦克风",
+      "desc": "用于录制你的语音,这是开始识别前必须拿到的权限。"
+    },
+    "accessibility": {
+      "title": "辅助功能",
+      "desc": "用于把识别结果自动输入到当前文本框,也用于模拟粘贴和键盘输入。"
+    },
+    "inputMonitoring": {
+      "title": "输入监控",
+      "desc": "用于在私语输入窗口不可见、其他 App 正在前台时监听右 Alt 全局热键。"
+    },
+    "backgroundActivity": {
+      "title": "后台活动 / 登录项",
+      "desc": "用于登录系统后自动启动私语输入,并允许它作为菜单栏应用在后台运行;不影响当前已经打开的语音输入。"
+    }
+  },
+  "hotkeys": {
+    "title": "触发热键",
+    "current": "当前热键",
+    "change": "修改",
+    "pressAnyKey": "请按任意键...",
+    "updated": "已更新",
+    "hintTitle": "两种输入模式",
+    "hintHold": "按住说话",
+    "hintHoldDesc": "长按热键开始录音,松开即停止识别并输入文字,适合快速短语。",
+    "hintFree": "自由说话",
+    "hintFreeDesc": "短按一下热键开始,连续说话,再短按一下停止并输入文字,适合长段口述。",
+    "hintCombo": "使用组合键(如 Ctrl+C)不会触发录音。"
+  },
+  "about": {
+    "version": "版本",
+    "engineMode": "推理引擎",
+    "website": "官方网站",
+    "checkUpdate": "检查更新",
+    "checkingUpdate": "正在检查...",
+    "isLatestVersion": "已是最新版本",
+    "checkFailed": "检查失败",
+    "tagline": "永远离线·稳定极速",
+    "slogan": "Private.Offline",
+    "legalText": "本应用基于开源项目 VoiceSnap 进行二次开发,遵循 MIT License。",
+    "legalDetails": "完整版权声明、许可文本及改进详情,请访问:",
+    "supportUrl": "https://shanghai3168.github.io/privatevoice-support/",
+    "openSupport": "打开版权与许可说明",
+    "copyright": "© {year} PrivateVoice Input · MIT License"
+  },
+  "history": {
+    "title": "识别历史",
+    "retention": "保留时长",
+    "days7": "7 天",
+    "days30": "30 天",
+    "days90": "90 天",
+    "forever": "永久",
+    "empty": "暂无历史记录",
+    "emptyDesc": "识别结果会自动保存在这里",
+    "loading": "正在加载历史记录",
+    "loadFailed": "历史记录加载失败",
+    "pageEmpty": "当前页没有历史记录",
+    "copied": "已复制",
+    "copy": "复制",
+    "delete": "删除",
+    "deleteFailed": "删除失败",
+    "storageLocation": "保存位置",
+    "storageSize": "大小 {size}",
+    "sizeUnknown": "--",
+    "copyPath": "复制路径",
+    "pathCopied": "路径已复制",
+    "openFolder": "打开所在文件夹",
+    "openFolderFailed": "打开文件夹失败",
+    "exportCorrectionCsv": "导出勘误 CSV",
+    "exportTodayUnprocessed": "导出今天未处理",
+    "exportLast7DaysUnprocessed": "导出近 7 天未处理",
+    "exportAllUnprocessed": "导出全部未处理",
+    "exportingCorrectionCsv": "正在导出...",
+    "exportCorrectionCsvSuccess": "已导出到 {path}",
+    "exportCorrectionCsvFailed": "导出失败",
+    "exportCorrectionCsvNoEntries": "没有符合条件的未处理历史记录",
+    "exportCorrectionCsvPrivacy": "导出的 CSV 包含本地识别历史,请妥善保管",
+    "clearMenu": "清理",
+    "clearAll": "清除所有",
+    "clearConfirm": "确定要清除所有历史记录吗?",
+    "clearAllSuccess": "已清除所有历史",
+    "clearExported": "清除已导出 CSV 的历史",
+    "clearExportedConfirm": "确认清除已导出过勘误 CSV 的历史?这里的“已导出”只表示已经成功写出 CSV,不代表已经完成校对或导入。未导出的历史会保留。删除后历史页不可恢复,请确认 CSV 已妥善保存。",
+    "clearExportedSuccess": "已清除 {count} 条已导出历史",
+    "clearExportedNoEntries": "没有已导出过 CSV 的历史",
+    "clearFailed": "清理失败",
+    "retentionFailed": "保留时长设置失败",
+    "firstPage": "首页",
+    "prevPage": "上一页",
+    "nextPage": "下一页",
+    "lastPage": "末页",
+    "pageInput": "页码",
+    "totalPages": "共 {total} 页",
+    "totalEntries": "共 {total} 条",
+    "today": "今天",
+    "yesterday": "昨天"
+  },
+  "userdict": {
+    "title": "用户词库",
+    "subtitle": "把常被识别错的词替换成你想保留的写法",
+    "from": "识别结果",
+    "to": "替换为",
+    "fromPlaceholder": "例如:私语输入",
+    "toPlaceholder": "例如:PrivateVoice Input",
+    "add": "添加",
+    "edit": "编辑",
+    "save": "保存",
+    "cancel": "取消",
+    "delete": "删除",
+    "enable": "启用",
+    "disable": "停用",
+    "import": "导入",
+    "importJson": "导入用户词库",
+    "importJsonSuccess": "词库 JSON 已导入",
+    "importJsonFailed": "词库 JSON 导入失败",
+    "export": "导出用户词库",
+    "importCorrectionCsv": "导入勘误表",
+    "readingCorrectionCsv": "正在读取...",
+    "previewTitle": "导入勘误表预览",
+    "previewFile": "文件:{file}",
+    "previewWillAdd": "将新增",
+    "previewCsvDuplicate": "CSV 内重复",
+    "previewDictDuplicate": "词库重复",
+    "previewConflict": "冲突",
+    "previewInvalid": "无效",
+    "previewEmpty": "空项",
+    "previewLimitExceeded": "超限",
+    "previewTruncated": "优先显示前 {count} 条关键明细,摘要统计已包含全部候选",
+    "previewNoNewRules": "没有可导入的新规则",
+    "previewNoDetails": "没有可显示的明细",
+    "previewRowSlot": "第 {row} 行 · 第 {slot} 组",
+    "confirmImport": "导入新增规则",
+    "confirmingImport": "正在导入...",
+    "importCorrectionCsvSuccess": "已导入 {count} 条新增规则",
+    "importCorrectionCsvFailed": "勘误表导入失败",
+    "statusNew": "新增",
+    "statusCsvDuplicate": "CSV 重复",
+    "statusDictDuplicate": "词库重复",
+    "statusConflict": "冲突",
+    "statusInvalid": "无效",
+    "statusEmpty": "空项",
+    "statusLimit": "超限",
+    "imported": "已导入",
+    "exported": "已导出",
+    "exportedTo": "已导出到 {path}",
+    "saved": "已保存",
+    "saveFailed": "保存失败",
+    "importFailed": "导入失败",
+    "exportFailed": "导出失败",
+    "empty": "暂无词语",
+    "emptyDesc": "添加人名、品牌名或常用专有词后,识别结果会自动替换",
+    "loading": "正在加载词库",
+    "loadFailed": "词库加载失败",
+    "pageEmpty": "当前页没有词库规则"
+  },
+  "onboarding": {
+    "title": "首次设置",
+    "syncModel": "正在同步语音模型...",
+    "optimizing": "正在优化...",
+    "extracting": "正在解压模型文件...",
+    "complete": "完成",
+    "failed": "设置失败"
+  },
+  "update": {
+    "title": "发现新版本",
+    "version": "版本 {version}",
+    "releaseNotes": "更新日志",
+    "download": "立即更新",
+    "later": "稍后",
+    "downloading": "正在下载...",
+    "applying": "正在应用更新..."
+  }
+}
diff --git a/VoiceSnapGo/frontend/src/lib/stores/app.ts b/privatevoice.src/frontend/src/lib/stores/app.ts
similarity index 100%
rename from VoiceSnapGo/frontend/src/lib/stores/app.ts
rename to privatevoice.src/frontend/src/lib/stores/app.ts
diff --git a/VoiceSnapGo/frontend/src/lib/stores/config.ts b/privatevoice.src/frontend/src/lib/stores/config.ts
similarity index 100%
rename from VoiceSnapGo/frontend/src/lib/stores/config.ts
rename to privatevoice.src/frontend/src/lib/stores/config.ts
diff --git a/VoiceSnapGo/frontend/src/lib/stores/indicator.ts b/privatevoice.src/frontend/src/lib/stores/indicator.ts
similarity index 100%
rename from VoiceSnapGo/frontend/src/lib/stores/indicator.ts
rename to privatevoice.src/frontend/src/lib/stores/indicator.ts
diff --git a/VoiceSnapGo/frontend/src/lib/stores/update.ts b/privatevoice.src/frontend/src/lib/stores/update.ts
similarity index 100%
rename from VoiceSnapGo/frontend/src/lib/stores/update.ts
rename to privatevoice.src/frontend/src/lib/stores/update.ts
diff --git a/VoiceSnapGo/frontend/src/main.ts b/privatevoice.src/frontend/src/main.ts
similarity index 100%
rename from VoiceSnapGo/frontend/src/main.ts
rename to privatevoice.src/frontend/src/main.ts
diff --git a/VoiceSnapGo/frontend/src/styles/global.css b/privatevoice.src/frontend/src/styles/global.css
similarity index 100%
rename from VoiceSnapGo/frontend/src/styles/global.css
rename to privatevoice.src/frontend/src/styles/global.css
diff --git a/privatevoice.src/frontend/src/vite-env.d.ts b/privatevoice.src/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..8ec27b7
--- /dev/null
+++ b/privatevoice.src/frontend/src/vite-env.d.ts
@@ -0,0 +1,4 @@
+declare module '*.png' {
+  const src: string
+  export default src
+}
diff --git a/VoiceSnapGo/frontend/svelte.config.js b/privatevoice.src/frontend/svelte.config.js
similarity index 100%
rename from VoiceSnapGo/frontend/svelte.config.js
rename to privatevoice.src/frontend/svelte.config.js
diff --git a/VoiceSnapGo/frontend/tsconfig.json b/privatevoice.src/frontend/tsconfig.json
similarity index 100%
rename from VoiceSnapGo/frontend/tsconfig.json
rename to privatevoice.src/frontend/tsconfig.json
diff --git a/VoiceSnapGo/frontend/vite.config.ts b/privatevoice.src/frontend/vite.config.ts
similarity index 100%
rename from VoiceSnapGo/frontend/vite.config.ts
rename to privatevoice.src/frontend/vite.config.ts
diff --git a/VoiceSnapGo/go.mod b/privatevoice.src/go.mod
similarity index 100%
rename from VoiceSnapGo/go.mod
rename to privatevoice.src/go.mod
diff --git a/VoiceSnapGo/go.sum b/privatevoice.src/go.sum
similarity index 100%
rename from VoiceSnapGo/go.sum
rename to privatevoice.src/go.sum
diff --git a/VoiceSnapGo/instructions.md b/privatevoice.src/instructions.md
similarity index 100%
rename from VoiceSnapGo/instructions.md
rename to privatevoice.src/instructions.md
diff --git a/VoiceSnapGo/internal/audio/recorder.go b/privatevoice.src/internal/audio/recorder.go
similarity index 87%
rename from VoiceSnapGo/internal/audio/recorder.go
rename to privatevoice.src/internal/audio/recorder.go
index 43dc995..bef4072 100755
--- a/VoiceSnapGo/internal/audio/recorder.go
+++ b/privatevoice.src/internal/audio/recorder.go
@@ -3,6 +3,7 @@
 import (
 	"math"
 	"sync"
+	"time"
 	"voicesnap/internal/logger"
 
 	"github.com/gen2brain/malgo"
@@ -151,36 +152,28 @@
 
 // Stop stops recording and discards audio data.
 func (r *Recorder) Stop() {
-	device := r.detachDevice()
-	stopAndUninitDevice(device)
-
-	r.mu.Lock()
-	defer r.mu.Unlock()
-	r.pcmBuf = nil
+	device, _ := r.detachDeviceAndPCM()
+	stopAndUninitDeviceAsync(device)
 }
 
 // StopAndGetSamples stops recording and returns the captured audio as float32 samples.
 func (r *Recorder) StopAndGetSamples() []float32 {
-	device := r.detachDevice()
-	stopAndUninitDevice(device)
+	device, pcm := r.detachDeviceAndPCM()
+	stopAndUninitDeviceAsync(device)
 
-	r.mu.Lock()
-	defer r.mu.Unlock()
-
-	if len(r.pcmBuf) < 2 {
+	if len(pcm) < 2 {
 		return nil
 	}
 
 	// Convert 16-bit PCM to float32
-	numSamples := len(r.pcmBuf) / 2
+	numSamples := len(pcm) / 2
 	samples := make([]float32, numSamples)
 	for i := 0; i < numSamples; i++ {
-		sample := int16(r.pcmBuf[i*2]) | int16(r.pcmBuf[i*2+1])<<8
+		sample := int16(pcm[i*2]) | int16(pcm[i*2+1])<<8
 		samples[i] = float32(sample) / 32768.0
 	}
 
 	logger.Info("Recording stopped, %d samples captured", numSamples)
-	r.pcmBuf = nil
 	return samples
 }
 
@@ -226,6 +219,36 @@
 	return device
 }
 
+func (r *Recorder) detachDeviceAndPCM() (*malgo.Device, []byte) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+
+	device := r.device
+	r.device = nil
+	r.isRecording = false
+	pcm := append([]byte(nil), r.pcmBuf...)
+	r.pcmBuf = nil
+	return device, pcm
+}
+
+func stopAndUninitDeviceAsync(device *malgo.Device) {
+	if device == nil {
+		return
+	}
+	done := make(chan struct{})
+	go func() {
+		stopAndUninitDevice(device)
+		close(done)
+	}()
+	go func() {
+		select {
+		case <-done:
+		case <-time.After(2 * time.Second):
+			logger.Error("Audio device stop is still pending; continuing without blocking UI")
+		}
+	}()
+}
+
 func stopAndUninitDevice(device *malgo.Device) {
 	if device == nil {
 		return
diff --git a/VoiceSnapGo/internal/config/config.go b/privatevoice.src/internal/config/config.go
similarity index 85%
rename from VoiceSnapGo/internal/config/config.go
rename to privatevoice.src/internal/config/config.go
index 04b54c6..62b4673 100755
--- a/VoiceSnapGo/internal/config/config.go
+++ b/privatevoice.src/internal/config/config.go
@@ -4,6 +4,7 @@
 	"encoding/json"
 	"os"
 	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
 	"voicesnap/internal/paths"
 )
 
@@ -23,6 +24,7 @@
 	DeviceName               string `json:"DeviceName,omitempty"`
 	IndicatorX               int    `json:"IndicatorX,omitempty"`
 	IndicatorY               int    `json:"IndicatorY,omitempty"`
+	SelectedModelID          string `json:"SelectedModelID,omitempty"`
 	ModelDownloadUrl         string `json:"ModelDownloadUrl"`
 	FallbackModelDownloadUrl string `json:"FallbackModelDownloadUrl"`
 }
@@ -36,6 +38,7 @@
 		SoundFeedback:            true,
 		HideDockIcon:             true,
 		CopyToClipboard:          true,
+		SelectedModelID:          model.DefaultModelID,
 		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",
 	}
@@ -63,6 +66,11 @@
 		return nil, err
 	}
 	cfg.HotkeyMode = NormalizeHotkeyMode(cfg.HotkeyMode)
+	selectedModelID := cfg.SelectedModelID
+	cfg.SelectedModelID = model.NormalizeModelID(cfg.SelectedModelID)
+	if selectedModelID != "" && selectedModelID != cfg.SelectedModelID {
+		logger.Info("Unknown SelectedModelID %q, falling back to %q", selectedModelID, cfg.SelectedModelID)
+	}
 	return cfg, nil
 }
 
diff --git a/privatevoice.src/internal/correctioncsv/correctioncsv.go b/privatevoice.src/internal/correctioncsv/correctioncsv.go
new file mode 100644
index 0000000..f96f835
--- /dev/null
+++ b/privatevoice.src/internal/correctioncsv/correctioncsv.go
@@ -0,0 +1,412 @@
+package correctioncsv
+
+import (
+	"bytes"
+	"encoding/csv"
+	"fmt"
+	"io"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+	"unicode/utf8"
+	"voicesnap/internal/history"
+	"voicesnap/internal/userdict"
+)
+
+const (
+	DefaultMaxNewRules = 500
+	DefaultMaxLength   = 120
+	DefaultDetailLimit = 20
+
+	StatusNew           = "new"
+	StatusCSVDuplicate  = "csv_duplicate"
+	StatusDictDuplicate = "dict_duplicate"
+	StatusConflict      = "conflict"
+	StatusInvalid       = "invalid"
+	StatusEmpty         = "empty"
+	StatusLimit         = "limit"
+)
+
+var header = []string{
+	"记录ID",
+	"识别时间",
+	"识别历史文本",
+	"错误词1",
+	"正确词1",
+	"错误词2",
+	"正确词2",
+	"错误词3",
+	"正确词3",
+	"错误词4",
+	"正确词4",
+	"错误词5",
+	"正确词5",
+}
+
+// Options controls CSV import classification limits.
+type Options struct {
+	MaxNewRules int
+	MaxLength   int
+	DetailLimit int
+}
+
+// Rule is a replacement candidate that can be appended to the user dictionary.
+type Rule struct {
+	From string `json:"from"`
+	To   string `json:"to"`
+}
+
+// Summary is the complete classification count for all scanned CSV slots.
+type Summary struct {
+	New           int `json:"new"`
+	CSVDuplicate  int `json:"csvDuplicate"`
+	DictDuplicate int `json:"dictDuplicate"`
+	Conflict      int `json:"conflict"`
+	Invalid       int `json:"invalid"`
+	Empty         int `json:"empty"`
+	Limit         int `json:"limit"`
+}
+
+// PreviewItem is one visible classification row in the import preview.
+type PreviewItem struct {
+	Row    int    `json:"row"`
+	Slot   int    `json:"slot"`
+	From   string `json:"from"`
+	To     string `json:"to"`
+	Status string `json:"status"`
+}
+
+// Preview contains a complete import summary and the first visible details.
+type Preview struct {
+	Summary          Summary       `json:"summary"`
+	Items            []PreviewItem `json:"items"`
+	TotalSlots       int           `json:"totalSlots"`
+	VisibleLimit     int           `json:"visibleLimit"`
+	DetailsTruncated bool          `json:"detailsTruncated"`
+	NewRules         []Rule        `json:"-"`
+}
+
+// BuildHistoryCSV creates the Phase 1 correction CSV for current retained history entries.
+func BuildHistoryCSV(entries []history.Entry, loc *time.Location) ([]byte, error) {
+	if loc == nil {
+		loc = time.Local
+	}
+
+	var buf bytes.Buffer
+	buf.Write([]byte{0xEF, 0xBB, 0xBF})
+
+	writer := csv.NewWriter(&buf)
+	if err := writer.Write(header); err != nil {
+		return nil, err
+	}
+	if err := writer.Write(exampleRow()); err != nil {
+		return nil, err
+	}
+
+	for _, entry := range entries {
+		row := []string{
+			strconv.FormatInt(entry.Timestamp, 10),
+			time.UnixMilli(entry.Timestamp).In(loc).Format("2006-01-02 15:04:05"),
+			entry.Text,
+			"", "", "", "", "", "", "", "", "", "",
+		}
+		if err := writer.Write(row); err != nil {
+			return nil, err
+		}
+	}
+
+	writer.Flush()
+	if err := writer.Error(); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+// ParseAndPreviewCSV parses a correction CSV and classifies all correction slots.
+func ParseAndPreviewCSV(data []byte, existing []userdict.Entry, opts Options) (Preview, error) {
+	opts = normalizeOptions(opts)
+	data = trimUTF8BOM(data)
+	if !utf8.Valid(data) {
+		return Preview{}, fmt.Errorf("file is not valid UTF-8")
+	}
+
+	reader := csv.NewReader(bytes.NewReader(data))
+	reader.FieldsPerRecord = -1
+	records, err := reader.ReadAll()
+	if err != nil {
+		if err == io.EOF {
+			return Preview{}, fmt.Errorf("CSV is empty")
+		}
+		return Preview{}, err
+	}
+	if len(records) == 0 {
+		return Preview{}, fmt.Errorf("CSV is empty")
+	}
+
+	columns := mapHeaders(records[0])
+	if err := validateColumns(columns); err != nil {
+		return Preview{}, err
+	}
+
+	existingPairs := map[string]struct{}{}
+	existingFrom := map[string]string{}
+	for _, entry := range existing {
+		from := normalizeCell(entry.From)
+		to := normalizeCell(entry.To)
+		if from == "" {
+			continue
+		}
+		existingPairs[pairKey(from, to)] = struct{}{}
+		if _, ok := existingFrom[from]; !ok {
+			existingFrom[from] = to
+		}
+	}
+
+	seenPairs := map[string]struct{}{}
+	seenFrom := map[string]string{}
+	preview := Preview{
+		VisibleLimit: opts.DetailLimit,
+	}
+	allItems := make([]PreviewItem, 0)
+
+	for recordIndex, row := range records[1:] {
+		rowNumber := recordIndex + 2
+		if isExampleRow(row, columns) {
+			continue
+		}
+
+		for slot := 1; slot <= 5; slot++ {
+			preview.TotalSlots++
+			from := normalizeCell(get(row, columns, fmt.Sprintf("error_%d", slot)))
+			to := normalizeCell(get(row, columns, fmt.Sprintf("corrected_%d", slot)))
+			status := classify(from, to, opts, seenPairs, seenFrom, existingPairs, existingFrom, len(preview.NewRules))
+
+			if from != "" && to != "" && from != to && len([]rune(from)) <= opts.MaxLength && len([]rune(to)) <= opts.MaxLength {
+				key := pairKey(from, to)
+				if _, duplicate := seenPairs[key]; !duplicate {
+					seenPairs[key] = struct{}{}
+				}
+				if _, exists := seenFrom[from]; !exists {
+					seenFrom[from] = to
+				}
+			}
+
+			preview.addSummary(status)
+			allItems = append(allItems, PreviewItem{
+				Row:    rowNumber,
+				Slot:   slot,
+				From:   from,
+				To:     to,
+				Status: status,
+			})
+
+			if status == StatusNew {
+				preview.NewRules = append(preview.NewRules, Rule{From: from, To: to})
+			}
+		}
+	}
+
+	preview.Items, preview.DetailsTruncated = prioritizePreviewItems(allItems, opts.DetailLimit)
+	return preview, nil
+}
+
+func exampleRow() []string {
+	return []string{
+		"示例",
+		"示例,可删除或保留",
+		"正确的格式是点 CSV",
+		"点 CSV",
+		".csv",
+		"", "", "", "", "", "", "", "",
+	}
+}
+
+func normalizeOptions(opts Options) Options {
+	if opts.MaxNewRules <= 0 {
+		opts.MaxNewRules = DefaultMaxNewRules
+	}
+	if opts.MaxLength <= 0 {
+		opts.MaxLength = DefaultMaxLength
+	}
+	if opts.DetailLimit <= 0 {
+		opts.DetailLimit = DefaultDetailLimit
+	}
+	return opts
+}
+
+func trimUTF8BOM(data []byte) []byte {
+	return bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
+}
+
+func mapHeaders(row []string) map[string]int {
+	columns := map[string]int{}
+	for i, name := range row {
+		canonical := canonicalHeaderName(name)
+		if canonical == "" {
+			continue
+		}
+		if _, exists := columns[canonical]; !exists {
+			columns[canonical] = i
+		}
+	}
+	return columns
+}
+
+func canonicalHeaderName(name string) string {
+	normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", ""))
+	switch normalized {
+	case "timestamp", "记录id", "记录编号", "时间戳":
+		return "timestamp"
+	case "datetime_local", "datetimelocal", "识别时间", "本地时间", "记录时间":
+		return "datetime_local"
+	case "text", "识别历史文本", "历史文本", "识别文本", "原文":
+		return "text"
+	}
+	for slot := 1; slot <= 5; slot++ {
+		if normalized == fmt.Sprintf("error_%d", slot) ||
+			normalized == fmt.Sprintf("error%d", slot) ||
+			normalized == fmt.Sprintf("错误词%d", slot) ||
+			normalized == fmt.Sprintf("错误词_%d", slot) {
+			return fmt.Sprintf("error_%d", slot)
+		}
+		if normalized == fmt.Sprintf("corrected_%d", slot) ||
+			normalized == fmt.Sprintf("corrected%d", slot) ||
+			normalized == fmt.Sprintf("正确词%d", slot) ||
+			normalized == fmt.Sprintf("正确词_%d", slot) ||
+			normalized == fmt.Sprintf("改正词%d", slot) ||
+			normalized == fmt.Sprintf("改正词_%d", slot) {
+			return fmt.Sprintf("corrected_%d", slot)
+		}
+	}
+	return ""
+}
+
+func validateColumns(columns map[string]int) error {
+	required := []string{"timestamp", "datetime_local", "text"}
+	for slot := 1; slot <= 5; slot++ {
+		required = append(required, fmt.Sprintf("error_%d", slot), fmt.Sprintf("corrected_%d", slot))
+	}
+	for _, name := range required {
+		if _, ok := columns[name]; !ok {
+			return fmt.Errorf("missing required CSV column: %s", name)
+		}
+	}
+	return nil
+}
+
+func isExampleRow(row []string, columns map[string]int) bool {
+	marker := strings.TrimSpace(get(row, columns, "timestamp"))
+	return marker == "__example__" || marker == "示例" || strings.EqualFold(marker, "example")
+}
+
+func get(row []string, columns map[string]int, name string) string {
+	i, ok := columns[name]
+	if !ok || i >= len(row) {
+		return ""
+	}
+	return row[i]
+}
+
+func normalizeCell(value string) string {
+	value = strings.ReplaceAll(value, "\r\n", " ")
+	value = strings.ReplaceAll(value, "\r", " ")
+	value = strings.ReplaceAll(value, "\n", " ")
+	return strings.TrimSpace(value)
+}
+
+func classify(
+	from string,
+	to string,
+	opts Options,
+	seenPairs map[string]struct{},
+	seenFrom map[string]string,
+	existingPairs map[string]struct{},
+	existingFrom map[string]string,
+	newCount int,
+) string {
+	if from == "" || to == "" {
+		return StatusEmpty
+	}
+	if from == to {
+		return StatusInvalid
+	}
+	if len([]rune(from)) > opts.MaxLength || len([]rune(to)) > opts.MaxLength {
+		return StatusInvalid
+	}
+
+	if _, ok := seenPairs[pairKey(from, to)]; ok {
+		return StatusCSVDuplicate
+	}
+	if existingTo, ok := seenFrom[from]; ok && existingTo != to {
+		return StatusConflict
+	}
+
+	if _, ok := existingPairs[pairKey(from, to)]; ok {
+		return StatusDictDuplicate
+	}
+	if existingTo, ok := existingFrom[from]; ok && existingTo != to {
+		return StatusConflict
+	}
+
+	if newCount >= opts.MaxNewRules {
+		return StatusLimit
+	}
+	return StatusNew
+}
+
+func (p *Preview) addSummary(status string) {
+	switch status {
+	case StatusNew:
+		p.Summary.New++
+	case StatusCSVDuplicate:
+		p.Summary.CSVDuplicate++
+	case StatusDictDuplicate:
+		p.Summary.DictDuplicate++
+	case StatusConflict:
+		p.Summary.Conflict++
+	case StatusInvalid:
+		p.Summary.Invalid++
+	case StatusEmpty:
+		p.Summary.Empty++
+	case StatusLimit:
+		p.Summary.Limit++
+	}
+}
+
+func prioritizePreviewItems(items []PreviewItem, detailLimit int) ([]PreviewItem, bool) {
+	if detailLimit <= 0 {
+		return nil, len(items) > 0
+	}
+
+	sorted := make([]PreviewItem, len(items))
+	copy(sorted, items)
+	sort.SliceStable(sorted, func(i, j int) bool {
+		return previewStatusRank(sorted[i].Status) < previewStatusRank(sorted[j].Status)
+	})
+
+	truncated := len(sorted) > detailLimit
+	if truncated {
+		sorted = sorted[:detailLimit]
+	}
+	return sorted, truncated
+}
+
+func previewStatusRank(status string) int {
+	switch status {
+	case StatusNew:
+		return 0
+	case StatusConflict:
+		return 1
+	case StatusInvalid, StatusLimit, StatusDictDuplicate, StatusCSVDuplicate:
+		return 2
+	case StatusEmpty:
+		return 3
+	default:
+		return 4
+	}
+}
+
+func pairKey(from, to string) string {
+	return from + "\x00" + to
+}
diff --git a/privatevoice.src/internal/correctioncsv/correctioncsv_e2e_test.go b/privatevoice.src/internal/correctioncsv/correctioncsv_e2e_test.go
new file mode 100644
index 0000000..0e91d8f
--- /dev/null
+++ b/privatevoice.src/internal/correctioncsv/correctioncsv_e2e_test.go
@@ -0,0 +1,130 @@
+package correctioncsv_test
+
+import (
+	"bytes"
+	"encoding/csv"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+	"voicesnap/internal/correctioncsv"
+	"voicesnap/internal/history"
+	"voicesnap/internal/userdict"
+)
+
+func TestCorrectionCSVEndToEndPreviewThenAppend(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("HOME", home)
+	t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+
+	store := userdict.New()
+	if _, err := store.AppendNewReplacements([]userdict.Replacement{
+		{From: "点 CSV", To: ".csv"},
+		{From: "麦克", To: "Mac"},
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	exported, err := correctioncsv.BuildHistoryCSV([]history.Entry{
+		{Timestamp: time.Date(2026, 5, 30, 13, 9, 0, 0, time.UTC).UnixMilli(), Text: "普通中文句子"},
+		{Timestamp: time.Date(2026, 5, 30, 13, 10, 0, 0, time.UTC).UnixMilli(), Text: "CSV 2026, \"quoted\"\nnext"},
+		{Timestamp: time.Date(2026, 5, 30, 13, 11, 0, 0, time.UTC).UnixMilli(), Text: "已经后处理的最终文本"},
+	}, time.UTC)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	records := readCSV(t, exported)
+	if len(records) != 5 {
+		t.Fatalf("expected header, example, and 3 history rows, got %d rows", len(records))
+	}
+	records[2][3] = "点 PDF"
+	records[2][4] = ".pdf"
+	records[2][5] = "点 CSV"
+	records[2][6] = ".csv"
+	records[2][7] = "麦克"
+	records[2][8] = "Mike"
+	records[3][3] = "换\n行"
+	records[3][4] = "换行词"
+
+	importData := writeCSV(t, records)
+	before := readUserDictFile(t, store.Path())
+
+	preview, err := correctioncsv.ParseAndPreviewCSV(importData, store.GetAll(), correctioncsv.Options{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	afterPreview := readUserDictFile(t, store.Path())
+	if !bytes.Equal(before, afterPreview) {
+		t.Fatal("preview changed userdict.json before confirmation")
+	}
+
+	if preview.Summary.New != 2 {
+		t.Fatalf("expected 2 new rules, got %+v", preview.Summary)
+	}
+	if preview.Summary.DictDuplicate != 1 {
+		t.Fatalf("expected 1 dictionary duplicate, got %+v", preview.Summary)
+	}
+	if preview.Summary.Conflict != 1 {
+		t.Fatalf("expected 1 conflict, got %+v", preview.Summary)
+	}
+
+	added, err := store.AppendNewReplacements(toReplacements(preview.NewRules))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(added) != 2 {
+		t.Fatalf("expected 2 appended rules, got %d", len(added))
+	}
+	if got := store.Apply("点 PDF 和 换 行 和 点 CSV 和 麦克"); got != ".pdf 和 换行词 和 .csv 和 Mac" {
+		t.Fatalf("unexpected applied text: %q", got)
+	}
+}
+
+func toReplacements(rules []correctioncsv.Rule) []userdict.Replacement {
+	replacements := make([]userdict.Replacement, 0, len(rules))
+	for _, rule := range rules {
+		replacements = append(replacements, userdict.Replacement{From: rule.From, To: rule.To})
+	}
+	return replacements
+}
+
+func readCSV(t *testing.T, data []byte) [][]string {
+	t.Helper()
+	reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})))
+	records, err := reader.ReadAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return records
+}
+
+func writeCSV(t *testing.T, records [][]string) []byte {
+	t.Helper()
+	var buf bytes.Buffer
+	writer := csv.NewWriter(&buf)
+	for _, row := range records {
+		if err := writer.Write(row); err != nil {
+			t.Fatal(err)
+		}
+	}
+	writer.Flush()
+	if err := writer.Error(); err != nil {
+		t.Fatal(err)
+	}
+	return buf.Bytes()
+}
+
+func readUserDictFile(t *testing.T, path string) []byte {
+	t.Helper()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var parsed any
+	if err := json.Unmarshal(data, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	return data
+}
diff --git a/privatevoice.src/internal/correctioncsv/correctioncsv_test.go b/privatevoice.src/internal/correctioncsv/correctioncsv_test.go
new file mode 100644
index 0000000..06a7916
--- /dev/null
+++ b/privatevoice.src/internal/correctioncsv/correctioncsv_test.go
@@ -0,0 +1,186 @@
+package correctioncsv
+
+import (
+	"bytes"
+	"encoding/csv"
+	"strings"
+	"testing"
+	"time"
+	"voicesnap/internal/history"
+	"voicesnap/internal/userdict"
+)
+
+func TestBuildHistoryCSVIncludesBOMHeaderExampleAndEscapedRows(t *testing.T) {
+	entries := []history.Entry{
+		{
+			Timestamp: time.Date(2026, 5, 30, 13, 9, 8, 0, time.UTC).UnixMilli(),
+			Text:      "中文,a \"quote\"\nnext",
+		},
+	}
+
+	data, err := BuildHistoryCSV(entries, time.UTC)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}) {
+		t.Fatal("expected UTF-8 BOM")
+	}
+
+	reader := csv.NewReader(bytes.NewReader(trimUTF8BOM(data)))
+	records, err := reader.ReadAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(records) != 3 {
+		t.Fatalf("expected header, example, and one data row, got %d rows", len(records))
+	}
+	if got := strings.Join(records[0], ","); got != strings.Join(header, ",") {
+		t.Fatalf("unexpected header: %s", got)
+	}
+	if records[1][0] != "示例" {
+		t.Fatalf("expected example row, got marker %q", records[1][0])
+	}
+	if records[1][2] != "正确的格式是点 CSV" {
+		t.Fatalf("unexpected example text: %q", records[1][2])
+	}
+	if records[2][2] != entries[0].Text {
+		t.Fatalf("expected text to round-trip, got %q", records[2][2])
+	}
+}
+
+func TestParseAndPreviewClassifiesRows(t *testing.T) {
+	data := csvBytes(t,
+		[]string{"corrected_1", "error_1", "text", "datetime_local", "timestamp", "error_2", "corrected_2", "error_3", "corrected_3", "error_4", "corrected_4", "error_5", "corrected_5", "notes"},
+		[]string{".csv", "点 CSV", "sample", "now", "__example__", "", "", "", "", "", "", "", "", "ignored"},
+		[]string{".csv", " 点 CSV ", "text", "now", "1", "麦克", "Mike", "空", "", "same", "same", strings.Repeat("长", 121), "x", "ignored"},
+	)
+
+	preview, err := ParseAndPreviewCSV(
+		append([]byte{0xEF, 0xBB, 0xBF}, data...),
+		[]userdict.Entry{{From: "麦克", To: "Mac"}},
+		Options{},
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if preview.TotalSlots != 5 {
+		t.Fatalf("expected 5 scanned slots, got %d", preview.TotalSlots)
+	}
+	if preview.Summary.New != 1 {
+		t.Fatalf("expected 1 new rule, got %+v", preview.Summary)
+	}
+	if preview.Summary.Conflict != 1 {
+		t.Fatalf("expected 1 conflict, got %+v", preview.Summary)
+	}
+	if preview.Summary.Empty != 1 {
+		t.Fatalf("expected 1 empty slot, got %+v", preview.Summary)
+	}
+	if preview.Summary.Invalid != 2 {
+		t.Fatalf("expected 2 invalid slots, got %+v", preview.Summary)
+	}
+	if len(preview.NewRules) != 1 || preview.NewRules[0].From != "点 CSV" || preview.NewRules[0].To != ".csv" {
+		t.Fatalf("unexpected new rules: %+v", preview.NewRules)
+	}
+}
+
+func TestParseAndPreviewAcceptsChineseHeadersAndSkipsChineseExampleRow(t *testing.T) {
+	data := csvBytes(t,
+		[]string{"正确词1", "错误词1", "识别历史文本", "识别时间", "记录ID", "错误词2", "正确词2", "错误词3", "正确词3", "错误词4", "正确词4", "错误词5", "正确词5", "备注"},
+		[]string{".csv", "点 CSV", "正确的格式是点 CSV", "示例,可删除或保留", "示例", "", "", "", "", "", "", "", "", "ignored"},
+		[]string{".pdf", "点 PDF", "text", "2026-05-30 13:00:00", "1", "", "", "", "", "", "", "", "", "ignored"},
+	)
+
+	preview, err := ParseAndPreviewCSV(data, nil, Options{})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if preview.TotalSlots != 5 {
+		t.Fatalf("expected 5 scanned slots, got %d", preview.TotalSlots)
+	}
+	if preview.Summary.New != 1 {
+		t.Fatalf("expected only the real data row to create 1 new rule, got %+v", preview.Summary)
+	}
+	if len(preview.NewRules) != 1 || preview.NewRules[0].From != "点 PDF" || preview.NewRules[0].To != ".pdf" {
+		t.Fatalf("unexpected new rules: %+v", preview.NewRules)
+	}
+}
+
+func TestParseAndPreviewDetectsCSVDuplicateConflictLimitAndTruncatesDetails(t *testing.T) {
+	data := csvBytes(t,
+		header,
+		[]string{"1", "now", "text", "A", "B", "A", "B", "A", "C", "D", "E", "F", "G"},
+	)
+
+	preview, err := ParseAndPreviewCSV(data, nil, Options{MaxNewRules: 2, DetailLimit: 3})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if preview.Summary.New != 2 || preview.Summary.CSVDuplicate != 1 || preview.Summary.Conflict != 1 || preview.Summary.Limit != 1 {
+		t.Fatalf("unexpected summary: %+v", preview.Summary)
+	}
+	if len(preview.Items) != 3 {
+		t.Fatalf("expected 3 visible details, got %d", len(preview.Items))
+	}
+	if !preview.DetailsTruncated {
+		t.Fatal("expected details to be truncated")
+	}
+	if preview.VisibleLimit != 3 {
+		t.Fatalf("expected visible limit 3, got %d", preview.VisibleLimit)
+	}
+}
+
+func TestParseAndPreviewPrioritizesUsefulDetailsOverEmptySlots(t *testing.T) {
+	data := csvBytes(t,
+		header,
+		[]string{"1", "now", "empty row", "", "", "", "", "", "", "", "", "", ""},
+		[]string{"2", "now", "useful row", "A", "B", "A", "C", "", "", "", "", "", ""},
+	)
+
+	preview, err := ParseAndPreviewCSV(data, nil, Options{DetailLimit: 2})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(preview.Items) != 2 {
+		t.Fatalf("expected 2 visible details, got %d", len(preview.Items))
+	}
+	if preview.Items[0].Status != StatusNew || preview.Items[1].Status != StatusConflict {
+		t.Fatalf("expected new and conflict to be shown before empty slots, got %+v", preview.Items)
+	}
+	if !preview.DetailsTruncated {
+		t.Fatal("expected details to be truncated")
+	}
+	if preview.Summary.Empty != 8 || preview.Summary.New != 1 || preview.Summary.Conflict != 1 {
+		t.Fatalf("unexpected summary: %+v", preview.Summary)
+	}
+}
+
+func TestParseAndPreviewRejectsInvalidInput(t *testing.T) {
+	if _, err := ParseAndPreviewCSV([]byte{0xff, 0xfe}, nil, Options{}); err == nil {
+		t.Fatal("expected invalid UTF-8 to fail")
+	}
+
+	data := csvBytes(t, []string{"timestamp"}, []string{"1"})
+	if _, err := ParseAndPreviewCSV(data, nil, Options{}); err == nil {
+		t.Fatal("expected missing required columns to fail")
+	}
+}
+
+func csvBytes(t *testing.T, rows ...[]string) []byte {
+	t.Helper()
+	var buf bytes.Buffer
+	writer := csv.NewWriter(&buf)
+	for _, row := range rows {
+		if err := writer.Write(row); err != nil {
+			t.Fatal(err)
+		}
+	}
+	writer.Flush()
+	if err := writer.Error(); err != nil {
+		t.Fatal(err)
+	}
+	return buf.Bytes()
+}
diff --git a/VoiceSnapGo/internal/dock/dock.go b/privatevoice.src/internal/dock/dock.go
similarity index 100%
rename from VoiceSnapGo/internal/dock/dock.go
rename to privatevoice.src/internal/dock/dock.go
diff --git a/VoiceSnapGo/internal/dock/dock_darwin.go b/privatevoice.src/internal/dock/dock_darwin.go
similarity index 100%
rename from VoiceSnapGo/internal/dock/dock_darwin.go
rename to privatevoice.src/internal/dock/dock_darwin.go
diff --git a/VoiceSnapGo/internal/dock/dock_other.go b/privatevoice.src/internal/dock/dock_other.go
similarity index 100%
rename from VoiceSnapGo/internal/dock/dock_other.go
rename to privatevoice.src/internal/dock/dock_other.go
diff --git a/privatevoice.src/internal/engine/engine.go b/privatevoice.src/internal/engine/engine.go
new file mode 100755
index 0000000..8e2c23c
--- /dev/null
+++ b/privatevoice.src/internal/engine/engine.go
@@ -0,0 +1,94 @@
+package engine
+
+import (
+	"fmt"
+	"voicesnap/internal/config"
+	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
+	"voicesnap/internal/paths"
+)
+
+// Engine is the interface for ASR engines.
+type Engine interface {
+	// Recognize takes float32 PCM samples (16kHz mono) and returns the recognized text.
+	Recognize(samples []float32) (string, error)
+	// HardwareInfo returns a human-readable description of the hardware backend being used.
+	HardwareInfo() string
+	// Close releases engine resources.
+	Close()
+}
+
+// ModelDir returns the path to the sensevoice model directory.
+func ModelDir() string {
+	resolved, err := resolveCurrentModel()
+	if err == nil && resolved.IsUsable() {
+		return resolved.RootDir
+	}
+	return paths.ModelDir()
+}
+
+// ModelPath returns the path to the ONNX model file (prefers int8).
+func ModelPath() string {
+	resolved, err := resolveCurrentModel()
+	if err == nil && resolved.IsUsable() {
+		return resolved.Files["model"]
+	}
+	return ""
+}
+
+// TokensPath returns the path to the tokens.txt file.
+func TokensPath() string {
+	resolved, err := resolveCurrentModel()
+	if err == nil && resolved.IsUsable() {
+		return resolved.Files["tokens"]
+	}
+	return ""
+}
+
+// ModelExists checks if both the model and tokens files exist.
+func ModelExists() bool {
+	resolved, err := resolveCurrentModel()
+	if err != nil {
+		return false
+	}
+	return resolved.IsUsable()
+}
+
+// New creates a new platform-specific ASR engine.
+// Returns an error if the model files are not found.
+func New() (Engine, error) {
+	cfg, err := config.Load()
+	if err != nil {
+		logger.Error("Failed to load config for model selection: %v", err)
+		return NewWithModelID(model.DefaultModelID)
+	}
+	return NewWithModelID(cfg.SelectedModelID)
+}
+
+func NewWithModelID(modelID string) (Engine, error) {
+	resolved, err := model.ResolveModel(model.NormalizeModelID(modelID))
+	if err != nil {
+		return nil, err
+	}
+	return NewWithResolvedModel(resolved)
+}
+
+func NewWithResolvedModel(resolved model.ResolvedModel) (Engine, error) {
+	if !resolved.IsUsable() {
+		return nil, fmt.Errorf("model %s is not usable: %s missing=%v problems=%v", resolved.ModelID, resolved.Status, resolved.Missing, resolved.Problems)
+	}
+	if resolved.BackendKind != model.BackendSenseVoice {
+		return nil, fmt.Errorf("unsupported backend kind: %s", resolved.BackendKind)
+	}
+
+	logger.Info("Loading model %s from %s", resolved.ModelID, resolved.RootDir)
+	return newPlatformEngine(resolved)
+}
+
+func resolveCurrentModel() (model.ResolvedModel, error) {
+	cfg, err := config.Load()
+	if err != nil {
+		return model.ResolveModel(model.DefaultModelID)
+	}
+	return model.ResolveModel(model.NormalizeModelID(cfg.SelectedModelID))
+}
diff --git a/VoiceSnapGo/internal/engine/engine_darwin.go b/privatevoice.src/internal/engine/engine_darwin.go
similarity index 61%
rename from VoiceSnapGo/internal/engine/engine_darwin.go
rename to privatevoice.src/internal/engine/engine_darwin.go
index 52af4cb..7a7f9c1 100755
--- a/VoiceSnapGo/internal/engine/engine_darwin.go
+++ b/privatevoice.src/internal/engine/engine_darwin.go
@@ -5,9 +5,10 @@
 import (
 	"fmt"
 	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
 
-	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 	_ "github.com/k2-fsa/sherpa-onnx-go-macos"
+	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 )
 
 type sherpaEngine struct {
@@ -15,27 +16,17 @@
 	hwInfo     string
 }
 
-func newPlatformEngine() (Engine, error) {
-	modelPath := ModelPath()
-	tokensPath := TokensPath()
-
-	// Try CoreML first, then CPU fallback
-	providers := []struct {
-		name     string
-		provider string
-	}{
-		{"CoreML (Apple Neural Engine)", "coreml"},
-		{"CPU", "cpu"},
-	}
+func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
+	providers := darwinProviders(resolved.ProviderOrder)
 
 	for _, p := range providers {
 		config := sherpa.OfflineRecognizerConfig{}
 		config.FeatConfig.SampleRate = 16000
 		config.FeatConfig.FeatureDim = 80
-		config.ModelConfig.SenseVoice.Model = modelPath
+		config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
 		config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
-		config.ModelConfig.Tokens = tokensPath
-		config.ModelConfig.NumThreads = 4
+		config.ModelConfig.Tokens = resolved.Files["tokens"]
+		config.ModelConfig.NumThreads = resolved.Profile.NumThreads
 		config.ModelConfig.Provider = p.provider
 		config.DecodingMethod = "greedy_search"
 
@@ -54,6 +45,37 @@
 	return nil, fmt.Errorf("failed to initialize sherpa-onnx with any provider")
 }
 
+func darwinProviders(providerOrder []string) []struct {
+	name     string
+	provider string
+} {
+	names := map[string]string{
+		"coreml": "CoreML (Apple Neural Engine)",
+		"cpu":    "CPU",
+	}
+	providers := make([]struct {
+		name     string
+		provider string
+	}, 0, len(providerOrder))
+	for _, provider := range providerOrder {
+		name, ok := names[provider]
+		if !ok {
+			continue
+		}
+		providers = append(providers, struct {
+			name     string
+			provider string
+		}{name: name, provider: provider})
+	}
+	if len(providers) == 0 {
+		providers = append(providers, struct {
+			name     string
+			provider string
+		}{name: "CPU", provider: "cpu"})
+	}
+	return providers
+}
+
 func (e *sherpaEngine) Recognize(samples []float32) (string, error) {
 	stream := sherpa.NewOfflineStream(e.recognizer)
 	defer sherpa.DeleteOfflineStream(stream)
diff --git a/VoiceSnapGo/internal/engine/engine_linux.go b/privatevoice.src/internal/engine/engine_linux.go
similarity index 82%
rename from VoiceSnapGo/internal/engine/engine_linux.go
rename to privatevoice.src/internal/engine/engine_linux.go
index f73f9bb..41e18de 100755
--- a/VoiceSnapGo/internal/engine/engine_linux.go
+++ b/privatevoice.src/internal/engine/engine_linux.go
@@ -5,9 +5,10 @@
 import (
 	"fmt"
 	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
 
-	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 	_ "github.com/k2-fsa/sherpa-onnx-go-linux"
+	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 )
 
 type sherpaEngine struct {
@@ -15,18 +16,15 @@
 	hwInfo     string
 }
 
-func newPlatformEngine() (Engine, error) {
-	modelPath := ModelPath()
-	tokensPath := TokensPath()
-
+func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
 	// Linux: CPU only
 	config := sherpa.OfflineRecognizerConfig{}
 	config.FeatConfig.SampleRate = 16000
 	config.FeatConfig.FeatureDim = 80
-	config.ModelConfig.SenseVoice.Model = modelPath
+	config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
 	config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
-	config.ModelConfig.Tokens = tokensPath
-	config.ModelConfig.NumThreads = 4
+	config.ModelConfig.Tokens = resolved.Files["tokens"]
+	config.ModelConfig.NumThreads = resolved.Profile.NumThreads
 	config.ModelConfig.Provider = "cpu"
 	config.DecodingMethod = "greedy_search"
 
diff --git a/VoiceSnapGo/internal/engine/engine_windows.go b/privatevoice.src/internal/engine/engine_windows.go
similarity index 85%
rename from VoiceSnapGo/internal/engine/engine_windows.go
rename to privatevoice.src/internal/engine/engine_windows.go
index d82fcb2..8df88fb 100755
--- a/VoiceSnapGo/internal/engine/engine_windows.go
+++ b/privatevoice.src/internal/engine/engine_windows.go
@@ -5,9 +5,10 @@
 import (
 	"fmt"
 	"voicesnap/internal/logger"
+	"voicesnap/internal/model"
 
-	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 	_ "github.com/k2-fsa/sherpa-onnx-go-windows"
+	sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
 )
 
 type sherpaEngine struct {
@@ -15,10 +16,7 @@
 	hwInfo     string
 }
 
-func newPlatformEngine() (Engine, error) {
-	modelPath := ModelPath()
-	tokensPath := TokensPath()
-
+func newPlatformEngine(resolved model.ResolvedModel) (Engine, error) {
 	// Try DirectML first, then CPU fallback
 	providers := []struct {
 		name     string
@@ -32,10 +30,10 @@
 		config := sherpa.OfflineRecognizerConfig{}
 		config.FeatConfig.SampleRate = 16000
 		config.FeatConfig.FeatureDim = 80
-		config.ModelConfig.SenseVoice.Model = modelPath
+		config.ModelConfig.SenseVoice.Model = resolved.Files["model"]
 		config.ModelConfig.SenseVoice.UseInverseTextNormalization = 1
-		config.ModelConfig.Tokens = tokensPath
-		config.ModelConfig.NumThreads = 4
+		config.ModelConfig.Tokens = resolved.Files["tokens"]
+		config.ModelConfig.NumThreads = resolved.Profile.NumThreads
 		config.ModelConfig.Provider = p.provider
 		config.DecodingMethod = "greedy_search"
 
diff --git a/privatevoice.src/internal/history/history.go b/privatevoice.src/internal/history/history.go
new file mode 100755
index 0000000..108a06c
--- /dev/null
+++ b/privatevoice.src/internal/history/history.go
@@ -0,0 +1,480 @@
+package history
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+	"unicode/utf8"
+	"voicesnap/internal/logger"
+	"voicesnap/internal/paths"
+)
+
+const defaultRetentionDays = 30
+const defaultPageSize = 100
+const maxPageSize = 500
+
+// Entry represents a single recognition history item.
+type Entry struct {
+	Text       string `json:"text"`
+	Timestamp  int64  `json:"timestamp"`            // Unix milliseconds
+	ExportedAt int64  `json:"exportedAt,omitempty"` // Unix milliseconds; 0 means unexported
+}
+
+// ExportScope limits which unexported entries are returned for correction CSV export.
+type ExportScope string
+
+const (
+	ExportScopeToday     ExportScope = "today"
+	ExportScopeLast7Days ExportScope = "last7Days"
+	ExportScopeAll       ExportScope = "all"
+)
+
+// Store manages recognition history with JSON file persistence.
+type Store struct {
+	mu            sync.Mutex
+	entries       []Entry
+	retentionDays int
+	path          string
+}
+
+type fileData struct {
+	RetentionDays *int    `json:"retentionDays"`
+	Entries       []Entry `json:"entries"`
+}
+
+// PageResult contains one page of history entries plus pagination metadata.
+type PageResult struct {
+	Entries    []Entry `json:"entries"`
+	Total      int     `json:"total"`
+	Page       int     `json:"page"`
+	PageSize   int     `json:"pageSize"`
+	TotalPages int     `json:"totalPages"`
+}
+
+// Counts summarizes history state across the full retained dataset.
+type Counts struct {
+	TotalCount               int `json:"totalCount"`
+	ExportedCount            int `json:"exportedCount"`
+	UnexportedTodayCount     int `json:"unexportedTodayCount"`
+	UnexportedLast7DaysCount int `json:"unexportedLast7DaysCount"`
+	UnexportedAllCount       int `json:"unexportedAllCount"`
+}
+
+// ClearExportedResult reports how many exported entries were removed.
+type ClearExportedResult struct {
+	DeletedCount   int `json:"deletedCount"`
+	RemainingCount int `json:"remainingCount"`
+}
+
+// New creates a new history store, loading existing data from disk.
+func New() *Store {
+	s := &Store{
+		retentionDays: defaultRetentionDays,
+		path:          historyPath(),
+	}
+	s.load()
+	s.pruneAndSave()
+	return s
+}
+
+// Add inserts a new entry at the top and persists to disk.
+// Single-character results (e.g. "." "。") are noise and skipped.
+func (s *Store) Add(text string) {
+	if utf8.RuneCountInString(text) <= 1 {
+		return
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	entry := Entry{
+		Text:      text,
+		Timestamp: time.Now().UnixMilli(),
+	}
+
+	// Prepend
+	s.entries = append([]Entry{entry}, s.entries...)
+	s.pruneUnlocked()
+
+	s.save()
+}
+
+// GetAll returns all history entries (newest first).
+func (s *Store) GetAll() []Entry {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.pruneUnlocked() {
+		s.save()
+	}
+
+	result := make([]Entry, len(s.entries))
+	copy(result, s.entries)
+	return result
+}
+
+// GetPage returns a clamped page of history entries (newest first).
+func (s *Store) GetPage(page int, pageSize int) PageResult {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.pruneUnlocked() {
+		s.save()
+	}
+
+	pageSize = normalizePageSize(pageSize)
+	total := len(s.entries)
+	totalPages := 0
+	if total > 0 {
+		totalPages = (total + pageSize - 1) / pageSize
+	}
+	page = normalizePage(page, totalPages)
+
+	result := PageResult{
+		Total:      total,
+		Page:       page,
+		PageSize:   pageSize,
+		TotalPages: totalPages,
+		Entries:    []Entry{},
+	}
+	if total == 0 {
+		return result
+	}
+
+	start := (page - 1) * pageSize
+	if start >= total {
+		start = 0
+		result.Page = 1
+	}
+	end := start + pageSize
+	if end > total {
+		end = total
+	}
+	result.Entries = make([]Entry, end-start)
+	copy(result.Entries, s.entries[start:end])
+	return result
+}
+
+// Counts returns full-history counters used by paginated frontends.
+func (s *Store) Counts(now time.Time, loc *time.Location) Counts {
+	if loc == nil {
+		loc = time.Local
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.pruneUnlocked() {
+		s.save()
+	}
+
+	todayCutoff := exportScopeCutoff(ExportScopeToday, now, loc)
+	last7DaysCutoff := exportScopeCutoff(ExportScopeLast7Days, now, loc)
+	counts := Counts{TotalCount: len(s.entries)}
+	for _, entry := range s.entries {
+		if entry.ExportedAt > 0 {
+			counts.ExportedCount++
+			continue
+		}
+		counts.UnexportedAllCount++
+		if entry.Timestamp >= todayCutoff {
+			counts.UnexportedTodayCount++
+		}
+		if entry.Timestamp >= last7DaysCutoff {
+			counts.UnexportedLast7DaysCount++
+		}
+	}
+	return counts
+}
+
+// GetUnexported returns unexported history entries within the requested export scope.
+func (s *Store) GetUnexported(scope ExportScope, now time.Time, loc *time.Location) []Entry {
+	if loc == nil {
+		loc = time.Local
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.pruneUnlocked() {
+		s.save()
+	}
+
+	cutoff := exportScopeCutoff(scope, now, loc)
+	result := make([]Entry, 0, len(s.entries))
+	for _, entry := range s.entries {
+		if entry.ExportedAt > 0 {
+			continue
+		}
+		if cutoff > 0 && entry.Timestamp < cutoff {
+			continue
+		}
+		result = append(result, entry)
+	}
+	return result
+}
+
+// MarkExported marks entries as exported after a correction CSV has been written successfully.
+func (s *Store) MarkExported(timestamps []int64, exportedAt int64) (int, error) {
+	if len(timestamps) == 0 {
+		return 0, nil
+	}
+	if exportedAt <= 0 {
+		exportedAt = time.Now().UnixMilli()
+	}
+
+	targets := make(map[int64]struct{}, len(timestamps))
+	for _, timestamp := range timestamps {
+		targets[timestamp] = struct{}{}
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	updated := 0
+	previous := map[int]int64{}
+	for i := range s.entries {
+		if _, ok := targets[s.entries[i].Timestamp]; !ok {
+			continue
+		}
+		if s.entries[i].ExportedAt > 0 {
+			continue
+		}
+		previous[i] = s.entries[i].ExportedAt
+		s.entries[i].ExportedAt = exportedAt
+		updated++
+	}
+	if updated > 0 {
+		if err := s.save(); err != nil {
+			for index, value := range previous {
+				s.entries[index].ExportedAt = value
+			}
+			return 0, err
+		}
+	}
+	return updated, nil
+}
+
+// Delete removes an entry by its timestamp.
+func (s *Store) Delete(timestamp int64) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for i, e := range s.entries {
+		if e.Timestamp == timestamp {
+			s.entries = append(s.entries[:i], s.entries[i+1:]...)
+			s.save()
+			return
+		}
+	}
+}
+
+// ClearAll removes all entries.
+func (s *Store) ClearAll() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.entries = nil
+	s.save()
+}
+
+// ClearExported removes all entries that have been successfully exported.
+func (s *Store) ClearExported() (ClearExportedResult, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	previous := make([]Entry, len(s.entries))
+	copy(previous, s.entries)
+
+	kept := s.entries[:0]
+	deleted := 0
+	for _, entry := range s.entries {
+		if entry.ExportedAt > 0 {
+			deleted++
+			continue
+		}
+		kept = append(kept, entry)
+	}
+	if deleted == 0 {
+		return ClearExportedResult{DeletedCount: 0, RemainingCount: len(s.entries)}, nil
+	}
+
+	s.entries = kept
+	if err := s.save(); err != nil {
+		s.entries = previous
+		return ClearExportedResult{}, err
+	}
+	return ClearExportedResult{DeletedCount: deleted, RemainingCount: len(s.entries)}, nil
+}
+
+// GetRetentionDays returns the current retention period.
+func (s *Store) GetRetentionDays() int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.retentionDays
+}
+
+// SetRetentionDays sets the retention period and prunes old entries.
+func (s *Store) SetRetentionDays(days int) error {
+	if !validRetentionDays(days) {
+		return fmt.Errorf("unsupported history retention days: %d", days)
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.retentionDays = days
+	s.pruneUnlocked()
+	s.save()
+	return nil
+}
+
+// Path returns the persisted history file path.
+func (s *Store) Path() string {
+	return s.path
+}
+
+// prune removes entries older than the retention period.
+func (s *Store) pruneAndSave() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.pruneUnlocked() {
+		s.save()
+	}
+}
+
+func (s *Store) pruneUnlocked() bool {
+	if s.retentionDays <= 0 {
+		return false // 0 = keep forever
+	}
+
+	before := len(s.entries)
+	cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli()
+	kept := s.entries[:0]
+	for _, e := range s.entries {
+		if e.Timestamp >= cutoff {
+			kept = append(kept, e)
+		}
+	}
+	s.entries = kept
+	return len(s.entries) != before
+}
+
+func (s *Store) load() {
+	data, err := os.ReadFile(s.path)
+	if err != nil {
+		return
+	}
+
+	var fd fileData
+	if err := json.Unmarshal(data, &fd); err != nil {
+		logger.Error("Failed to parse history: %v", err)
+		return
+	}
+
+	s.entries = fd.Entries
+	if fd.RetentionDays != nil {
+		s.retentionDays = normalizeRetentionDays(*fd.RetentionDays)
+	}
+}
+
+func (s *Store) save() error {
+	if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
+		logger.Error("Failed to create history dir: %v", err)
+		return err
+	}
+
+	days := s.retentionDays
+	fd := fileData{
+		RetentionDays: &days,
+		Entries:       s.entries,
+	}
+
+	data, err := json.MarshalIndent(fd, "", "  ")
+	if err != nil {
+		logger.Error("Failed to marshal history: %v", err)
+		return err
+	}
+
+	if err := os.WriteFile(s.path, data, 0600); err != nil {
+		logger.Error("Failed to save history: %v", err)
+		return err
+	}
+	if err := os.Chmod(s.path, 0600); err != nil {
+		logger.Error("Failed to set history permissions: %v", err)
+		return err
+	}
+	return nil
+}
+
+func historyPath() string {
+	return paths.File("history.json")
+}
+
+func normalizeRetentionDays(days int) int {
+	if validRetentionDays(days) {
+		return days
+	}
+	return defaultRetentionDays
+}
+
+func validRetentionDays(days int) bool {
+	switch days {
+	case 0, 7, 30, 90:
+		return true
+	default:
+		return false
+	}
+}
+
+func normalizePageSize(pageSize int) int {
+	if pageSize <= 0 {
+		return defaultPageSize
+	}
+	if pageSize > maxPageSize {
+		return maxPageSize
+	}
+	return pageSize
+}
+
+func normalizePage(page int, totalPages int) int {
+	if totalPages <= 0 {
+		return 1
+	}
+	if page < 1 {
+		return 1
+	}
+	if page > totalPages {
+		return totalPages
+	}
+	return page
+}
+
+// ParseExportScope normalizes frontend export scope values.
+func ParseExportScope(value string) (ExportScope, error) {
+	scope := ExportScope(value)
+	switch scope {
+	case "", ExportScopeAll:
+		return ExportScopeAll, nil
+	case ExportScopeToday, ExportScopeLast7Days:
+		return scope, nil
+	default:
+		return "", fmt.Errorf("unsupported history export scope: %s", value)
+	}
+}
+
+func exportScopeCutoff(scope ExportScope, now time.Time, loc *time.Location) int64 {
+	switch scope {
+	case ExportScopeToday:
+		localNow := now.In(loc)
+		year, month, day := localNow.Date()
+		return time.Date(year, month, day, 0, 0, 0, 0, loc).UnixMilli()
+	case ExportScopeLast7Days:
+		return now.In(loc).AddDate(0, 0, -7).UnixMilli()
+	default:
+		return 0
+	}
+}
diff --git a/privatevoice.src/internal/history/history_test.go b/privatevoice.src/internal/history/history_test.go
new file mode 100644
index 0000000..4daa332
--- /dev/null
+++ b/privatevoice.src/internal/history/history_test.go
@@ -0,0 +1,379 @@
+package history
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestAddDoesNotCapAtFiftyEntries(t *testing.T) {
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+	}
+
+	for i := 0; i < 60; i++ {
+		store.Add("history entry")
+	}
+
+	entries := store.GetAll()
+	if len(entries) != 60 {
+		t.Fatalf("expected 60 entries, got %d", len(entries))
+	}
+
+	fd := readHistoryFile(t, store.path)
+	if len(fd.Entries) != 60 {
+		t.Fatalf("expected 60 persisted entries, got %d", len(fd.Entries))
+	}
+}
+
+func TestRetentionPrunesMemoryAndFile(t *testing.T) {
+	now := time.Now()
+	store := &Store{
+		retentionDays: 7,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+		entries: []Entry{
+			{Text: "new entry", Timestamp: now.UnixMilli()},
+			{Text: "old entry", Timestamp: now.AddDate(0, 0, -8).UnixMilli()},
+		},
+	}
+
+	entries := store.GetAll()
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 retained entry, got %d", len(entries))
+	}
+	if entries[0].Text != "new entry" {
+		t.Fatalf("expected new entry to be retained, got %q", entries[0].Text)
+	}
+
+	fd := readHistoryFile(t, store.path)
+	if len(fd.Entries) != 1 {
+		t.Fatalf("expected 1 persisted entry, got %d", len(fd.Entries))
+	}
+	if fd.Entries[0].Text != "new entry" {
+		t.Fatalf("expected persisted new entry, got %q", fd.Entries[0].Text)
+	}
+}
+
+func TestSetRetentionDaysRejectsUnsupportedValues(t *testing.T) {
+	store := &Store{
+		retentionDays: 30,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+	}
+
+	if err := store.SetRetentionDays(365); err == nil {
+		t.Fatal("expected unsupported retention value to fail")
+	}
+	if got := store.GetRetentionDays(); got != 30 {
+		t.Fatalf("expected retention to remain 30, got %d", got)
+	}
+}
+
+func TestLoadNormalizesUnsupportedRetention(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "history.json")
+	data, err := json.Marshal(fileData{
+		RetentionDays: intPtr(365),
+		Entries:       []Entry{{Text: "entry", Timestamp: time.Now().UnixMilli()}},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, data, 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store := &Store{
+		retentionDays: 30,
+		path:          path,
+	}
+	store.load()
+
+	if got := store.GetRetentionDays(); got != 30 {
+		t.Fatalf("expected invalid loaded retention to normalize to 30, got %d", got)
+	}
+}
+
+func TestGetUnexportedFiltersByScope(t *testing.T) {
+	loc := time.FixedZone("UTC+8", 8*60*60)
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, loc)
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+		entries: []Entry{
+			{Text: "today", Timestamp: now.Add(-time.Hour).UnixMilli()},
+			{Text: "exported", Timestamp: now.Add(-2 * time.Hour).UnixMilli(), ExportedAt: now.UnixMilli()},
+			{Text: "yesterday", Timestamp: now.AddDate(0, 0, -1).UnixMilli()},
+			{Text: "old", Timestamp: now.AddDate(0, 0, -8).UnixMilli()},
+		},
+	}
+
+	today := store.GetUnexported(ExportScopeToday, now, loc)
+	if len(today) != 1 || today[0].Text != "today" {
+		t.Fatalf("expected only today's unexported entry, got %+v", today)
+	}
+
+	last7Days := store.GetUnexported(ExportScopeLast7Days, now, loc)
+	if len(last7Days) != 2 || last7Days[0].Text != "today" || last7Days[1].Text != "yesterday" {
+		t.Fatalf("expected today and yesterday, got %+v", last7Days)
+	}
+
+	all := store.GetUnexported(ExportScopeAll, now, loc)
+	if len(all) != 3 || all[0].Text != "today" || all[1].Text != "yesterday" || all[2].Text != "old" {
+		t.Fatalf("expected all unexported entries, got %+v", all)
+	}
+}
+
+func TestGetPageClampsAndReturnsCurrentSlice(t *testing.T) {
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+	}
+	for i := 0; i < 12; i++ {
+		store.entries = append(store.entries, Entry{
+			Text:      fmt.Sprintf("entry %02d", i),
+			Timestamp: int64(1000 - i),
+		})
+	}
+
+	page := store.GetPage(2, 5)
+	if page.Total != 12 || page.TotalPages != 3 || page.Page != 2 || page.PageSize != 5 {
+		t.Fatalf("unexpected page metadata: %+v", page)
+	}
+	if len(page.Entries) != 5 || page.Entries[0].Text != "entry 05" || page.Entries[4].Text != "entry 09" {
+		t.Fatalf("unexpected page entries: %+v", page.Entries)
+	}
+
+	last := store.GetPage(99, 5)
+	if last.Page != 3 || len(last.Entries) != 2 || last.Entries[0].Text != "entry 10" {
+		t.Fatalf("expected clamped last page, got %+v", last)
+	}
+
+	defaultSize := store.GetPage(1, 0)
+	if defaultSize.PageSize != defaultPageSize {
+		t.Fatalf("expected default page size %d, got %d", defaultPageSize, defaultSize.PageSize)
+	}
+}
+
+func TestGetPageHandlesEmptyHistory(t *testing.T) {
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+	}
+
+	page := store.GetPage(4, 100)
+	if page.Total != 0 || page.TotalPages != 0 || page.Page != 1 || len(page.Entries) != 0 {
+		t.Fatalf("unexpected empty page: %+v", page)
+	}
+}
+
+func TestCountsSummarizesFullHistory(t *testing.T) {
+	loc := time.FixedZone("UTC+8", 8*60*60)
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, loc)
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+		entries: []Entry{
+			{Text: "today", Timestamp: now.Add(-time.Hour).UnixMilli()},
+			{Text: "exported", Timestamp: now.Add(-2 * time.Hour).UnixMilli(), ExportedAt: now.UnixMilli()},
+			{Text: "yesterday", Timestamp: now.AddDate(0, 0, -1).UnixMilli()},
+			{Text: "old", Timestamp: now.AddDate(0, 0, -8).UnixMilli()},
+			{Text: "corrupt exportedAt", Timestamp: now.Add(-3 * time.Hour).UnixMilli(), ExportedAt: -1},
+		},
+	}
+
+	counts := store.Counts(now, loc)
+	if counts.TotalCount != 5 {
+		t.Fatalf("expected total 5, got %+v", counts)
+	}
+	if counts.ExportedCount != 1 {
+		t.Fatalf("expected exported 1, got %+v", counts)
+	}
+	if counts.UnexportedAllCount != 4 || counts.UnexportedLast7DaysCount != 3 || counts.UnexportedTodayCount != 2 {
+		t.Fatalf("unexpected unexported counts: %+v", counts)
+	}
+}
+
+func TestMarkExportedPersistsExportedAt(t *testing.T) {
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC)
+	exportedAt := now.Add(time.Minute).UnixMilli()
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+		entries: []Entry{
+			{Text: "one", Timestamp: now.UnixMilli()},
+			{Text: "two", Timestamp: now.Add(-time.Minute).UnixMilli()},
+		},
+	}
+
+	updated, err := store.MarkExported([]int64{store.entries[0].Timestamp}, exportedAt)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if updated != 1 {
+		t.Fatalf("expected 1 updated entry, got %d", updated)
+	}
+	if store.entries[0].ExportedAt != exportedAt {
+		t.Fatalf("expected exportedAt %d, got %d", exportedAt, store.entries[0].ExportedAt)
+	}
+	if store.entries[1].ExportedAt != 0 {
+		t.Fatalf("expected unmatched entry to remain unexported, got %d", store.entries[1].ExportedAt)
+	}
+
+	fd := readHistoryFile(t, store.path)
+	if len(fd.Entries) != 2 || fd.Entries[0].ExportedAt != exportedAt {
+		t.Fatalf("expected exportedAt to persist, got %+v", fd.Entries)
+	}
+}
+
+func TestMarkExportedRollsBackOnSaveFailure(t *testing.T) {
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC)
+	path := filepath.Join(t.TempDir(), "history.json")
+	if err := os.Mkdir(path, 0755); err != nil {
+		t.Fatal(err)
+	}
+	store := &Store{
+		retentionDays: 0,
+		path:          path,
+		entries: []Entry{
+			{Text: "one", Timestamp: now.UnixMilli()},
+		},
+	}
+
+	updated, err := store.MarkExported([]int64{store.entries[0].Timestamp}, now.UnixMilli())
+	if err == nil {
+		t.Fatal("expected save failure")
+	}
+	if updated != 0 {
+		t.Fatalf("expected 0 updated entries after rollback, got %d", updated)
+	}
+	if store.entries[0].ExportedAt != 0 {
+		t.Fatalf("expected exportedAt rollback, got %d", store.entries[0].ExportedAt)
+	}
+}
+
+func TestMarkExportedReplacesNonPositiveExportedAt(t *testing.T) {
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC)
+	exportedAt := now.Add(time.Minute).UnixMilli()
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+		entries: []Entry{
+			{Text: "negative", Timestamp: now.UnixMilli(), ExportedAt: -1},
+		},
+	}
+
+	updated, err := store.MarkExported([]int64{store.entries[0].Timestamp}, exportedAt)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if updated != 1 {
+		t.Fatalf("expected 1 updated entry, got %d", updated)
+	}
+	if store.entries[0].ExportedAt != exportedAt {
+		t.Fatalf("expected exportedAt to be replaced, got %d", store.entries[0].ExportedAt)
+	}
+}
+
+func TestClearExportedRemovesOnlyPositiveExportedAt(t *testing.T) {
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC)
+	store := &Store{
+		retentionDays: 0,
+		path:          filepath.Join(t.TempDir(), "history.json"),
+		entries: []Entry{
+			{Text: "exported", Timestamp: now.UnixMilli(), ExportedAt: now.UnixMilli()},
+			{Text: "unexported", Timestamp: now.Add(-time.Minute).UnixMilli()},
+			{Text: "zero", Timestamp: now.Add(-2 * time.Minute).UnixMilli(), ExportedAt: 0},
+			{Text: "negative", Timestamp: now.Add(-3 * time.Minute).UnixMilli(), ExportedAt: -1},
+		},
+	}
+
+	result, err := store.ClearExported()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result.DeletedCount != 1 || result.RemainingCount != 3 {
+		t.Fatalf("unexpected result: %+v", result)
+	}
+	if len(store.entries) != 3 {
+		t.Fatalf("expected 3 remaining entries, got %d", len(store.entries))
+	}
+	for _, entry := range store.entries {
+		if entry.Text == "exported" {
+			t.Fatalf("exported entry was not removed: %+v", store.entries)
+		}
+	}
+
+	fd := readHistoryFile(t, store.path)
+	if len(fd.Entries) != 3 {
+		t.Fatalf("expected persisted 3 entries, got %+v", fd.Entries)
+	}
+}
+
+func TestClearExportedRollsBackOnSaveFailure(t *testing.T) {
+	now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC)
+	path := filepath.Join(t.TempDir(), "history.json")
+	if err := os.Mkdir(path, 0755); err != nil {
+		t.Fatal(err)
+	}
+	store := &Store{
+		retentionDays: 0,
+		path:          path,
+		entries: []Entry{
+			{Text: "exported", Timestamp: now.UnixMilli(), ExportedAt: now.UnixMilli()},
+			{Text: "unexported", Timestamp: now.Add(-time.Minute).UnixMilli()},
+		},
+	}
+
+	result, err := store.ClearExported()
+	if err == nil {
+		t.Fatal("expected save failure")
+	}
+	if result.DeletedCount != 0 || result.RemainingCount != 0 {
+		t.Fatalf("expected empty result after rollback, got %+v", result)
+	}
+	if len(store.entries) != 2 || store.entries[0].Text != "exported" {
+		t.Fatalf("expected entries rollback, got %+v", store.entries)
+	}
+}
+
+func TestLoadOldHistoryWithoutExportedAtDefaultsToUnexported(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "history.json")
+	data := []byte(`{"retentionDays":0,"entries":[{"text":"old format","timestamp":1780134620883}]}`)
+	if err := os.WriteFile(path, data, 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	store := &Store{
+		retentionDays: 30,
+		path:          path,
+	}
+	store.load()
+
+	entries := store.GetAll()
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].ExportedAt != 0 {
+		t.Fatalf("expected old entry to load as unexported, got exportedAt=%d", entries[0].ExportedAt)
+	}
+}
+
+func readHistoryFile(t *testing.T, path string) fileData {
+	t.Helper()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var fd fileData
+	if err := json.Unmarshal(data, &fd); err != nil {
+		t.Fatal(err)
+	}
+	return fd
+}
+
+func intPtr(v int) *int {
+	return &v
+}
diff --git a/VoiceSnapGo/internal/hotkey/hotkey.go b/privatevoice.src/internal/hotkey/hotkey.go
similarity index 100%
rename from VoiceSnapGo/internal/hotkey/hotkey.go
rename to privatevoice.src/internal/hotkey/hotkey.go
diff --git a/VoiceSnapGo/internal/hotkey/hotkey_darwin.go b/privatevoice.src/internal/hotkey/hotkey_darwin.go
similarity index 96%
rename from VoiceSnapGo/internal/hotkey/hotkey_darwin.go
rename to privatevoice.src/internal/hotkey/hotkey_darwin.go
index 1ddda60..043b4ee 100755
--- a/VoiceSnapGo/internal/hotkey/hotkey_darwin.go
+++ b/privatevoice.src/internal/hotkey/hotkey_darwin.go
@@ -151,7 +151,7 @@
 	if (g_activityToken != nil) return;
 	g_activityToken = [[NSProcessInfo processInfo]
 		beginActivityWithOptions:NSActivityUserInitiatedAllowingIdleSystemSleep
-		reason:@"VoiceSnap global hotkey listener"];
+		reason:@"PrivateVoice Input global hotkey listener"];
 }
 
 static void* eventTapThreadMain(void* arg) {
@@ -186,9 +186,16 @@
 static int isSideModifierDown(int keyCode) {
 	if (keyCode >= 0 && keyCode < 128) {
 		if (CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode)) return 1;
-		if (g_keysDown[keyCode] && g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.25) return 1;
-		if (g_keysDown[keyCode]) g_keysDown[keyCode] = 0;
-		if (g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.12) return 1;
+		unsigned long long mask = modifierMaskForKey((unsigned short)keyCode);
+		int modifierDown = mask != 0 ? isModifierDown(mask) : 0;
+		double age = g_lastDownAt[keyCode] > 0 ? nowSeconds() - g_lastDownAt[keyCode] : 999.0;
+		if (g_keysDown[keyCode]) {
+			if (modifierDown) return 1;
+			if (age < 0.25) return 1;
+			g_keysDown[keyCode] = 0;
+			return 0;
+		}
+		if (age < 0.12) return 1;
 	}
 	return 0;
 }
@@ -325,7 +332,7 @@
 	C.beginHotkeyActivity()
 	trusted := C.ensureAccessibility()
 	if trusted == 0 {
-		logger.Info("Accessibility permission not granted for current app identity — hotkey won't work until VoiceSnap is removed and re-added in System Settings > Privacy & Security > Accessibility")
+		logger.Info("Accessibility permission not granted for current app identity — hotkey won't work until PrivateVoice Input is removed and re-added in System Settings > Privacy & Security > Accessibility")
 	} else {
 		logger.Info("Accessibility permission granted")
 	}
diff --git a/VoiceSnapGo/internal/hotkey/hotkey_linux.go b/privatevoice.src/internal/hotkey/hotkey_linux.go
similarity index 100%
rename from VoiceSnapGo/internal/hotkey/hotkey_linux.go
rename to privatevoice.src/internal/hotkey/hotkey_linux.go
diff --git a/VoiceSnapGo/internal/hotkey/hotkey_windows.go b/privatevoice.src/internal/hotkey/hotkey_windows.go
similarity index 100%
rename from VoiceSnapGo/internal/hotkey/hotkey_windows.go
rename to privatevoice.src/internal/hotkey/hotkey_windows.go
diff --git a/VoiceSnapGo/internal/input/paste.go b/privatevoice.src/internal/input/paste.go
similarity index 100%
rename from VoiceSnapGo/internal/input/paste.go
rename to privatevoice.src/internal/input/paste.go
diff --git a/VoiceSnapGo/internal/input/paste_darwin.go b/privatevoice.src/internal/input/paste_darwin.go
similarity index 100%
rename from VoiceSnapGo/internal/input/paste_darwin.go
rename to privatevoice.src/internal/input/paste_darwin.go
diff --git a/VoiceSnapGo/internal/input/paste_linux.go b/privatevoice.src/internal/input/paste_linux.go
similarity index 100%
rename from VoiceSnapGo/internal/input/paste_linux.go
rename to privatevoice.src/internal/input/paste_linux.go
diff --git a/VoiceSnapGo/internal/input/paste_windows.go b/privatevoice.src/internal/input/paste_windows.go
similarity index 100%
rename from VoiceSnapGo/internal/input/paste_windows.go
rename to privatevoice.src/internal/input/paste_windows.go
diff --git a/VoiceSnapGo/internal/logger/logger.go b/privatevoice.src/internal/logger/logger.go
similarity index 100%
rename from VoiceSnapGo/internal/logger/logger.go
rename to privatevoice.src/internal/logger/logger.go
diff --git a/privatevoice.src/internal/model/downloader.go b/privatevoice.src/internal/model/downloader.go
new file mode 100755
index 0000000..7aa9c25
--- /dev/null
+++ b/privatevoice.src/internal/model/downloader.go
@@ -0,0 +1,264 @@
+package model
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"time"
+
+	"voicesnap/internal/logger"
+)
+
+// ProgressCallback is called with download progress (percent, downloaded bytes, total bytes).
+type ProgressCallback func(percent float64, downloaded, total int64)
+
+// Download downloads and extracts the ASR model.
+// It tries the primary URL first, then falls back to the fallback URL.
+func Download(primaryURL, fallbackURL, modelsDir string, progress ProgressCallback) error {
+	profile := DefaultModelProfile()
+	urls := []string{primaryURL, fallbackURL}
+	return DownloadProfile(profile, urls, modelsDir, progress)
+}
+
+func DownloadProfile(profile ModelProfile, urls []string, modelsDir string, progress ProgressCallback) error {
+	if err := os.MkdirAll(modelsDir, 0755); err != nil {
+		return fmt.Errorf("failed to create models dir: %w", err)
+	}
+
+	var lastErr error
+	for i, url := range urls {
+		if url == "" {
+			continue
+		}
+		if err := downloadAndInstallFromURL(profile, url, modelsDir, i+1, progress); err != nil {
+			lastErr = err
+			logger.Info("Model install from URL %d failed: %v", i+1, err)
+			continue
+		}
+
+		if err := UpdateInstalledModelState(modelsDir, profile, profile.InstallDirName, "new"); err != nil {
+			logger.Error("Failed to update model install state: %v", err)
+		}
+		logger.Info("Model download and extraction complete")
+		return nil
+	}
+
+	if lastErr == nil {
+		lastErr = fmt.Errorf("no download URL configured")
+	}
+	return fmt.Errorf("download failed: %w", lastErr)
+}
+
+func downloadAndInstallFromURL(profile ModelProfile, url, modelsDir string, attempt int, progress ProgressCallback) error {
+	runID := fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt)
+	downloadDir := filepath.Join(modelsDir, ".downloads", profile.ID, runID)
+	extractDir := filepath.Join(downloadDir, "extract")
+	stagingDir := filepath.Join(modelsDir, ".staging", profile.ID+"-"+runID)
+	archivePath := filepath.Join(downloadDir, "model_package")
+
+	if err := os.MkdirAll(extractDir, 0755); err != nil {
+		return fmt.Errorf("failed to create download dir: %w", err)
+	}
+	defer os.RemoveAll(downloadDir)
+	defer os.RemoveAll(stagingDir)
+
+	logger.Info("Downloading model from URL %d: %s", attempt, url)
+	if err := downloadFile(url, archivePath, progress); err != nil {
+		return err
+	}
+
+	logger.Info("Extracting model archive...")
+	if err := extractArchive(archivePath, extractDir); err != nil {
+		return fmt.Errorf("extraction failed: %w", err)
+	}
+
+	sourceDir, err := findInstallSource(profile, extractDir)
+	if err != nil {
+		return err
+	}
+
+	if err := moveOrCopyDir(sourceDir, stagingDir); err != nil {
+		return fmt.Errorf("failed to stage model: %w", err)
+	}
+
+	validation := ValidateModelDir(profile, stagingDir)
+	if !validation.Valid {
+		return fmt.Errorf("downloaded model is incomplete: missing=%v problems=%v", validation.Missing, validation.Problems)
+	}
+
+	finalDir := filepath.Join(modelsDir, profile.InstallDirName)
+	return installStagedModel(profile, stagingDir, finalDir, runID)
+}
+
+func downloadFile(url, destPath string, progress ProgressCallback) error {
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
+	}
+
+	totalBytes := resp.ContentLength
+	out, err := os.Create(destPath)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	buf := make([]byte, 32*1024)
+	var downloaded int64
+	lastPercent := -1.0
+
+	for {
+		n, readErr := resp.Body.Read(buf)
+		if n > 0 {
+			if _, writeErr := out.Write(buf[:n]); writeErr != nil {
+				return writeErr
+			}
+			downloaded += int64(n)
+
+			if totalBytes > 0 && progress != nil {
+				pct := float64(downloaded) / float64(totalBytes) * 100
+				if pct-lastPercent >= 0.5 {
+					lastPercent = pct
+					progress(pct, downloaded, totalBytes)
+				}
+			}
+		}
+		if readErr != nil {
+			if readErr == io.EOF {
+				break
+			}
+			return readErr
+		}
+	}
+
+	return nil
+}
+
+func extractArchive(archivePath, destDir string) error {
+	cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output))
+	}
+	return nil
+}
+
+func findInstallSource(profile ModelProfile, extractDir string) (string, error) {
+	if validation := ValidateModelDir(profile, extractDir); validation.Valid {
+		return extractDir, nil
+	}
+
+	var found string
+	err := filepath.WalkDir(extractDir, func(path string, entry os.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		if found != "" || !entry.IsDir() {
+			return nil
+		}
+		if path == extractDir {
+			return nil
+		}
+		if validation := ValidateModelDir(profile, path); validation.Valid {
+			found = path
+			return filepath.SkipDir
+		}
+		return nil
+	})
+	if err != nil {
+		return "", err
+	}
+	if found == "" {
+		return "", fmt.Errorf("no complete %s model found in archive", profile.ID)
+	}
+	return found, nil
+}
+
+func installStagedModel(profile ModelProfile, stagingDir, finalDir, runID string) error {
+	var backupDir string
+	if _, err := os.Stat(finalDir); err == nil {
+		backupDir = finalDir + ".backup-" + runID
+		if err := os.Rename(finalDir, backupDir); err != nil {
+			return fmt.Errorf("failed to move existing model dir: %w", err)
+		}
+	}
+
+	if err := os.Rename(stagingDir, finalDir); err != nil {
+		if copyErr := copyDir(stagingDir, finalDir); copyErr != nil {
+			restoreBackup(finalDir, backupDir)
+			return fmt.Errorf("failed to install staged model: rename=%v copy=%w", err, copyErr)
+		}
+	}
+
+	if validation := ValidateModelDir(profile, finalDir); !validation.Valid {
+		restoreBackup(finalDir, backupDir)
+		return fmt.Errorf("installed model is incomplete: missing=%v problems=%v", validation.Missing, validation.Problems)
+	}
+
+	if backupDir != "" {
+		os.RemoveAll(backupDir)
+	}
+	return nil
+}
+
+func restoreBackup(finalDir, backupDir string) {
+	if backupDir == "" {
+		return
+	}
+	os.RemoveAll(finalDir)
+	os.Rename(backupDir, finalDir)
+}
+
+func moveOrCopyDir(src, dst string) error {
+	os.RemoveAll(dst)
+	if err := os.Rename(src, dst); err == nil {
+		return nil
+	}
+	return copyDir(src, dst)
+}
+
+func copyDir(src, dst string) error {
+	return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		target := filepath.Join(dst, rel)
+		info, err := entry.Info()
+		if err != nil {
+			return err
+		}
+		if entry.IsDir() {
+			return os.MkdirAll(target, info.Mode())
+		}
+		in, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		defer in.Close()
+
+		out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
+		if err != nil {
+			return err
+		}
+		if _, err := io.Copy(out, in); err != nil {
+			out.Close()
+			return err
+		}
+		if err := out.Close(); err != nil {
+			return err
+		}
+		return nil
+	})
+}
diff --git a/privatevoice.src/internal/model/install_state.go b/privatevoice.src/internal/model/install_state.go
new file mode 100644
index 0000000..8448081
--- /dev/null
+++ b/privatevoice.src/internal/model/install_state.go
@@ -0,0 +1,97 @@
+package model
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"time"
+
+	"voicesnap/internal/paths"
+)
+
+type InstallState struct {
+	SchemaVersion   int                            `json:"schemaVersion"`
+	SelectedModelID string                         `json:"selectedModelId"`
+	InstalledModels map[string]InstalledModelState `json:"installedModels"`
+}
+
+type InstalledModelState struct {
+	InstalledAt   int64  `json:"installedAt"`
+	Version       string `json:"version"`
+	Path          string `json:"path"`
+	SourceDirKind string `json:"sourceDirKind"`
+}
+
+func NewInstallState() *InstallState {
+	return &InstallState{
+		SchemaVersion:   1,
+		SelectedModelID: DefaultModelID,
+		InstalledModels: make(map[string]InstalledModelState),
+	}
+}
+
+func LoadInstallState() (*InstallState, error) {
+	return LoadInstallStateFromRoot(paths.ModelsRoot())
+}
+
+func LoadInstallStateFromRoot(modelsRoot string) (*InstallState, error) {
+	data, err := os.ReadFile(filepath.Join(modelsRoot, "state.json"))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return NewInstallState(), nil
+		}
+		return NewInstallState(), err
+	}
+
+	state := NewInstallState()
+	if err := json.Unmarshal(data, state); err != nil {
+		return NewInstallState(), err
+	}
+	normalizeInstallState(state)
+	return state, nil
+}
+
+func SaveInstallState(state *InstallState) error {
+	return SaveInstallStateToRoot(paths.ModelsRoot(), state)
+}
+
+func SaveInstallStateToRoot(modelsRoot string, state *InstallState) error {
+	normalizeInstallState(state)
+	if err := os.MkdirAll(modelsRoot, 0755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(state, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	target := filepath.Join(modelsRoot, "state.json")
+	tmp := target + ".tmp"
+	if err := os.WriteFile(tmp, data, 0644); err != nil {
+		return err
+	}
+	return os.Rename(tmp, target)
+}
+
+func UpdateInstalledModelState(modelsRoot string, profile ModelProfile, relPath, sourceDirKind string) error {
+	state, _ := LoadInstallStateFromRoot(modelsRoot)
+	state.SelectedModelID = profile.ID
+	state.InstalledModels[profile.ID] = InstalledModelState{
+		InstalledAt:   time.Now().UnixMilli(),
+		Version:       "unknown",
+		Path:          relPath,
+		SourceDirKind: sourceDirKind,
+	}
+	return SaveInstallStateToRoot(modelsRoot, state)
+}
+
+func normalizeInstallState(state *InstallState) {
+	if state.SchemaVersion == 0 {
+		state.SchemaVersion = 1
+	}
+	state.SelectedModelID = NormalizeModelID(state.SelectedModelID)
+	if state.InstalledModels == nil {
+		state.InstalledModels = make(map[string]InstalledModelState)
+	}
+}
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
new file mode 100644
index 0000000..90a325e
--- /dev/null
+++ b/privatevoice.src/internal/model/model_test.go
@@ -0,0 +1,389 @@
+package model
+
+import (
+	"archive/tar"
+	"bytes"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestRegistryReturnsDefaultSenseVoice(t *testing.T) {
+	profile := DefaultModelProfile()
+	if profile.ID != DefaultModelID {
+		t.Fatalf("default model id = %q, want %q", profile.ID, DefaultModelID)
+	}
+	if profile.BackendKind != BackendSenseVoice {
+		t.Fatalf("backend kind = %q, want %q", profile.BackendKind, BackendSenseVoice)
+	}
+	if _, err := GetModelProfile("missing"); err == nil {
+		t.Fatal("expected unknown model id to fail")
+	}
+}
+
+func TestValidateSenseVoiceAnyOfAndAllOf(t *testing.T) {
+	dir := t.TempDir()
+	writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+	writeTestFile(t, filepath.Join(dir, "model.onnx"), "model")
+
+	result := ValidateModelDir(DefaultModelProfile(), dir)
+	if !result.Valid {
+		t.Fatalf("expected valid model dir, missing=%v problems=%v", result.Missing, result.Problems)
+	}
+	if got := filepath.Base(result.Files["model"]); got != "model.onnx" {
+		t.Fatalf("model file = %q, want model.onnx", got)
+	}
+}
+
+func TestValidateSenseVoicePrefersInt8(t *testing.T) {
+	dir := t.TempDir()
+	writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+	writeTestFile(t, filepath.Join(dir, "model.onnx"), "model")
+	writeTestFile(t, filepath.Join(dir, "model.int8.onnx"), "int8")
+
+	result := ValidateModelDir(DefaultModelProfile(), dir)
+	if !result.Valid {
+		t.Fatalf("expected valid model dir, missing=%v problems=%v", result.Missing, result.Problems)
+	}
+	if got := filepath.Base(result.Files["model"]); got != "model.int8.onnx" {
+		t.Fatalf("model file = %q, want model.int8.onnx", got)
+	}
+}
+
+func TestValidateSenseVoiceRejectsMissingTokens(t *testing.T) {
+	dir := t.TempDir()
+	writeTestFile(t, filepath.Join(dir, "model.int8.onnx"), "model")
+
+	result := ValidateModelDir(DefaultModelProfile(), dir)
+	if result.Valid {
+		t.Fatal("expected missing tokens to fail validation")
+	}
+}
+
+func TestValidateSenseVoiceRejectsZeroByteFile(t *testing.T) {
+	dir := t.TempDir()
+	writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+	writeTestFile(t, filepath.Join(dir, "model.int8.onnx"), "")
+
+	result := ValidateModelDir(DefaultModelProfile(), dir)
+	if result.Valid {
+		t.Fatal("expected zero-byte model file to fail validation")
+	}
+}
+
+func TestResolveModelUsesLegacyDirWithoutState(t *testing.T) {
+	root := t.TempDir()
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice"))
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !resolved.IsUsable() {
+		t.Fatalf("expected usable model, status=%s missing=%v problems=%v", resolved.Status, resolved.Missing, resolved.Problems)
+	}
+	if resolved.SourceDirKind != "legacy" {
+		t.Fatalf("source dir kind = %q, want legacy", resolved.SourceDirKind)
+	}
+}
+
+func TestResolveModelPrefersNewDirWithoutState(t *testing.T) {
+	root := t.TempDir()
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice"))
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice-zh"))
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.SourceDirKind != "new" {
+		t.Fatalf("source dir kind = %q, want new", resolved.SourceDirKind)
+	}
+	if got := filepath.Base(resolved.RootDir); got != "sensevoice-zh" {
+		t.Fatalf("root dir = %q, want sensevoice-zh", got)
+	}
+}
+
+func TestResolveModelInvalidStateFallsBackToLegacy(t *testing.T) {
+	root := t.TempDir()
+	writeTestFile(t, filepath.Join(root, "state.json"), "{invalid json")
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice"))
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.Status != ModelStateInvalidFallbackFound {
+		t.Fatalf("status = %s, want %s", resolved.Status, ModelStateInvalidFallbackFound)
+	}
+	if resolved.SourceDirKind != "legacy" {
+		t.Fatalf("source dir kind = %q, want legacy", resolved.SourceDirKind)
+	}
+}
+
+func TestResolveModelStatePointsToBrokenDirFallsBackToLegacy(t *testing.T) {
+	root := t.TempDir()
+	broken := filepath.Join(root, "broken")
+	if err := os.MkdirAll(broken, 0755); err != nil {
+		t.Fatal(err)
+	}
+	writeTestFile(t, filepath.Join(broken, "tokens.txt"), "tokens")
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice"))
+
+	state := NewInstallState()
+	state.InstalledModels[DefaultModelID] = InstalledModelState{
+		Path:          "broken",
+		SourceDirKind: "new",
+	}
+	if err := SaveInstallStateToRoot(root, state); err != nil {
+		t.Fatal(err)
+	}
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.Status != ModelStateInvalidFallbackFound {
+		t.Fatalf("status = %s, want %s", resolved.Status, ModelStateInvalidFallbackFound)
+	}
+	if resolved.SourceDirKind != "legacy" {
+		t.Fatalf("source dir kind = %q, want legacy", resolved.SourceDirKind)
+	}
+}
+
+func TestResolveModelRejectsStatePathOutsideModelsRoot(t *testing.T) {
+	root := t.TempDir()
+	outside := t.TempDir()
+	createValidSenseVoice(t, filepath.Join(outside, "outside-model"))
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice-zh"))
+
+	state := NewInstallState()
+	state.InstalledModels[DefaultModelID] = InstalledModelState{
+		Path:          filepath.Join(outside, "outside-model"),
+		SourceDirKind: "new",
+	}
+	if err := SaveInstallStateToRoot(root, state); err != nil {
+		t.Fatal(err)
+	}
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.Status != ModelStateInvalidFallbackFound {
+		t.Fatalf("status = %s, want %s", resolved.Status, ModelStateInvalidFallbackFound)
+	}
+	if got := filepath.Base(resolved.RootDir); got != "sensevoice-zh" {
+		t.Fatalf("root dir = %q, want sensevoice-zh", got)
+	}
+}
+
+func TestResolveModelRejectsStatePathEscapingModelsRoot(t *testing.T) {
+	root := t.TempDir()
+	createValidSenseVoice(t, filepath.Join(root, "sensevoice"))
+
+	state := NewInstallState()
+	state.InstalledModels[DefaultModelID] = InstalledModelState{
+		Path:          "../outside-model",
+		SourceDirKind: "new",
+	}
+	if err := SaveInstallStateToRoot(root, state); err != nil {
+		t.Fatal(err)
+	}
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.Status != ModelStateInvalidFallbackFound {
+		t.Fatalf("status = %s, want %s", resolved.Status, ModelStateInvalidFallbackFound)
+	}
+	if resolved.SourceDirKind != "legacy" {
+		t.Fatalf("source dir kind = %q, want legacy", resolved.SourceDirKind)
+	}
+}
+
+func TestResolveModelIncompleteDir(t *testing.T) {
+	root := t.TempDir()
+	dir := filepath.Join(root, "sensevoice-zh")
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		t.Fatal(err)
+	}
+	writeTestFile(t, filepath.Join(dir, "model.int8.onnx"), "model")
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resolved.Status != ModelInstalledIncomplete {
+		t.Fatalf("status = %s, want %s", resolved.Status, ModelInstalledIncomplete)
+	}
+}
+
+func TestInstallStateRoundTrip(t *testing.T) {
+	root := t.TempDir()
+	if err := UpdateInstalledModelState(root, DefaultModelProfile(), "sensevoice-zh", "new"); err != nil {
+		t.Fatal(err)
+	}
+
+	state, err := LoadInstallStateFromRoot(root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if state.SelectedModelID != DefaultModelID {
+		t.Fatalf("selected model = %q, want %q", state.SelectedModelID, DefaultModelID)
+	}
+	installed := state.InstalledModels[DefaultModelID]
+	if installed.Path != "sensevoice-zh" {
+		t.Fatalf("installed path = %q, want sensevoice-zh", installed.Path)
+	}
+}
+
+func TestDownloadFailureDoesNotRemoveLegacyModel(t *testing.T) {
+	root := t.TempDir()
+	legacyDir := filepath.Join(root, "sensevoice")
+	createValidSenseVoice(t, legacyDir)
+
+	err := DownloadProfile(DefaultModelProfile(), []string{"://bad-url"}, root, nil)
+	if err == nil {
+		t.Fatal("expected download failure")
+	}
+
+	result := ValidateModelDir(DefaultModelProfile(), legacyDir)
+	if !result.Valid {
+		t.Fatalf("legacy model was damaged, missing=%v problems=%v", result.Missing, result.Problems)
+	}
+}
+
+func TestDownloadProfileFallsBackWhenPrimaryArchiveInvalid(t *testing.T) {
+	root := t.TempDir()
+	validArchive := tarArchive(t, map[string]string{
+		"sensevoice/tokens.txt":      "tokens",
+		"sensevoice/model.int8.onnx": "fallback-model",
+	})
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/bad":
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte("not a tar archive"))
+		case "/fallback":
+			w.WriteHeader(http.StatusOK)
+			w.Write(validArchive)
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer server.Close()
+
+	err := DownloadProfile(DefaultModelProfile(), []string{server.URL + "/bad", server.URL + "/fallback"}, root, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resolved, err := ResolveModelInRoot(DefaultModelID, root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !resolved.IsUsable() {
+		t.Fatalf("expected usable downloaded model, status=%s missing=%v problems=%v", resolved.Status, resolved.Missing, resolved.Problems)
+	}
+	if got := filepath.Base(resolved.RootDir); got != "sensevoice-zh" {
+		t.Fatalf("root dir = %q, want sensevoice-zh", got)
+	}
+}
+
+func TestDownloadProfileReplacesExistingNewDir(t *testing.T) {
+	root := t.TempDir()
+	finalDir := filepath.Join(root, "sensevoice-zh")
+	createValidSenseVoice(t, finalDir)
+	writeTestFile(t, filepath.Join(finalDir, "model.int8.onnx"), "old-model")
+
+	validArchive := tarArchive(t, map[string]string{
+		"sensevoice/tokens.txt":      "tokens",
+		"sensevoice/model.int8.onnx": "new-model",
+	})
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write(validArchive)
+	}))
+	defer server.Close()
+
+	if err := DownloadProfile(DefaultModelProfile(), []string{server.URL}, root, nil); err != nil {
+		t.Fatal(err)
+	}
+
+	data, err := os.ReadFile(filepath.Join(finalDir, "model.int8.onnx"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(data) != "new-model" {
+		t.Fatalf("model contents = %q, want new-model", string(data))
+	}
+}
+
+func TestDownloadProfileBadArchiveDoesNotWriteInstalledState(t *testing.T) {
+	root := t.TempDir()
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte("not a tar archive"))
+	}))
+	defer server.Close()
+
+	if err := DownloadProfile(DefaultModelProfile(), []string{server.URL}, root, nil); err == nil {
+		t.Fatal("expected bad archive to fail")
+	}
+
+	state, err := LoadInstallStateFromRoot(root)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := state.InstalledModels[DefaultModelID]; ok {
+		t.Fatal("bad archive should not write installed model state")
+	}
+}
+
+func createValidSenseVoice(t *testing.T, dir string) {
+	t.Helper()
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		t.Fatal(err)
+	}
+	writeTestFile(t, filepath.Join(dir, "tokens.txt"), "tokens")
+	writeTestFile(t, filepath.Join(dir, "model.int8.onnx"), "model")
+}
+
+func tarArchive(t *testing.T, files map[string]string) []byte {
+	t.Helper()
+	var buf bytes.Buffer
+	tw := tar.NewWriter(&buf)
+	for name, content := range files {
+		data := []byte(content)
+		header := &tar.Header{
+			Name: name,
+			Mode: 0644,
+			Size: int64(len(data)),
+		}
+		if err := tw.WriteHeader(header); err != nil {
+			t.Fatal(err)
+		}
+		if _, err := tw.Write(data); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := tw.Close(); err != nil {
+		t.Fatal(err)
+	}
+	return buf.Bytes()
+}
+
+func writeTestFile(t *testing.T, path, content string) {
+	t.Helper()
+	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/privatevoice.src/internal/model/profile.go b/privatevoice.src/internal/model/profile.go
new file mode 100644
index 0000000..6a7821b
--- /dev/null
+++ b/privatevoice.src/internal/model/profile.go
@@ -0,0 +1,65 @@
+package model
+
+const (
+	DefaultModelID = "sensevoice-zh"
+)
+
+type ModelProfile struct {
+	ID                   string
+	DisplayName          string
+	BackendKind          string
+	Tier                 string
+	SupportedLanguageIDs []string
+	RecommendedFor       []string
+	Description          string
+	ApproxSize           string
+	DownloadURLs         []string
+	InstallDirName       string
+	LegacyDirNames       []string
+	RequiredFiles        []RequiredFileRule
+	ProviderOrder        []string
+	NumThreads           int
+}
+
+type RequiredFileRule struct {
+	Role     string
+	AllOf    []string
+	AnyOf    []string
+	Required bool
+}
+
+type LanguageProfile struct {
+	ID              string
+	DisplayName     string
+	UILocale        string
+	SystemMatchers  []string
+	DefaultModelID  string
+	UpgradeModelIDs []string
+}
+
+type ModelInstallStatus string
+
+const (
+	ModelNotInstalled              ModelInstallStatus = "not_installed"
+	ModelInstalledValid            ModelInstallStatus = "installed_valid"
+	ModelInstalledIncomplete       ModelInstallStatus = "installed_incomplete"
+	ModelStateInvalidFallbackFound ModelInstallStatus = "state_invalid_fallback_found"
+	ModelStateInvalidNoFallback    ModelInstallStatus = "state_invalid_no_fallback"
+)
+
+type ResolvedModel struct {
+	ModelID       string
+	Profile       ModelProfile
+	RootDir       string
+	SourceDirKind string
+	Files         map[string]string
+	BackendKind   string
+	ProviderOrder []string
+	Status        ModelInstallStatus
+	Missing       []string
+	Problems      []string
+}
+
+func (r ResolvedModel) IsUsable() bool {
+	return r.Status == ModelInstalledValid || r.Status == ModelStateInvalidFallbackFound
+}
diff --git a/privatevoice.src/internal/model/registry.go b/privatevoice.src/internal/model/registry.go
new file mode 100644
index 0000000..9314941
--- /dev/null
+++ b/privatevoice.src/internal/model/registry.go
@@ -0,0 +1,99 @@
+package model
+
+import "fmt"
+
+const (
+	BackendSenseVoice = "sensevoice"
+)
+
+var modelProfiles = map[string]ModelProfile{
+	DefaultModelID: {
+		ID:          DefaultModelID,
+		DisplayName: "SenseVoice",
+		BackendKind: BackendSenseVoice,
+		Tier:        "default",
+		SupportedLanguageIDs: []string{
+			"zh-CN",
+			"zh-Hans",
+			"zh-Hant",
+			"yue",
+			"en",
+			"ja",
+			"ko",
+		},
+		RecommendedFor: []string{"zh-CN"},
+		Description:    "轻量中文模型,适合中文和中英混输。",
+		InstallDirName: "sensevoice-zh",
+		LegacyDirNames: []string{"sensevoice"},
+		RequiredFiles: []RequiredFileRule{
+			{
+				Role:     "model",
+				AnyOf:    []string{"model.int8.onnx", "model.onnx"},
+				Required: true,
+			},
+			{
+				Role:     "tokens",
+				AllOf:    []string{"tokens.txt"},
+				Required: true,
+			},
+		},
+		ProviderOrder: []string{"coreml", "cpu"},
+		NumThreads:    4,
+	},
+}
+
+var languageProfiles = map[string]LanguageProfile{
+	"zh-CN": {
+		ID:             "zh-CN",
+		DisplayName:    "中文",
+		UILocale:       "zh-CN",
+		SystemMatchers: []string{"zh", "zh-CN", "zh-Hans", "zh-Hant"},
+		DefaultModelID: DefaultModelID,
+	},
+}
+
+func GetModelProfile(id string) (ModelProfile, error) {
+	if id == "" {
+		id = DefaultModelID
+	}
+	profile, ok := modelProfiles[id]
+	if !ok {
+		return ModelProfile{}, fmt.Errorf("unknown model profile: %s", id)
+	}
+	return profile, nil
+}
+
+func DefaultModelProfile() ModelProfile {
+	return modelProfiles[DefaultModelID]
+}
+
+func ListModelProfiles() []ModelProfile {
+	profiles := make([]ModelProfile, 0, len(modelProfiles))
+	for _, profile := range modelProfiles {
+		profiles = append(profiles, profile)
+	}
+	return profiles
+}
+
+func NormalizeModelID(id string) string {
+	if _, err := GetModelProfile(id); err != nil {
+		return DefaultModelID
+	}
+	return id
+}
+
+func GetLanguageProfile(id string) (LanguageProfile, error) {
+	profile, ok := languageProfiles[id]
+	if !ok {
+		return LanguageProfile{}, fmt.Errorf("unknown language profile: %s", id)
+	}
+	return profile, nil
+}
+
+func ListLanguageProfiles() []LanguageProfile {
+	profiles := make([]LanguageProfile, 0, len(languageProfiles))
+	for _, profile := range languageProfiles {
+		profiles = append(profiles, profile)
+	}
+	return profiles
+}
diff --git a/privatevoice.src/internal/model/resolver.go b/privatevoice.src/internal/model/resolver.go
new file mode 100644
index 0000000..b5455fd
--- /dev/null
+++ b/privatevoice.src/internal/model/resolver.go
@@ -0,0 +1,136 @@
+package model
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"voicesnap/internal/paths"
+)
+
+type resolveCandidate struct {
+	dir           string
+	sourceDirKind string
+	fromState     bool
+}
+
+func ResolveModel(modelID string) (ResolvedModel, error) {
+	return ResolveModelInRoot(modelID, paths.ModelsRoot())
+}
+
+func ResolveModelInRoot(modelID, modelsRoot string) (ResolvedModel, error) {
+	profile, err := GetModelProfile(modelID)
+	if err != nil {
+		return ResolvedModel{}, err
+	}
+
+	result := ResolvedModel{
+		ModelID:       profile.ID,
+		Profile:       profile,
+		BackendKind:   profile.BackendKind,
+		ProviderOrder: append([]string(nil), profile.ProviderOrder...),
+		Status:        ModelNotInstalled,
+		Files:         make(map[string]string),
+	}
+
+	state, stateErr := LoadInstallStateFromRoot(modelsRoot)
+	if stateErr != nil {
+		result.Problems = append(result.Problems, fmt.Sprintf("state invalid: %v", stateErr))
+	}
+
+	candidates := make([]resolveCandidate, 0, 3)
+	seen := make(map[string]bool)
+	stateCandidateFailed := stateErr != nil
+	addCandidate := func(relOrAbs, sourceDirKind string, fromState bool) {
+		if relOrAbs == "" {
+			return
+		}
+		if fromState && !validStateRelativePath(relOrAbs) {
+			stateCandidateFailed = true
+			result.Problems = append(result.Problems, fmt.Sprintf("state path escapes models root: %s", relOrAbs))
+			return
+		}
+		dir := filepath.Join(modelsRoot, relOrAbs)
+		clean := filepath.Clean(dir)
+		if seen[clean] {
+			return
+		}
+		seen[clean] = true
+		candidates = append(candidates, resolveCandidate{
+			dir:           clean,
+			sourceDirKind: sourceDirKind,
+			fromState:     fromState,
+		})
+	}
+
+	if state != nil {
+		if installed, ok := state.InstalledModels[profile.ID]; ok {
+			addCandidate(installed.Path, installed.SourceDirKind, true)
+		}
+	}
+	addCandidate(profile.InstallDirName, "new", false)
+	for _, legacy := range profile.LegacyDirNames {
+		addCandidate(legacy, "legacy", false)
+	}
+
+	anyDirExists := false
+	var lastValidation ValidationResult
+	var existingInvalidValidation ValidationResult
+
+	for _, candidate := range candidates {
+		if directoryExists(candidate.dir) {
+			anyDirExists = true
+		}
+
+		validation := ValidateModelDir(profile, candidate.dir)
+		if validation.Valid {
+			result.RootDir = candidate.dir
+			result.SourceDirKind = candidate.sourceDirKind
+			result.Files = validation.Files
+			if stateCandidateFailed {
+				result.Status = ModelStateInvalidFallbackFound
+			} else {
+				result.Status = ModelInstalledValid
+			}
+			return result, nil
+		}
+
+		lastValidation = validation
+		if directoryExists(candidate.dir) && !validation.Valid && len(existingInvalidValidation.Missing) == 0 && len(existingInvalidValidation.Problems) == 0 {
+			existingInvalidValidation = validation
+		}
+		if candidate.fromState {
+			stateCandidateFailed = true
+			result.Problems = append(result.Problems, validation.Problems...)
+		}
+	}
+
+	switch {
+	case stateCandidateFailed:
+		result.Status = ModelStateInvalidNoFallback
+	case anyDirExists:
+		result.Status = ModelInstalledIncomplete
+		result.Missing = append(result.Missing, existingInvalidValidation.Missing...)
+		result.Problems = append(result.Problems, existingInvalidValidation.Problems...)
+	default:
+		result.Status = ModelNotInstalled
+		result.Missing = append(result.Missing, lastValidation.Missing...)
+		result.Problems = append(result.Problems, lastValidation.Problems...)
+	}
+
+	return result, nil
+}
+
+func directoryExists(path string) bool {
+	info, err := os.Stat(path)
+	return err == nil && info.IsDir()
+}
+
+func validStateRelativePath(path string) bool {
+	if filepath.IsAbs(path) {
+		return false
+	}
+	clean := filepath.Clean(path)
+	return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(os.PathSeparator))
+}
diff --git a/privatevoice.src/internal/model/validator.go b/privatevoice.src/internal/model/validator.go
new file mode 100644
index 0000000..0870a68
--- /dev/null
+++ b/privatevoice.src/internal/model/validator.go
@@ -0,0 +1,80 @@
+package model
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+type ValidationResult struct {
+	Valid    bool
+	Files    map[string]string
+	Missing  []string
+	Problems []string
+}
+
+func ValidateModelDir(profile ModelProfile, dir string) ValidationResult {
+	result := ValidationResult{
+		Valid: true,
+		Files: make(map[string]string),
+	}
+
+	info, err := os.Stat(dir)
+	if err != nil {
+		result.Valid = false
+		result.Missing = append(result.Missing, dir)
+		return result
+	}
+	if !info.IsDir() {
+		result.Valid = false
+		result.Problems = append(result.Problems, fmt.Sprintf("%s is not a directory", dir))
+		return result
+	}
+
+	for _, rule := range profile.RequiredFiles {
+		if !rule.Required {
+			continue
+		}
+
+		if len(rule.AllOf) > 0 {
+			for _, rel := range rule.AllOf {
+				path := filepath.Join(dir, rel)
+				if !validRequiredFile(path) {
+					result.Valid = false
+					result.Missing = append(result.Missing, rel)
+					continue
+				}
+				if rule.Role != "" && result.Files[rule.Role] == "" {
+					result.Files[rule.Role] = path
+				}
+			}
+		}
+
+		if len(rule.AnyOf) > 0 {
+			var selected string
+			for _, rel := range rule.AnyOf {
+				path := filepath.Join(dir, rel)
+				if validRequiredFile(path) {
+					selected = path
+					break
+				}
+			}
+			if selected == "" {
+				result.Valid = false
+				result.Missing = append(result.Missing, fmt.Sprintf("%s:anyOf(%v)", rule.Role, rule.AnyOf))
+			} else if rule.Role != "" {
+				result.Files[rule.Role] = selected
+			}
+		}
+	}
+
+	return result
+}
+
+func validRequiredFile(path string) bool {
+	info, err := os.Stat(path)
+	if err != nil || info.IsDir() {
+		return false
+	}
+	return info.Size() > 0
+}
diff --git a/privatevoice.src/internal/overlay/overlay.go b/privatevoice.src/internal/overlay/overlay.go
new file mode 100755
index 0000000..d3f7d9f
--- /dev/null
+++ b/privatevoice.src/internal/overlay/overlay.go
@@ -0,0 +1,31 @@
+package overlay
+
+// Status represents the indicator display state.
+type Status string
+
+const (
+	StatusHidden      Status = "hidden"
+	StatusLoading     Status = "loading"
+	StatusReady       Status = "ready"
+	StatusRecording   Status = "recording"
+	StatusFreetalking Status = "freetalking"
+	StatusProcessing  Status = "processing"
+	StatusDone        Status = "done"
+	StatusCancelled   Status = "cancelled"
+	StatusNoVoice     Status = "no_voice"
+	StatusNoContent   Status = "no_content"
+	StatusError       Status = "error"
+)
+
+// Overlay is a native floating indicator window with per-pixel alpha.
+type Overlay interface {
+	Show()
+	Hide()
+	SetStatus(status Status, text string)
+	SetVolume(vol float64)
+	SetPosition(x, y int)
+	GetPosition() (int, int)
+	Size() (int, int)
+	OnDragged(fn func(x, y int))
+	Close()
+}
diff --git a/privatevoice.src/internal/overlay/overlay_darwin.go b/privatevoice.src/internal/overlay/overlay_darwin.go
new file mode 100755
index 0000000..a3aecb7
--- /dev/null
+++ b/privatevoice.src/internal/overlay/overlay_darwin.go
@@ -0,0 +1,1027 @@
+//go:build darwin
+
+package overlay
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices
+#import <Cocoa/Cocoa.h>
+#import <ApplicationServices/ApplicationServices.h>
+#include <dispatch/dispatch.h>
+#include <math.h>
+#include <string.h>
+
+// ---- Visual constants for the borderless macOS light-wave overlay ----
+#define CAP_W  620.0f
+#define CAP_H  112.0f
+#define DRAG_W 280.0f
+#define DRAG_H 76.0f
+#define BAR_W  7.0f
+#define BAR_GAP 13.0f
+#define BAR_R  3.5f
+#define N_BARS 11
+#define TXT_SZ 22.0f
+#define TXT_GAP 52.0f
+
+// ---- Shared state (written by Go, read on main thread) ----
+typedef struct {
+	float barHeights[N_BARS];
+	float barR, barG, barB;
+	char  text[128];
+	int   status;
+	float volumes[N_BARS];
+	float animTime;
+	float fadeProgress;
+	float fadeTarget;
+	int   posX, posY;
+	int   needsPosition;
+	int   hasPosition;
+	int   dragEnded;
+	int   dragX, dragY;
+} OverlayState;
+
+static OverlayState g_state = {
+	.barHeights = {14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14},
+	.barR = 0, .barG = 0.478f, .barB = 1.0f,
+};
+
+static float clamp01(float v) {
+	if (v < 0) return 0;
+	if (v > 1) return 1;
+	return v;
+}
+
+static float mixf(float a, float b, float t) {
+	return a + (b - a) * clamp01(t);
+}
+
+static void spectralColor(float p, float* r, float* g, float* b) {
+	float c1[3] = {0.19f, 0.88f, 1.00f};
+	float c2[3] = {0.10f, 0.46f, 1.00f};
+	float c3[3] = {0.56f, 0.36f, 1.00f};
+	float c4[3] = {1.00f, 0.30f, 0.84f};
+	float c5[3] = {1.00f, 0.62f, 0.74f};
+	float* a = c1;
+	float* z = c2;
+	float t = 0;
+
+	if (p < 0.25f) {
+		a = c1; z = c2; t = p / 0.25f;
+	} else if (p < 0.55f) {
+		a = c2; z = c3; t = (p - 0.25f) / 0.30f;
+	} else if (p < 0.80f) {
+		a = c3; z = c4; t = (p - 0.55f) / 0.25f;
+	} else {
+		a = c4; z = c5; t = (p - 0.80f) / 0.20f;
+	}
+
+	*r = mixf(a[0], z[0], t);
+	*g = mixf(a[1], z[1], t);
+	*b = mixf(a[2], z[2], t);
+}
+
+static void applyStatusTint(float* r, float* g, float* b, float amount) {
+	*r = mixf(*r, g_state.barR, amount);
+	*g = mixf(*g, g_state.barG, amount);
+	*b = mixf(*b, g_state.barB, amount);
+}
+
+static void drawEllipticalGlow(CGContextRef ctx, float cx, float cy, float rx, float ry,
+	float r, float g, float b, float alpha) {
+	CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
+	if (!colorSpace) return;
+	CGFloat locations[] = {0.0f, 0.55f, 1.0f};
+	CGFloat components[] = {
+		r, g, b, alpha,
+		r, g, b, alpha * 0.28f,
+		r, g, b, 0.0f,
+	};
+	CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace, components, locations, 3);
+	if (!gradient) {
+		CGColorSpaceRelease(colorSpace);
+		return;
+	}
+
+	CGContextSaveGState(ctx);
+	CGContextTranslateCTM(ctx, cx, cy);
+	CGContextScaleCTM(ctx, rx, ry);
+	CGContextDrawRadialGradient(ctx, gradient, CGPointMake(0, 0), 0.0f, CGPointMake(0, 0), 1.0f, 0);
+	CGContextRestoreGState(ctx);
+
+	CGGradientRelease(gradient);
+	CGColorSpaceRelease(colorSpace);
+}
+
+static void drawWavePath(CGContextRef ctx, float startX, float endX, float centerY,
+	float amplitude, float phase, float cycles, float r, float g, float b, float alpha, float lineWidth) {
+	CGMutablePathRef path = CGPathCreateMutable();
+	if (!path) return;
+	int steps = 180;
+	for (int i = 0; i <= steps; i++) {
+		float p = (float)i / (float)steps;
+		float x = mixf(startX, endX, p);
+		float edgeFade = sinf(p * (float)M_PI);
+		float y = centerY
+			+ sinf(p * cycles * 2.0f * (float)M_PI + phase) * amplitude * edgeFade
+			+ sinf(p * (cycles * 0.55f) * 2.0f * (float)M_PI + phase * 1.7f) * amplitude * 0.36f * edgeFade;
+		if (i == 0) {
+			CGPathMoveToPoint(path, NULL, x, y);
+		} else {
+			CGPathAddLineToPoint(path, NULL, x, y);
+		}
+	}
+
+	CGColorRef glow = CGColorCreateGenericRGB(r, g, b, alpha * 0.65f);
+	CGContextSaveGState(ctx);
+	CGContextSetLineCap(ctx, kCGLineCapRound);
+	CGContextSetLineJoin(ctx, kCGLineJoinRound);
+	if (glow) {
+		CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 9.0f, glow);
+	}
+	CGContextAddPath(ctx, path);
+	CGContextSetRGBStrokeColor(ctx, r, g, b, alpha);
+	CGContextSetLineWidth(ctx, lineWidth);
+	CGContextStrokePath(ctx);
+	CGContextRestoreGState(ctx);
+	if (glow) {
+		CGColorRelease(glow);
+	}
+	CGPathRelease(path);
+}
+
+static NSWindow* g_window = nil;
+static NSWindow* g_dragWindow = nil;
+
+static float desktopTopY(void) {
+	NSScreen* mainScreen = [NSScreen mainScreen];
+	if (!mainScreen) return 0.0f;
+	return mainScreen.frame.origin.y + mainScreen.frame.size.height;
+}
+
+static NSPoint visualOriginFromTopLeft(float x, float y) {
+	return NSMakePoint(x, desktopTopY() - y - CAP_H);
+}
+
+static NSPoint topLeftFromVisualOrigin(NSPoint visualOrigin) {
+	return NSMakePoint(visualOrigin.x, desktopTopY() - visualOrigin.y - CAP_H);
+}
+
+static NSPoint dragOriginForVisualOrigin(NSPoint visualOrigin) {
+	return NSMakePoint(
+		visualOrigin.x + (CAP_W - DRAG_W) * 0.5f,
+		visualOrigin.y + (CAP_H - DRAG_H) * 0.5f
+	);
+}
+
+static void positionOverlayWindows(NSPoint visualOrigin) {
+	if (g_window) {
+		[g_window setFrameOrigin:visualOrigin];
+	}
+	if (g_dragWindow) {
+		[g_dragWindow setFrameOrigin:dragOriginForVisualOrigin(visualOrigin)];
+	}
+}
+
+static void storeDragPositionFromVisualOrigin(NSPoint visualOrigin) {
+	NSPoint topLeft = topLeftFromVisualOrigin(visualOrigin);
+	g_state.dragX = (int)topLeft.x;
+	g_state.dragY = (int)topLeft.y;
+	g_state.posX = g_state.dragX;
+	g_state.posY = g_state.dragY;
+	g_state.dragEnded = 1;
+}
+
+static BOOL rectLooksUsable(NSRect rect) {
+	return isfinite(rect.origin.x) && isfinite(rect.origin.y) &&
+		isfinite(rect.size.width) && isfinite(rect.size.height) &&
+		rect.size.width > 1.0f && rect.size.height > 1.0f;
+}
+
+static CGFloat rectIntersectionArea(NSRect a, NSRect b) {
+	NSRect intersection = NSIntersectionRect(a, b);
+	if (NSIsEmptyRect(intersection)) return 0.0f;
+	return intersection.size.width * intersection.size.height;
+}
+
+static BOOL rectIntersectsAnyScreen(NSRect rect) {
+	if (!rectLooksUsable(rect)) return NO;
+	for (NSScreen* screen in [NSScreen screens]) {
+		if (rectIntersectionArea(rect, screen.frame) > 0.0f) return YES;
+	}
+	return NO;
+}
+
+static NSRect rectFromTopLeftCGRect(CGRect rect) {
+	return NSMakeRect(
+		rect.origin.x,
+		desktopTopY() - rect.origin.y - rect.size.height,
+		rect.size.width,
+		rect.size.height
+	);
+}
+
+static NSRect rectFromTopLeftOrRawCGRect(CGRect rect) {
+	NSRect converted = rectFromTopLeftCGRect(rect);
+	if (rectIntersectsAnyScreen(converted)) return converted;
+	NSRect raw = NSMakeRect(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height);
+	if (rectIntersectsAnyScreen(raw)) return raw;
+	return converted;
+}
+
+static BOOL axCopyRectFromElement(AXUIElementRef element, NSRect* outRect) {
+	if (!element || !outRect) return NO;
+
+	CFTypeRef positionValue = NULL;
+	CFTypeRef sizeValue = NULL;
+	CGPoint position = CGPointZero;
+	CGSize size = CGSizeZero;
+	BOOL ok = NO;
+
+	if (AXUIElementCopyAttributeValue(element, kAXPositionAttribute, &positionValue) == kAXErrorSuccess &&
+		AXUIElementCopyAttributeValue(element, kAXSizeAttribute, &sizeValue) == kAXErrorSuccess &&
+		positionValue && sizeValue &&
+		CFGetTypeID(positionValue) == AXValueGetTypeID() &&
+		CFGetTypeID(sizeValue) == AXValueGetTypeID() &&
+		AXValueGetValue((AXValueRef)positionValue, kAXValueCGPointType, &position) &&
+		AXValueGetValue((AXValueRef)sizeValue, kAXValueCGSizeType, &size)) {
+		CGRect rect = CGRectMake(position.x, position.y, size.width, size.height);
+		*outRect = rectFromTopLeftOrRawCGRect(rect);
+		ok = rectLooksUsable(*outRect);
+	}
+
+	if (positionValue) CFRelease(positionValue);
+	if (sizeValue) CFRelease(sizeValue);
+	return ok;
+}
+
+static BOOL axCopyBoundsForSelectedText(AXUIElementRef element, NSRect* outRect) {
+	if (!element || !outRect) return NO;
+
+	CFTypeRef selectedRange = NULL;
+	CFTypeRef boundsValue = NULL;
+	CGRect bounds = CGRectZero;
+	BOOL ok = NO;
+
+	if (AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute, &selectedRange) == kAXErrorSuccess &&
+		selectedRange &&
+		AXUIElementCopyParameterizedAttributeValue(element, kAXBoundsForRangeParameterizedAttribute, selectedRange, &boundsValue) == kAXErrorSuccess &&
+		boundsValue &&
+		CFGetTypeID(boundsValue) == AXValueGetTypeID() &&
+		AXValueGetValue((AXValueRef)boundsValue, kAXValueCGRectType, &bounds)) {
+		*outRect = rectFromTopLeftOrRawCGRect(bounds);
+		ok = rectLooksUsable(*outRect);
+	}
+
+	if (selectedRange) CFRelease(selectedRange);
+	if (boundsValue) CFRelease(boundsValue);
+	return ok;
+}
+
+static BOOL copyFocusedInputRect(NSRect* outRect) {
+	if (!outRect) return NO;
+
+	AXUIElementRef systemWide = AXUIElementCreateSystemWide();
+	if (!systemWide) return NO;
+
+	CFTypeRef focusedValue = NULL;
+	BOOL ok = NO;
+	if (AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute, &focusedValue) == kAXErrorSuccess &&
+		focusedValue &&
+		CFGetTypeID(focusedValue) == AXUIElementGetTypeID()) {
+		AXUIElementRef focused = (AXUIElementRef)focusedValue;
+		CFTypeRef windowValue = NULL;
+		if (AXUIElementCopyAttributeValue(focused, kAXWindowAttribute, &windowValue) == kAXErrorSuccess &&
+			windowValue &&
+			CFGetTypeID(windowValue) == AXUIElementGetTypeID()) {
+			ok = axCopyRectFromElement((AXUIElementRef)windowValue, outRect);
+		}
+		if (!ok) ok = axCopyBoundsForSelectedText(focused, outRect);
+		if (!ok) ok = axCopyRectFromElement(focused, outRect);
+		if (windowValue) CFRelease(windowValue);
+	}
+
+	if (focusedValue) CFRelease(focusedValue);
+	CFRelease(systemWide);
+	return ok;
+}
+
+static BOOL copyFrontmostWindowRect(NSRect* outRect) {
+	if (!outRect) return NO;
+	NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication];
+	if (!app) return NO;
+	pid_t pid = [app processIdentifier];
+
+	CFArrayRef windowInfo = CGWindowListCopyWindowInfo(
+		kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
+		kCGNullWindowID
+	);
+	if (!windowInfo) return NO;
+
+	BOOL ok = NO;
+	NSArray* windows = (NSArray*)windowInfo;
+	for (NSDictionary* info in windows) {
+		NSNumber* ownerPID = [info objectForKey:(id)kCGWindowOwnerPID];
+		if (!ownerPID || [ownerPID intValue] != pid) continue;
+
+		NSNumber* layer = [info objectForKey:(id)kCGWindowLayer];
+		if (layer && [layer intValue] != 0) continue;
+
+		NSNumber* alpha = [info objectForKey:(id)kCGWindowAlpha];
+		if (alpha && [alpha floatValue] <= 0.0f) continue;
+
+		CFDictionaryRef boundsDict = (CFDictionaryRef)[info objectForKey:(id)kCGWindowBounds];
+		CGRect bounds = CGRectZero;
+		if (!boundsDict || !CGRectMakeWithDictionaryRepresentation(boundsDict, &bounds)) continue;
+		if (bounds.size.width < 80.0f || bounds.size.height < 40.0f) continue;
+
+		*outRect = rectFromTopLeftOrRawCGRect(bounds);
+		ok = rectLooksUsable(*outRect);
+		if (ok) break;
+	}
+
+	CFRelease(windowInfo);
+	return ok;
+}
+
+static NSScreen* screenForPoint(NSPoint point) {
+	for (NSScreen* candidate in [NSScreen screens]) {
+		if (NSPointInRect(point, candidate.frame)) return candidate;
+	}
+	return nil;
+}
+
+static NSScreen* screenForRect(NSRect rect) {
+	if (!rectLooksUsable(rect)) return nil;
+
+	NSScreen* screen = screenForPoint(NSMakePoint(NSMidX(rect), NSMidY(rect)));
+	if (screen) return screen;
+
+	NSScreen* best = nil;
+	CGFloat bestArea = 0.0f;
+	for (NSScreen* candidate in [NSScreen screens]) {
+		CGFloat area = rectIntersectionArea(rect, candidate.frame);
+		if (!best || area > bestArea) {
+			best = candidate;
+			bestArea = area;
+		}
+	}
+	return best;
+}
+
+static CGFloat clampFloat(CGFloat value, CGFloat minValue, CGFloat maxValue) {
+	if (maxValue < minValue) return minValue;
+	if (value < minValue) return minValue;
+	if (value > maxValue) return maxValue;
+	return value;
+}
+
+static NSPoint overlayOriginForTarget(NSScreen* screen, NSRect targetRect, BOOL hasTarget) {
+	NSRect wa = screen.visibleFrame;
+	CGFloat targetMidX = hasTarget ? NSMidX(targetRect) : NSMidX(wa);
+	CGFloat x = targetMidX - CAP_W * 0.5f;
+	if (CAP_W < wa.size.width) {
+		x = clampFloat(x, wa.origin.x, wa.origin.x + wa.size.width - CAP_W);
+	} else {
+		x = wa.origin.x + (wa.size.width - CAP_W) * 0.5f;
+	}
+
+	CGFloat y = wa.origin.y + 100.0f;
+	if (hasTarget && targetRect.size.height > CAP_H + 80.0f) {
+		CGFloat inset = fmin(120.0f, fmax(32.0f, targetRect.size.height * 0.18f));
+		y = targetRect.origin.y + inset;
+	}
+	if (CAP_H < wa.size.height) {
+		y = clampFloat(y, wa.origin.y, wa.origin.y + wa.size.height - CAP_H);
+	} else {
+		y = wa.origin.y + (wa.size.height - CAP_H) * 0.5f;
+	}
+	return NSMakePoint((int)x, (int)y);
+}
+
+// ---- Custom NSView for CG drawing ----
+@interface PrivateVoiceOverlayView : NSView
+@end
+
+@implementation PrivateVoiceOverlayView
+
+- (void)drawRect:(NSRect)dirtyRect {
+	CGContextRef ctx = [[NSGraphicsContext currentContext] CGContext];
+	float w = self.bounds.size.width;
+	float h = self.bounds.size.height;
+
+	CGContextClearRect(ctx, self.bounds);
+
+	// Measure text
+	float textW = 0;
+	NSString* text = nil;
+	NSDictionary* textAttrs = nil;
+	if (g_state.text[0] != '\0') {
+		text = [NSString stringWithUTF8String:g_state.text];
+		textAttrs = @{
+			NSFontAttributeName: [NSFont systemFontOfSize:TXT_SZ weight:NSFontWeightRegular],
+			NSForegroundColorAttributeName: [NSColor colorWithRed:1 green:1 blue:1 alpha:0.82f]
+		};
+		textW = [text sizeWithAttributes:textAttrs].width;
+	}
+
+	// Borderless light field: no stroke, no hard capsule edge.
+	float midY = h * 0.5f;
+	float t = g_state.animTime;
+	CGContextSetBlendMode(ctx, kCGBlendModePlusLighter);
+	drawEllipticalGlow(ctx, w * 0.34f, midY - 3, w * 0.34f, h * 0.58f, 0.13f, 0.72f, 1.0f, 0.34f);
+	drawEllipticalGlow(ctx, w * 0.52f, midY + 2, w * 0.31f, h * 0.48f, 0.46f, 0.32f, 1.0f, 0.25f);
+	drawEllipticalGlow(ctx, w * 0.70f, midY - 2, w * 0.30f, h * 0.56f, 1.0f, 0.30f, 0.80f, 0.30f);
+	drawEllipticalGlow(ctx, w * 0.50f, midY, w * 0.52f, h * 0.32f, 1.0f, 1.0f, 1.0f, 0.09f);
+
+	float barsW = N_BARS * BAR_W + (N_BARS - 1) * BAR_GAP;
+	float totalW = barsW;
+	if (textW > 0) totalW += TXT_GAP + textW;
+	float startX = (w - totalW) / 2;
+	if (startX < 86) startX = 86;
+	float barsEnd = startX + barsW;
+	float textX = barsEnd + TXT_GAP;
+	float waveStart = 54;
+	float waveEnd = textW > 0 ? textX + textW + 34 : w - 54;
+	if (waveEnd > w - 36) waveEnd = w - 36;
+
+	// Fine horizontal light waves flowing through the transparent field.
+	drawWavePath(ctx, waveStart, waveEnd, midY - 8.0f, 12.0f, t * 1.15f, 2.45f, 0.67f, 0.91f, 1.00f, 0.45f, 1.05f);
+	drawWavePath(ctx, waveStart + 18, waveEnd - 8, midY + 5.0f, 9.0f, -t * 1.00f + 1.9f, 2.05f, 1.00f, 0.80f, 0.98f, 0.38f, 0.95f);
+	drawWavePath(ctx, waveStart + 10, waveEnd - 24, midY + 14.0f, 6.5f, t * 0.82f + 3.1f, 2.75f, 0.92f, 1.00f, 1.00f, 0.24f, 0.75f);
+	drawWavePath(ctx, waveStart + 32, waveEnd - 28, midY - 19.0f, 7.5f, -t * 0.72f + 0.7f, 2.00f, 0.92f, 0.76f, 1.00f, 0.22f, 0.70f);
+
+	// Draw bars
+	static const float profiles[N_BARS] = {0.20f, 0.34f, 0.58f, 0.83f, 1.12f, 1.34f, 1.05f, 0.82f, 0.60f, 0.40f, 0.24f};
+	for (int i = 0; i < N_BARS; i++) {
+		float bx = startX + i * (BAR_W + BAR_GAP);
+		float bh = g_state.barHeights[i] * profiles[i];
+		if (bh < 7) bh = 7;
+		if (bh > 66) bh = 66;
+		float by = (h - bh) / 2;
+
+		CGRect barRect = CGRectMake(bx, by, BAR_W, bh);
+		CGPathRef barPath = CGPathCreateWithRoundedRect(barRect, BAR_R, BAR_R, NULL);
+		if (!barPath) continue;
+		float p = (float)i / (float)(N_BARS - 1);
+		float r, g, b;
+		spectralColor(p, &r, &g, &b);
+		applyStatusTint(&r, &g, &b, g_state.status == 3 || g_state.status == 4 ? 0.06f : 0.16f);
+		CGColorRef glow = CGColorCreateGenericRGB(r, g, b, 0.82f);
+
+		CGContextSaveGState(ctx);
+		if (glow) {
+			CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 14.0f, glow);
+		}
+		CGContextAddPath(ctx, barPath);
+		CGContextSetRGBFillColor(ctx, r, g, b, 0.88f);
+		CGContextFillPath(ctx);
+		CGContextRestoreGState(ctx);
+
+		CGContextAddPath(ctx, barPath);
+		CGContextSetRGBStrokeColor(ctx, 1, 1, 1, 0.52f);
+		CGContextSetLineWidth(ctx, 1.15f);
+		CGContextStrokePath(ctx);
+		if (glow) {
+			CGColorRelease(glow);
+		}
+		CGPathRelease(barPath);
+	}
+
+	// Small luminous beads echo the reference artwork without creating a border.
+	for (int i = 0; i < 7; i++) {
+		float p = (float)i / 6.0f;
+		float cx = mixf(startX - 46, barsEnd + 46, p);
+		float phase = t * 2.1f + p * 8.0f;
+		float radius = 2.0f + (sinf(phase) * 0.5f + 0.5f) * 2.0f;
+		float r, g, b;
+		spectralColor(p, &r, &g, &b);
+		applyStatusTint(&r, &g, &b, g_state.status == 3 || g_state.status == 4 ? 0.06f : 0.16f);
+		CGColorRef glow = CGColorCreateGenericRGB(r, g, b, 0.68f);
+		CGContextSaveGState(ctx);
+		if (glow) {
+			CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 8.0f, glow);
+		}
+		CGContextSetRGBFillColor(ctx, r, g, b, 0.72f);
+		CGContextFillEllipseInRect(ctx, CGRectMake(cx - radius, midY - radius, radius * 2, radius * 2));
+		CGContextRestoreGState(ctx);
+		if (glow) {
+			CGColorRelease(glow);
+		}
+	}
+
+	// Draw text
+	if (text && textAttrs) {
+		NSSize sz = [text sizeWithAttributes:textAttrs];
+		float ty = (h - sz.height) / 2;
+		float tr = 0.63f, tg = 0.88f, tb = 1.0f;
+		applyStatusTint(&tr, &tg, &tb, g_state.status == 3 || g_state.status == 4 ? 0.06f : 0.16f);
+		CGColorRef textGlow = CGColorCreateGenericRGB(tr, tg, tb, 0.58f);
+		CGContextSaveGState(ctx);
+		if (textGlow) {
+			CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 10.0f, textGlow);
+		}
+		[text drawAtPoint:NSMakePoint(textX, ty) withAttributes:textAttrs];
+		CGContextRestoreGState(ctx);
+		if (textGlow) {
+			CGColorRelease(textGlow);
+		}
+	}
+
+	CGContextSetBlendMode(ctx, kCGBlendModeNormal);
+}
+
+@end
+
+@interface PrivateVoiceDragView : NSView {
+	NSPoint _dragStartMouse;
+	NSPoint _dragStartOrigin;
+	BOOL _draggingWindow;
+}
+@end
+
+@implementation PrivateVoiceDragView
+
+- (BOOL)acceptsFirstMouse:(NSEvent*)event {
+	return YES;
+}
+
+- (void)mouseDown:(NSEvent*)event {
+	_dragStartMouse = [NSEvent mouseLocation];
+	_dragStartOrigin = g_window ? g_window.frame.origin : self.window.frame.origin;
+	_draggingWindow = YES;
+}
+
+- (void)mouseDragged:(NSEvent*)event {
+	if (!_draggingWindow || !self.window) return;
+	NSPoint currentMouse = [NSEvent mouseLocation];
+	NSPoint nextOrigin = NSMakePoint(
+		_dragStartOrigin.x + currentMouse.x - _dragStartMouse.x,
+		_dragStartOrigin.y + currentMouse.y - _dragStartMouse.y
+	);
+	positionOverlayWindows(nextOrigin);
+}
+
+- (void)mouseUp:(NSEvent*)event {
+	if (_draggingWindow && g_window) {
+		storeDragPositionFromVisualOrigin(g_window.frame.origin);
+	}
+	_draggingWindow = NO;
+}
+
+@end
+
+// ---- Custom NSPanel for non-activating overlay windows ----
+@interface PrivateVoiceOverlayWindow : NSPanel
+@end
+
+@implementation PrivateVoiceOverlayWindow
+
+- (BOOL)canBecomeKeyWindow { return NO; }
+- (BOOL)canBecomeMainWindow { return NO; }
+
+@end
+
+// ---- Timer target ----
+static PrivateVoiceOverlayView* g_view = nil;
+static NSTimer* g_timer = nil;
+
+static void applyOverlayWindowPolicy(NSWindow* window) {
+	if (!window) return;
+	[window setLevel:CGWindowLevelForKey(kCGStatusWindowLevelKey)];
+	[window setCollectionBehavior:
+		NSWindowCollectionBehaviorCanJoinAllSpaces |
+		NSWindowCollectionBehaviorStationary |
+		NSWindowCollectionBehaviorFullScreenAuxiliary |
+		NSWindowCollectionBehaviorTransient |
+		NSWindowCollectionBehaviorIgnoresCycle];
+	[window setCanHide:NO];
+	if ([window isKindOfClass:[NSPanel class]]) {
+		[(NSPanel*)window setHidesOnDeactivate:NO];
+	}
+}
+
+static void animateBars(void) {
+	g_state.animTime += 0.033f;
+	float t = g_state.animTime;
+
+	switch (g_state.status) {
+	case 1: // loading
+		for (int i = 0; i < N_BARS; i++) {
+			float pulse = sinf(t * 2 + i * 0.3f) * 0.5f + 0.5f;
+			g_state.barHeights[i] = 10 + pulse * 20;
+		}
+		break;
+	case 2: // ready
+	case 6: // done
+		for (int i = 0; i < N_BARS; i++)
+			g_state.barHeights[i] = g_state.barHeights[i] * 0.85f + 14 * 0.15f;
+		break;
+	case 3: // recording
+	case 4: { // freetalking
+		int hasReal = 0;
+		for (int i = 0; i < N_BARS; i++)
+			if (g_state.volumes[i] > 0.01f) { hasReal = 1; break; }
+		for (int i = 0; i < N_BARS; i++) {
+			float target;
+			if (hasReal) {
+				target = 8 + g_state.volumes[i] * 32;
+			} else {
+				float wave = sinf(t * 4 + i * 0.8f) * 0.5f + 0.5f;
+				float rnd = sinf(t * 7 + i * 1.5f) * 0.3f;
+				target = 10 + (wave + rnd) * 25;
+			}
+			g_state.barHeights[i] = g_state.barHeights[i] * 0.6f + target * 0.4f;
+		}
+		break;
+	}
+	case 5: // processing
+		for (int i = 0; i < N_BARS; i++) {
+			float wave = sinf(t * 3 + i * 0.6f) * 0.5f + 0.5f;
+			g_state.barHeights[i] = 12 + wave * 18;
+		}
+		break;
+	default:
+		for (int i = 0; i < N_BARS; i++)
+			g_state.barHeights[i] = g_state.barHeights[i] * 0.85f + 14 * 0.15f;
+		break;
+	}
+}
+
+@interface OverlayTimerTarget : NSObject
+- (void)tick:(NSTimer*)timer;
+@end
+
+@implementation OverlayTimerTarget
+
+- (void)tick:(NSTimer*)timer {
+	if (!g_window) return;
+
+	// Fade
+	if (g_state.fadeProgress < g_state.fadeTarget) {
+		g_state.fadeProgress += 0.16f;
+		if (g_state.fadeProgress > 1.0f) g_state.fadeProgress = 1.0f;
+	} else if (g_state.fadeProgress > g_state.fadeTarget) {
+		g_state.fadeProgress -= 0.22f;
+		if (g_state.fadeProgress < 0) g_state.fadeProgress = 0;
+	}
+
+	float eased = g_state.fadeProgress * (2 - g_state.fadeProgress);
+	[g_window setAlphaValue:eased];
+
+	if (g_state.fadeProgress <= 0 && g_state.fadeTarget == 0) {
+		[g_window orderOut:nil];
+		if (g_dragWindow) [g_dragWindow orderOut:nil];
+		return;
+	}
+
+	if (g_state.fadeTarget > 0) {
+		if (![g_window isVisible]) {
+			applyOverlayWindowPolicy(g_window);
+			[g_window orderFrontRegardless];
+		}
+		if (g_dragWindow && ![g_dragWindow isVisible]) {
+			applyOverlayWindowPolicy(g_dragWindow);
+			[g_dragWindow orderFrontRegardless];
+		}
+	}
+
+	// Reposition
+	if (g_state.needsPosition) {
+		g_state.needsPosition = 0;
+		positionOverlayWindows(visualOriginFromTopLeft(g_state.posX, g_state.posY));
+	}
+
+	animateBars();
+	[g_view setNeedsDisplay:YES];
+}
+
+@end
+
+static OverlayTimerTarget* g_timerTarget = nil;
+
+// ---- C API for Go ----
+
+static void doCreateOverlay(void* ctx) {
+	(void)ctx;
+
+	PrivateVoiceOverlayWindow* window = [[PrivateVoiceOverlayWindow alloc]
+		initWithContentRect:NSMakeRect(0, 0, CAP_W, CAP_H)
+		styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
+		backing:NSBackingStoreBuffered
+		defer:NO];
+
+	[window setOpaque:NO];
+	[window setBackgroundColor:[NSColor clearColor]];
+	applyOverlayWindowPolicy(window);
+	[window setIgnoresMouseEvents:YES];
+	[window setMovableByWindowBackground:NO];
+	[window setHasShadow:NO];
+	[window setAlphaValue:0];
+
+	PrivateVoiceOverlayView* view = [[PrivateVoiceOverlayView alloc]
+		initWithFrame:NSMakeRect(0, 0, CAP_W, CAP_H)];
+	[window setContentView:view];
+
+	g_window = window;
+	g_view = view;
+
+	PrivateVoiceOverlayWindow* dragWindow = [[PrivateVoiceOverlayWindow alloc]
+		initWithContentRect:NSMakeRect(0, 0, DRAG_W, DRAG_H)
+		styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
+		backing:NSBackingStoreBuffered
+		defer:NO];
+
+	[dragWindow setOpaque:NO];
+	[dragWindow setBackgroundColor:[NSColor clearColor]];
+	applyOverlayWindowPolicy(dragWindow);
+	[dragWindow setIgnoresMouseEvents:NO];
+	[dragWindow setMovableByWindowBackground:NO];
+	[dragWindow setHasShadow:NO];
+	[dragWindow setAlphaValue:1.0];
+
+	PrivateVoiceDragView* dragView = [[PrivateVoiceDragView alloc]
+		initWithFrame:NSMakeRect(0, 0, DRAG_W, DRAG_H)];
+	[dragWindow setContentView:dragView];
+	g_dragWindow = dragWindow;
+
+	g_timerTarget = [[OverlayTimerTarget alloc] init];
+	g_timer = [NSTimer timerWithTimeInterval:0.033
+		target:g_timerTarget
+		selector:@selector(tick:)
+		userInfo:nil
+		repeats:YES];
+	[[NSRunLoop currentRunLoop] addTimer:g_timer forMode:NSRunLoopCommonModes];
+}
+
+static void overlayCreate(void) {
+	dispatch_async_f(dispatch_get_main_queue(), NULL, doCreateOverlay);
+}
+
+static void doAutoPosition(void* ctx) {
+	(void)ctx;
+	if (!g_window) return;
+
+	NSRect targetRect = NSZeroRect;
+	BOOL hasTarget = copyFocusedInputRect(&targetRect);
+	if (!hasTarget) hasTarget = copyFrontmostWindowRect(&targetRect);
+
+	NSScreen* screen = hasTarget ? screenForRect(targetRect) : nil;
+	if (!screen) {
+		screen = screenForPoint([NSEvent mouseLocation]);
+	}
+	if (!screen) screen = [NSScreen mainScreen];
+	if (!screen) return;
+
+	NSPoint origin = overlayOriginForTarget(screen, targetRect, hasTarget);
+	positionOverlayWindows(origin);
+	NSPoint topLeft = topLeftFromVisualOrigin(origin);
+	g_state.posX = (int)topLeft.x;
+	g_state.posY = (int)topLeft.y;
+	g_state.hasPosition = 1;
+}
+
+static void doBringOverlayForward(void* ctx) {
+	(void)ctx;
+	if (!g_window) return;
+	applyOverlayWindowPolicy(g_window);
+	[g_window orderFrontRegardless];
+	if (g_dragWindow) {
+		applyOverlayWindowPolicy(g_dragWindow);
+		[g_dragWindow orderFrontRegardless];
+	}
+}
+
+static void overlayShow(void) {
+	g_state.fadeTarget = 1.0f;
+	if (!g_state.hasPosition) {
+		dispatch_async_f(dispatch_get_main_queue(), NULL, doAutoPosition);
+	}
+	dispatch_async_f(dispatch_get_main_queue(), NULL, doBringOverlayForward);
+}
+
+static void overlayAutoPosition(void) {
+	g_state.hasPosition = 0;
+	dispatch_async_f(dispatch_get_main_queue(), NULL, doAutoPosition);
+}
+
+static void doHideDragOverlay(void* ctx) {
+	(void)ctx;
+	if (g_state.fadeTarget == 0.0f && g_dragWindow) {
+		[g_dragWindow orderOut:nil];
+	}
+}
+
+static void overlayHide(void) {
+	g_state.fadeTarget = 0.0f;
+	dispatch_async_f(dispatch_get_main_queue(), NULL, doHideDragOverlay);
+}
+
+static void overlaySetStatus(int status, const char* text, float r, float g, float b) {
+	g_state.status = status;
+	g_state.barR = r;
+	g_state.barG = g;
+	g_state.barB = b;
+	if (text) {
+		strncpy(g_state.text, text, sizeof(g_state.text) - 1);
+		g_state.text[sizeof(g_state.text) - 1] = '\0';
+	} else {
+		g_state.text[0] = '\0';
+	}
+}
+
+static void overlaySetVolume(float vol) {
+	for (int i = 0; i < N_BARS - 1; i++)
+		g_state.volumes[i] = g_state.volumes[i + 1];
+	g_state.volumes[N_BARS - 1] = vol;
+}
+
+static void overlaySetPosition(int x, int y) {
+	g_state.posX = x;
+	g_state.posY = y;
+	g_state.hasPosition = 1;
+	g_state.needsPosition = 1;
+}
+
+static void overlayGetPosition(int* x, int* y) {
+	*x = g_state.posX;
+	*y = g_state.posY;
+}
+
+static int overlayCheckDrag(int* x, int* y) {
+	if (g_state.dragEnded) {
+		g_state.dragEnded = 0;
+		*x = g_state.dragX;
+		*y = g_state.dragY;
+		return 1;
+	}
+	return 0;
+}
+
+static void doCloseOverlay(void* ctx) {
+	(void)ctx;
+	if (g_timer) {
+		[g_timer invalidate];
+		g_timer = nil;
+	}
+	if (g_window) {
+		[g_window close];
+		g_window = nil;
+	}
+	if (g_dragWindow) {
+		[g_dragWindow close];
+		g_dragWindow = nil;
+	}
+	g_view = nil;
+	g_timerTarget = nil;
+}
+
+static void overlayClose(void) {
+	dispatch_async_f(dispatch_get_main_queue(), NULL, doCloseOverlay);
+}
+*/
+import "C"
+
+import (
+	"time"
+	"unsafe"
+)
+
+const (
+	darwinOverlayWidth  = 620
+	darwinOverlayHeight = 112
+)
+
+// darwinOverlay uses a native NSWindow + Core Graphics to render
+// the floating indicator, matching the Windows GDI+ implementation.
+type darwinOverlay struct {
+	dragCb  func(int, int)
+	created bool
+	done    chan struct{}
+}
+
+func New() Overlay {
+	return &darwinOverlay{
+		done: make(chan struct{}),
+	}
+}
+
+func (o *darwinOverlay) ensureCreated() {
+	if !o.created {
+		C.overlayCreate()
+		o.created = true
+		time.Sleep(50 * time.Millisecond)
+		go o.pollDrag()
+	}
+}
+
+func (o *darwinOverlay) Show() {
+	o.ensureCreated()
+	C.overlayShow()
+}
+
+func (o *darwinOverlay) Hide() {
+	C.overlayHide()
+}
+
+func (o *darwinOverlay) SetStatus(status Status, text string) {
+	r, g, b := statusColorRGB(status)
+	cText := C.CString(text)
+	defer C.free(unsafe.Pointer(cText))
+	C.overlaySetStatus(statusToInt(status), cText, C.float(r), C.float(g), C.float(b))
+}
+
+func (o *darwinOverlay) SetVolume(vol float64) {
+	C.overlaySetVolume(C.float(vol))
+}
+
+func (o *darwinOverlay) SetPosition(x, y int) {
+	C.overlaySetPosition(C.int(x), C.int(y))
+}
+
+func (o *darwinOverlay) AutoPosition() {
+	o.ensureCreated()
+	C.overlayAutoPosition()
+}
+
+func (o *darwinOverlay) GetPosition() (int, int) {
+	var x, y C.int
+	C.overlayGetPosition(&x, &y)
+	return int(x), int(y)
+}
+
+func (o *darwinOverlay) Size() (int, int) {
+	return darwinOverlayWidth, darwinOverlayHeight
+}
+
+func (o *darwinOverlay) OnDragged(fn func(int, int)) {
+	o.dragCb = fn
+}
+
+func (o *darwinOverlay) Close() {
+	select {
+	case <-o.done:
+	default:
+		close(o.done)
+	}
+	C.overlayClose()
+}
+
+func (o *darwinOverlay) pollDrag() {
+	ticker := time.NewTicker(100 * time.Millisecond)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-o.done:
+			return
+		case <-ticker.C:
+			var x, y C.int
+			if C.overlayCheckDrag(&x, &y) != 0 {
+				if o.dragCb != nil {
+					o.dragCb(int(x), int(y))
+				}
+			}
+		}
+	}
+}
+
+func statusToInt(s Status) C.int {
+	switch s {
+	case StatusLoading:
+		return 1
+	case StatusReady:
+		return 2
+	case StatusRecording:
+		return 3
+	case StatusFreetalking:
+		return 4
+	case StatusProcessing:
+		return 5
+	case StatusDone:
+		return 6
+	case StatusCancelled:
+		return 7
+	case StatusNoVoice:
+		return 8
+	case StatusNoContent:
+		return 9
+	case StatusError:
+		return 10
+	default:
+		return 0
+	}
+}
+
+func statusColorRGB(s Status) (float32, float32, float32) {
+	switch s {
+	case StatusLoading, StatusFreetalking:
+		return 0, 0.478, 1.0 // #007AFF
+	case StatusReady, StatusDone:
+		return 0.204, 0.780, 0.349 // #34C759
+	case StatusRecording, StatusError:
+		return 1.0, 0.231, 0.188 // #FF3B30
+	case StatusProcessing, StatusCancelled, StatusNoVoice, StatusNoContent:
+		return 1.0, 0.584, 0.0 // #FF9500
+	default:
+		return 0, 0.478, 1.0
+	}
+}
diff --git a/privatevoice.src/internal/overlay/overlay_other.go b/privatevoice.src/internal/overlay/overlay_other.go
new file mode 100755
index 0000000..08d1ed2
--- /dev/null
+++ b/privatevoice.src/internal/overlay/overlay_other.go
@@ -0,0 +1,16 @@
+//go:build !windows && !darwin
+
+package overlay
+
+type stubOverlay struct{}
+
+func New() Overlay                              { return &stubOverlay{} }
+func (o *stubOverlay) Show()                    {}
+func (o *stubOverlay) Hide()                    {}
+func (o *stubOverlay) SetStatus(Status, string) {}
+func (o *stubOverlay) SetVolume(float64)        {}
+func (o *stubOverlay) SetPosition(int, int)     {}
+func (o *stubOverlay) GetPosition() (int, int)  { return 0, 0 }
+func (o *stubOverlay) Size() (int, int)         { return 0, 0 }
+func (o *stubOverlay) OnDragged(func(int, int)) {}
+func (o *stubOverlay) Close()                   {}
diff --git a/VoiceSnapGo/internal/overlay/overlay_windows.go b/privatevoice.src/internal/overlay/overlay_windows.go
similarity index 96%
rename from VoiceSnapGo/internal/overlay/overlay_windows.go
rename to privatevoice.src/internal/overlay/overlay_windows.go
index 251be9e..8bd89f6 100755
--- a/VoiceSnapGo/internal/overlay/overlay_windows.go
+++ b/privatevoice.src/internal/overlay/overlay_windows.go
@@ -96,18 +96,18 @@
 	pt      wPoint
 }
 type wWndClassEx struct {
-	cbSize      uint32
-	style       uint32
-	wndProc     uintptr
-	clsExtra    int32
-	wndExtra    int32
-	hInstance   uintptr
-	hIcon       uintptr
-	hCursor     uintptr
-	hbrBg       uintptr
-	menuName    *uint16
-	className   *uint16
-	hIconSm     uintptr
+	cbSize    uint32
+	style     uint32
+	wndProc   uintptr
+	clsExtra  int32
+	wndExtra  int32
+	hInstance uintptr
+	hIcon     uintptr
+	hCursor   uintptr
+	hbrBg     uintptr
+	menuName  *uint16
+	className *uint16
+	hIconSm   uintptr
 }
 type gdipSI struct {
 	ver      uint32
@@ -300,13 +300,34 @@
 func (o *winOverlay) SetVolume(vol float64)           { trySendF(o.volCh, vol) }
 func (o *winOverlay) SetPosition(x, y int)            { trySendP(o.posCh, [2]int{x, y}) }
 func (o *winOverlay) GetPosition() (int, int)         { return o.posX, o.posY }
+func (o *winOverlay) Size() (int, int)                { return capW, capH }
 func (o *winOverlay) OnDragged(fn func(x, y int))     { o.dragCb = fn }
 func (o *winOverlay) Close()                          { close(o.closeCh); <-o.done }
 
-func trySend(ch chan bool, v bool)          { select { case ch <- v: default: } }
-func trySendS(ch chan statusCmd, v statusCmd) { select { case ch <- v: default: } }
-func trySendF(ch chan float64, v float64)   { select { case ch <- v: default: } }
-func trySendP(ch chan [2]int, v [2]int)     { select { case ch <- v: default: } }
+func trySend(ch chan bool, v bool) {
+	select {
+	case ch <- v:
+	default:
+	}
+}
+func trySendS(ch chan statusCmd, v statusCmd) {
+	select {
+	case ch <- v:
+	default:
+	}
+}
+func trySendF(ch chan float64, v float64) {
+	select {
+	case ch <- v:
+	default:
+	}
+}
+func trySendP(ch chan [2]int, v [2]int) {
+	select {
+	case ch <- v:
+	default:
+	}
+}
 
 // ---- Main render thread ----
 
@@ -336,7 +357,7 @@
 
 	// Register window class
 	hInst, _, _ := kGetModuleHandle.Call(0)
-	cls, _ := syscall.UTF16PtrFromString("VoiceSnapOverlay")
+	cls, _ := syscall.UTF16PtrFromString("PrivateVoiceInputOverlay")
 	wc := wWndClassEx{
 		cbSize:    uint32(unsafe.Sizeof(wWndClassEx{})),
 		wndProc:   syscall.NewCallback(overlayWndProc),
diff --git a/VoiceSnapGo/internal/paths/paths.go b/privatevoice.src/internal/paths/paths.go
similarity index 70%
rename from VoiceSnapGo/internal/paths/paths.go
rename to privatevoice.src/internal/paths/paths.go
index 2b1ce20..5c311dd 100644
--- a/VoiceSnapGo/internal/paths/paths.go
+++ b/privatevoice.src/internal/paths/paths.go
@@ -7,19 +7,25 @@
 	"path/filepath"
 )
 
-const appDirName = "VoiceSnap"
+const appDirName = "PrivateVoice Input"
+
+var legacyAppDirNames = []string{"VoiceSnap"}
 
 // AppSupportDir returns the per-user persistent data directory.
 func AppSupportDir() string {
+	return appSupportDir(appDirName)
+}
+
+func appSupportDir(dirName string) string {
 	base, err := os.UserConfigDir()
 	if err != nil || base == "" {
 		home, homeErr := os.UserHomeDir()
 		if homeErr != nil || home == "" {
-			return appDirName
+			return dirName
 		}
 		base = filepath.Join(home, "Library", "Application Support")
 	}
-	return filepath.Join(base, appDirName)
+	return filepath.Join(base, dirName)
 }
 
 // File returns a path inside the persistent app data directory.
@@ -27,7 +33,7 @@
 	return filepath.Join(AppSupportDir(), name)
 }
 
-// ModelDir returns the persistent SenseVoice model directory.
+// ModelDir returns the legacy persistent SenseVoice model directory.
 func ModelDir() string {
 	return filepath.Join(AppSupportDir(), "models", "sensevoice")
 }
@@ -37,16 +43,36 @@
 	return filepath.Join(AppSupportDir(), "models")
 }
 
+// ModelInstallDir returns the persistent install directory for a model profile.
+func ModelInstallDir(name string) string {
+	return filepath.Join(ModelsRoot(), name)
+}
+
+// ModelStatePath returns the persistent model install state file path.
+func ModelStatePath() string {
+	return filepath.Join(ModelsRoot(), "state.json")
+}
+
 // Ensure creates the app data directory.
 func Ensure() error {
 	return os.MkdirAll(AppSupportDir(), 0755)
 }
 
-// MigrateFromLegacyExecutableDir copies old user data from beside the executable
-// into Application Support. Existing new-location files are never overwritten.
+// MigrateFromLegacyExecutableDir copies old user data into the current app
+// support directory. Existing new-location files are never overwritten.
 func MigrateFromLegacyExecutableDir() error {
 	if err := Ensure(); err != nil {
 		return err
+	}
+
+	for _, dirName := range legacyAppDirNames {
+		legacyDir := appSupportDir(dirName)
+		if samePath(legacyDir, AppSupportDir()) {
+			continue
+		}
+		if err := copyUserDataFromDir(legacyDir); err != nil {
+			return fmt.Errorf("migrate %s: %w", dirName, err)
+		}
 	}
 
 	legacyDir, err := legacyExecutableDir()
@@ -57,13 +83,21 @@
 		return nil
 	}
 
+	if err := copyUserDataFromDir(legacyDir); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func copyUserDataFromDir(sourceDir string) error {
 	for _, name := range []string{"config.json", "userdict.json", "history.json"} {
-		if err := copyFileIfMissing(filepath.Join(legacyDir, name), File(name)); err != nil {
+		if err := copyFileIfMissing(filepath.Join(sourceDir, name), File(name)); err != nil {
 			return fmt.Errorf("migrate %s: %w", name, err)
 		}
 	}
 
-	if err := copyDirIfMissing(filepath.Join(legacyDir, "models"), ModelsRoot()); err != nil {
+	if err := copyDirIfMissing(filepath.Join(sourceDir, "models"), ModelsRoot()); err != nil {
 		return fmt.Errorf("migrate models: %w", err)
 	}
 
diff --git a/VoiceSnapGo/internal/permissions/permissions.go b/privatevoice.src/internal/permissions/permissions.go
similarity index 100%
rename from VoiceSnapGo/internal/permissions/permissions.go
rename to privatevoice.src/internal/permissions/permissions.go
diff --git a/VoiceSnapGo/internal/permissions/permissions_darwin.go b/privatevoice.src/internal/permissions/permissions_darwin.go
similarity index 100%
rename from VoiceSnapGo/internal/permissions/permissions_darwin.go
rename to privatevoice.src/internal/permissions/permissions_darwin.go
diff --git a/VoiceSnapGo/internal/permissions/permissions_other.go b/privatevoice.src/internal/permissions/permissions_other.go
similarity index 100%
rename from VoiceSnapGo/internal/permissions/permissions_other.go
rename to privatevoice.src/internal/permissions/permissions_other.go
diff --git a/VoiceSnapGo/internal/singleinstance/singleinstance.go b/privatevoice.src/internal/singleinstance/singleinstance.go
similarity index 100%
rename from VoiceSnapGo/internal/singleinstance/singleinstance.go
rename to privatevoice.src/internal/singleinstance/singleinstance.go
diff --git a/VoiceSnapGo/internal/singleinstance/singleinstance_unix.go b/privatevoice.src/internal/singleinstance/singleinstance_unix.go
similarity index 90%
rename from VoiceSnapGo/internal/singleinstance/singleinstance_unix.go
rename to privatevoice.src/internal/singleinstance/singleinstance_unix.go
index 03a80e5..4a2e38d 100755
--- a/VoiceSnapGo/internal/singleinstance/singleinstance_unix.go
+++ b/privatevoice.src/internal/singleinstance/singleinstance_unix.go
@@ -15,7 +15,7 @@
 
 // Acquire tries to acquire a file lock. Returns error if another instance holds it.
 func Acquire() (Lock, error) {
-	lockPath := filepath.Join(os.TempDir(), "voicesnap.lock")
+	lockPath := filepath.Join(os.TempDir(), "privatevoice-input.lock")
 	f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
 	if err != nil {
 		return nil, err
diff --git a/VoiceSnapGo/internal/singleinstance/singleinstance_windows.go b/privatevoice.src/internal/singleinstance/singleinstance_windows.go
similarity index 92%
rename from VoiceSnapGo/internal/singleinstance/singleinstance_windows.go
rename to privatevoice.src/internal/singleinstance/singleinstance_windows.go
index bfed987..1e71c37 100755
--- a/VoiceSnapGo/internal/singleinstance/singleinstance_windows.go
+++ b/privatevoice.src/internal/singleinstance/singleinstance_windows.go
@@ -23,7 +23,7 @@
 
 // Acquire tries to acquire a named mutex. Returns error if another instance holds it.
 func Acquire() (Lock, error) {
-	name, err := syscall.UTF16PtrFromString("Global\\VoiceSnapSingleInstance")
+	name, err := syscall.UTF16PtrFromString("Global\\PrivateVoiceInputSingleInstance")
 	if err != nil {
 		return nil, err
 	}
diff --git a/VoiceSnapGo/internal/sound/sound_other.go b/privatevoice.src/internal/sound/sound_other.go
similarity index 100%
rename from VoiceSnapGo/internal/sound/sound_other.go
rename to privatevoice.src/internal/sound/sound_other.go
diff --git a/VoiceSnapGo/internal/sound/sound_windows.go b/privatevoice.src/internal/sound/sound_windows.go
similarity index 100%
rename from VoiceSnapGo/internal/sound/sound_windows.go
rename to privatevoice.src/internal/sound/sound_windows.go
diff --git a/VoiceSnapGo/internal/startup/startup.go b/privatevoice.src/internal/startup/startup.go
similarity index 100%
rename from VoiceSnapGo/internal/startup/startup.go
rename to privatevoice.src/internal/startup/startup.go
diff --git a/privatevoice.src/internal/startup/startup_darwin.go b/privatevoice.src/internal/startup/startup_darwin.go
new file mode 100755
index 0000000..4d152b3
--- /dev/null
+++ b/privatevoice.src/internal/startup/startup_darwin.go
@@ -0,0 +1,95 @@
+//go:build darwin && !appstore
+
+package startup
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"text/template"
+)
+
+const plistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.privatevoice.input</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>{{.ExePath}}</string>
+    </array>
+    <key>RunAtLoad</key>
+    <true/>
+</dict>
+</plist>
+`
+
+const launchAgentID = "com.privatevoice.input"
+
+var legacyLaunchAgentIDs = []string{"com.voicesnap.app"}
+
+func plistPath() string {
+	return plistPathForID(launchAgentID)
+}
+
+func plistPathForID(id string) string {
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, "Library", "LaunchAgents", id+".plist")
+}
+
+func isEnabled() bool {
+	removeLegacyPlists()
+	_, err := os.Stat(plistPath())
+	return err == nil
+}
+
+func setEnabled(enable bool) error {
+	path := plistPath()
+
+	if !enable {
+		if err := removePlist(path); err != nil {
+			return err
+		}
+		removeLegacyPlists()
+		return nil
+	}
+
+	removeLegacyPlists()
+
+	exe, err := os.Executable()
+	if err != nil {
+		return err
+	}
+
+	dir := filepath.Dir(path)
+	os.MkdirAll(dir, 0755)
+
+	f, err := os.Create(path)
+	if err != nil {
+		return fmt.Errorf("failed to create plist: %w", err)
+	}
+	defer f.Close()
+
+	tmpl := template.Must(template.New("plist").Parse(plistTemplate))
+	if err := tmpl.Execute(f, struct{ ExePath string }{exe}); err != nil {
+		return err
+	}
+
+	return exec.Command("launchctl", "load", path).Run()
+}
+
+func removeLegacyPlists() {
+	for _, id := range legacyLaunchAgentIDs {
+		_ = removePlist(plistPathForID(id))
+	}
+}
+
+func removePlist(path string) error {
+	exec.Command("launchctl", "unload", path).Run()
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	return nil
+}
diff --git a/privatevoice.src/internal/startup/startup_darwin_appstore.go b/privatevoice.src/internal/startup/startup_darwin_appstore.go
new file mode 100644
index 0000000..1d9fc30
--- /dev/null
+++ b/privatevoice.src/internal/startup/startup_darwin_appstore.go
@@ -0,0 +1,61 @@
+//go:build darwin && appstore
+
+package startup
+
+/*
+#cgo CFLAGS: -x objective-c
+#cgo LDFLAGS: -framework Foundation -framework ServiceManagement
+#import <Foundation/Foundation.h>
+#import <ServiceManagement/ServiceManagement.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int appStoreLoginItemStatus(void) {
+	if (@available(macOS 13.0, *)) {
+		SMAppServiceStatus status = [SMAppService mainAppService].status;
+		return status == SMAppServiceStatusEnabled || status == SMAppServiceStatusRequiresApproval;
+	}
+	return 0;
+}
+
+static char* appStoreSetLoginItemEnabled(int enabled) {
+	if (@available(macOS 13.0, *)) {
+		NSError* error = nil;
+		BOOL ok = enabled
+			? [[SMAppService mainAppService] registerAndReturnError:&error]
+			: [[SMAppService mainAppService] unregisterAndReturnError:&error];
+		if (ok) return NULL;
+		if (error && error.localizedDescription) {
+			return strdup(error.localizedDescription.UTF8String);
+		}
+		return strdup("SMAppService login item update failed");
+	}
+	return strdup("SMAppService requires macOS 13 or later");
+}
+*/
+import "C"
+
+import (
+	"fmt"
+	"unsafe"
+)
+
+func isEnabled() bool {
+	return C.appStoreLoginItemStatus() != 0
+}
+
+func setEnabled(enable bool) error {
+	errMsg := C.appStoreSetLoginItemEnabled(boolToCInt(enable))
+	if errMsg == nil {
+		return nil
+	}
+	defer C.free(unsafe.Pointer(errMsg))
+	return fmt.Errorf("failed to update login item: %s", C.GoString(errMsg))
+}
+
+func boolToCInt(v bool) C.int {
+	if v {
+		return 1
+	}
+	return 0
+}
diff --git a/privatevoice.src/internal/startup/startup_linux.go b/privatevoice.src/internal/startup/startup_linux.go
new file mode 100755
index 0000000..fcd3fcc
--- /dev/null
+++ b/privatevoice.src/internal/startup/startup_linux.go
@@ -0,0 +1,74 @@
+//go:build linux
+
+package startup
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+const desktopEntry = `[Desktop Entry]
+Type=Application
+Name=PrivateVoice Input
+Exec=%s
+Hidden=false
+X-GNOME-Autostart-enabled=true
+`
+
+func autostartPath() string {
+	return autostartPathForName("privatevoice-input.desktop")
+}
+
+func autostartPathForName(name string) string {
+	configDir, err := os.UserConfigDir()
+	if err != nil {
+		home, _ := os.UserHomeDir()
+		configDir = filepath.Join(home, ".config")
+	}
+	return filepath.Join(configDir, "autostart", name)
+}
+
+func isEnabled() bool {
+	removeLegacyAutostartFiles()
+	_, err := os.Stat(autostartPath())
+	return err == nil
+}
+
+func setEnabled(enable bool) error {
+	path := autostartPath()
+
+	if !enable {
+		if err := removeAutostartFile(path); err != nil {
+			return err
+		}
+		removeLegacyAutostartFiles()
+		return nil
+	}
+
+	removeLegacyAutostartFiles()
+
+	exe, err := os.Executable()
+	if err != nil {
+		return err
+	}
+
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	content := fmt.Sprintf(desktopEntry, exe)
+	return os.WriteFile(path, []byte(content), 0644)
+}
+
+func removeLegacyAutostartFiles() {
+	_ = removeAutostartFile(autostartPathForName("voicesnap.desktop"))
+}
+
+func removeAutostartFile(path string) error {
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	return nil
+}
diff --git a/privatevoice.src/internal/startup/startup_windows.go b/privatevoice.src/internal/startup/startup_windows.go
new file mode 100755
index 0000000..117aaa2
--- /dev/null
+++ b/privatevoice.src/internal/startup/startup_windows.go
@@ -0,0 +1,67 @@
+//go:build windows
+
+package startup
+
+import (
+	"fmt"
+	"os"
+
+	"golang.org/x/sys/windows/registry"
+)
+
+const registryKey = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`
+const appName = "PrivateVoice Input"
+
+var legacyAppNames = []string{"VoiceSnap"}
+
+func isEnabled() bool {
+	removeLegacyValues()
+
+	key, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.QUERY_VALUE)
+	if err != nil {
+		return false
+	}
+	defer key.Close()
+
+	val, _, err := key.GetStringValue(appName)
+	return err == nil && val != ""
+}
+
+func setEnabled(enable bool) error {
+	key, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE)
+	if err != nil {
+		return fmt.Errorf("failed to open registry key: %w", err)
+	}
+	defer key.Close()
+
+	if enable {
+		exe, err := os.Executable()
+		if err != nil {
+			return err
+		}
+		deleteLegacyValues(key)
+		return key.SetStringValue(appName, fmt.Sprintf(`"%s"`, exe))
+	}
+
+	err = key.DeleteValue(appName)
+	deleteLegacyValues(key)
+	if err != nil && err != registry.ErrNotExist {
+		return err
+	}
+	return nil
+}
+
+func deleteLegacyValues(key registry.Key) {
+	for _, name := range legacyAppNames {
+		_ = key.DeleteValue(name)
+	}
+}
+
+func removeLegacyValues() {
+	key, err := registry.OpenKey(registry.CURRENT_USER, registryKey, registry.SET_VALUE)
+	if err != nil {
+		return
+	}
+	defer key.Close()
+	deleteLegacyValues(key)
+}
diff --git a/VoiceSnapGo/internal/textproc/textproc.go b/privatevoice.src/internal/textproc/textproc.go
similarity index 100%
rename from VoiceSnapGo/internal/textproc/textproc.go
rename to privatevoice.src/internal/textproc/textproc.go
diff --git a/VoiceSnapGo/internal/updater/updater.go b/privatevoice.src/internal/updater/updater.go
similarity index 97%
rename from VoiceSnapGo/internal/updater/updater.go
rename to privatevoice.src/internal/updater/updater.go
index b890e8e..7d728d5 100755
--- a/VoiceSnapGo/internal/updater/updater.go
+++ b/privatevoice.src/internal/updater/updater.go
@@ -62,7 +62,7 @@
 		return fmt.Errorf("cannot determine executable path: %w", err)
 	}
 
-	tempExe := filepath.Join(os.TempDir(), "VoiceSnap_new.exe")
+	tempExe := filepath.Join(os.TempDir(), "PrivateVoice_Input_new.exe")
 	oldExe := currentExe + ".old"
 
 	// Download new version
diff --git a/privatevoice.src/internal/userdict/userdict.go b/privatevoice.src/internal/userdict/userdict.go
new file mode 100644
index 0000000..def243b
--- /dev/null
+++ b/privatevoice.src/internal/userdict/userdict.go
@@ -0,0 +1,558 @@
+package userdict
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+	"voicesnap/internal/logger"
+	"voicesnap/internal/paths"
+)
+
+const defaultPageSize = 100
+const maxPageSize = 500
+
+// Entry describes one user dictionary replacement rule.
+type Entry struct {
+	ID        int64  `json:"id"`
+	From      string `json:"from"`
+	To        string `json:"to"`
+	Enabled   bool   `json:"enabled"`
+	CreatedAt int64  `json:"createdAt"`
+	UpdatedAt int64  `json:"updatedAt"`
+}
+
+// Replacement is a new replacement rule to append to the dictionary.
+type Replacement struct {
+	From string
+	To   string
+}
+
+type fileData struct {
+	Replacements []Entry `json:"replacements"`
+}
+
+type storedEntry struct {
+	ID        int64  `json:"id,omitempty"`
+	From      string `json:"from"`
+	To        string `json:"to"`
+	Enabled   *bool  `json:"enabled,omitempty"`
+	CreatedAt int64  `json:"createdAt,omitempty"`
+	UpdatedAt int64  `json:"updatedAt,omitempty"`
+}
+
+type storedFileData struct {
+	Replacements []storedEntry `json:"replacements"`
+}
+
+type exportEntry struct {
+	From string `json:"from"`
+	To   string `json:"to"`
+}
+
+type exportData struct {
+	Replacements []exportEntry `json:"replacements"`
+}
+
+type importEntry struct {
+	ID        int64  `json:"id,omitempty"`
+	From      string `json:"from"`
+	To        string `json:"to"`
+	Enabled   *bool  `json:"enabled,omitempty"`
+	CreatedAt int64  `json:"createdAt,omitempty"`
+	UpdatedAt int64  `json:"updatedAt,omitempty"`
+}
+
+type importData struct {
+	Replacements []importEntry `json:"replacements"`
+}
+
+// PageResult contains one page of dictionary entries plus pagination metadata.
+type PageResult struct {
+	Entries    []Entry `json:"entries"`
+	Total      int     `json:"total"`
+	Page       int     `json:"page"`
+	PageSize   int     `json:"pageSize"`
+	TotalPages int     `json:"totalPages"`
+}
+
+// Store manages user dictionary rules persisted in userdict.json.
+type Store struct {
+	mu      sync.RWMutex
+	entries []Entry
+	path    string
+}
+
+// New creates a user dictionary store and creates userdict.json if needed.
+func New() *Store {
+	s := &Store{
+		path: userDictPath(),
+	}
+	s.load()
+	if err := s.save(); err != nil {
+		logger.Error("Failed to initialize user dictionary: %v", err)
+	}
+	return s
+}
+
+func userDictPath() string {
+	return paths.File("userdict.json")
+}
+
+// Path returns the backing JSON file path.
+func (s *Store) Path() string {
+	return s.path
+}
+
+// GetAll returns all dictionary entries newest first for display.
+func (s *Store) GetAll() []Entry {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	return sortedEntries(s.entries)
+}
+
+// GetPage returns a clamped page of dictionary entries newest first.
+func (s *Store) GetPage(page int, pageSize int) PageResult {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	pageSize = normalizePageSize(pageSize)
+	total := len(s.entries)
+	totalPages := 0
+	if total > 0 {
+		totalPages = (total + pageSize - 1) / pageSize
+	}
+	page = normalizePage(page, totalPages)
+
+	result := PageResult{
+		Entries:    []Entry{},
+		Total:      total,
+		Page:       page,
+		PageSize:   pageSize,
+		TotalPages: totalPages,
+	}
+	if total == 0 {
+		return result
+	}
+
+	sorted := sortedEntries(s.entries)
+	start := (page - 1) * pageSize
+	if start >= total {
+		start = 0
+		result.Page = 1
+	}
+	end := start + pageSize
+	if end > total {
+		end = total
+	}
+	result.Entries = make([]Entry, end-start)
+	copy(result.Entries, sorted[start:end])
+	return result
+}
+
+// Add creates a new enabled replacement rule.
+func (s *Store) Add(from, to string) (Entry, error) {
+	from = strings.TrimSpace(from)
+	to = strings.TrimSpace(to)
+	if from == "" {
+		return Entry{}, fmt.Errorf("source text is required")
+	}
+
+	now := time.Now().UnixMilli()
+	entry := Entry{
+		ID:        now,
+		From:      from,
+		To:        to,
+		Enabled:   true,
+		CreatedAt: now,
+		UpdatedAt: now,
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for s.hasIDLocked(entry.ID) {
+		entry.ID++
+	}
+	entries := append(cloneEntries(s.entries), entry)
+	if err := s.saveEntriesLocked(entries); err != nil {
+		return Entry{}, err
+	}
+	s.entries = entries
+	return entry, nil
+}
+
+// Update modifies an existing replacement rule.
+func (s *Store) Update(id int64, from, to string, enabled bool) (Entry, error) {
+	from = strings.TrimSpace(from)
+	to = strings.TrimSpace(to)
+	if from == "" {
+		return Entry{}, fmt.Errorf("source text is required")
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for i := range s.entries {
+		if s.entries[i].ID == id {
+			entries := cloneEntries(s.entries)
+			entries[i].From = from
+			entries[i].To = to
+			entries[i].Enabled = enabled
+			entries[i].UpdatedAt = time.Now().UnixMilli()
+			if err := s.saveEntriesLocked(entries); err != nil {
+				return Entry{}, err
+			}
+			s.entries = entries
+			return entries[i], nil
+		}
+	}
+	return Entry{}, fmt.Errorf("entry not found")
+}
+
+// AppendNewReplacements appends enabled replacement rules as one all-or-nothing write.
+func (s *Store) AppendNewReplacements(rules []Replacement) ([]Entry, error) {
+	now := time.Now().UnixMilli()
+	prepared := make([]Replacement, 0, len(rules))
+	for i, rule := range rules {
+		from := strings.TrimSpace(rule.From)
+		to := strings.TrimSpace(rule.To)
+		if from == "" {
+			return nil, fmt.Errorf("entry %d source text is required", i+1)
+		}
+		prepared = append(prepared, Replacement{From: from, To: to})
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	existingPairs := map[string]struct{}{}
+	existingFrom := map[string]string{}
+	for _, entry := range s.entries {
+		from := strings.TrimSpace(entry.From)
+		to := strings.TrimSpace(entry.To)
+		if from == "" {
+			continue
+		}
+		existingPairs[pairKey(from, to)] = struct{}{}
+		if _, ok := existingFrom[from]; !ok {
+			existingFrom[from] = to
+		}
+	}
+
+	entries := cloneEntries(s.entries)
+	added := make([]Entry, 0, len(prepared))
+	for i, rule := range prepared {
+		if _, ok := existingPairs[pairKey(rule.From, rule.To)]; ok {
+			return nil, fmt.Errorf("dictionary already contains replacement %q", rule.From)
+		}
+		if to, ok := existingFrom[rule.From]; ok && to != rule.To {
+			return nil, fmt.Errorf("dictionary contains conflicting replacement %q", rule.From)
+		}
+
+		id := now + int64(i)
+		for hasID(entries, id) {
+			id++
+		}
+		entry := Entry{
+			ID:        id,
+			From:      rule.From,
+			To:        rule.To,
+			Enabled:   true,
+			CreatedAt: now,
+			UpdatedAt: now,
+		}
+		entries = append(entries, entry)
+		added = append(added, entry)
+		existingPairs[pairKey(rule.From, rule.To)] = struct{}{}
+		existingFrom[rule.From] = rule.To
+	}
+
+	if err := s.saveEntriesLocked(entries); err != nil {
+		return nil, err
+	}
+	s.entries = entries
+	return added, nil
+}
+
+// Delete removes one entry by id.
+func (s *Store) Delete(id int64) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for i, entry := range s.entries {
+		if entry.ID == id {
+			entries := append(cloneEntries(s.entries[:i]), s.entries[i+1:]...)
+			if err := s.saveEntriesLocked(entries); err != nil {
+				logger.Error("Failed to delete user dictionary entry: %v", err)
+				return
+			}
+			s.entries = entries
+			return
+		}
+	}
+}
+
+// ClearAll removes all entries.
+func (s *Store) ClearAll() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if err := s.saveEntriesLocked(nil); err != nil {
+		logger.Error("Failed to clear user dictionary: %v", err)
+		return
+	}
+	s.entries = nil
+}
+
+// Apply replaces text using all enabled user dictionary rules.
+func (s *Store) Apply(text string) string {
+	if text == "" {
+		return text
+	}
+
+	s.mu.RLock()
+	entries := make([]Entry, len(s.entries))
+	copy(entries, s.entries)
+	s.mu.RUnlock()
+
+	for _, entry := range entries {
+		if !entry.Enabled || entry.From == "" {
+			continue
+		}
+		text = strings.ReplaceAll(text, entry.From, entry.To)
+	}
+	return text
+}
+
+// ExportJSON returns the dictionary file contents as formatted JSON.
+func (s *Store) ExportJSON() string {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	replacements := make([]exportEntry, 0, len(s.entries))
+	for _, entry := range s.entries {
+		replacements = append(replacements, exportEntry{
+			From: entry.From,
+			To:   entry.To,
+		})
+	}
+
+	data, err := json.MarshalIndent(exportData{Replacements: replacements}, "", "  ")
+	if err != nil {
+		logger.Error("Failed to export user dictionary: %v", err)
+		return "{\n  \"replacements\": []\n}"
+	}
+	return string(data)
+}
+
+// ImportJSON replaces the dictionary with entries from JSON.
+func (s *Store) ImportJSON(content string) error {
+	var fd importData
+	if err := json.Unmarshal([]byte(content), &fd); err != nil {
+		return err
+	}
+
+	now := time.Now().UnixMilli()
+	seen := map[int64]bool{}
+	entries := make([]Entry, 0, len(fd.Replacements))
+	for i := range fd.Replacements {
+		imported := fd.Replacements[i]
+		from := strings.TrimSpace(imported.From)
+		to := strings.TrimSpace(imported.To)
+		if from == "" {
+			return fmt.Errorf("entry %d source text is required", i+1)
+		}
+		id := imported.ID
+		if id <= 0 || seen[id] {
+			id = now + int64(i)
+		}
+		seen[id] = true
+
+		enabled := true
+		if imported.Enabled != nil {
+			enabled = *imported.Enabled
+		}
+
+		createdAt := imported.CreatedAt
+		if createdAt <= 0 {
+			createdAt = now
+		}
+
+		entries = append(entries, Entry{
+			ID:        id,
+			From:      from,
+			To:        to,
+			Enabled:   enabled,
+			CreatedAt: createdAt,
+			UpdatedAt: now,
+		})
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if err := s.saveEntriesLocked(entries); err != nil {
+		return err
+	}
+	s.entries = entries
+	return nil
+}
+
+func (s *Store) load() {
+	data, err := os.ReadFile(s.path)
+	if err != nil {
+		return
+	}
+
+	var fd storedFileData
+	if err := json.Unmarshal(data, &fd); err != nil {
+		logger.Error("Failed to parse user dictionary: %v", err)
+		return
+	}
+
+	now := time.Now().UnixMilli()
+	s.entries = make([]Entry, 0, len(fd.Replacements))
+	seen := map[int64]bool{}
+	for i, stored := range fd.Replacements {
+		from := strings.TrimSpace(stored.From)
+		if from == "" {
+			continue
+		}
+		id := stored.ID
+		if id <= 0 || seen[id] {
+			id = now + int64(i)
+		}
+		seen[id] = true
+		enabled := true
+		if stored.Enabled != nil {
+			enabled = *stored.Enabled
+		}
+		createdAt := stored.CreatedAt
+		if createdAt <= 0 {
+			createdAt = now
+		}
+		updatedAt := stored.UpdatedAt
+		if updatedAt <= 0 {
+			updatedAt = createdAt
+		}
+		s.entries = append(s.entries, Entry{
+			ID:        id,
+			From:      from,
+			To:        strings.TrimSpace(stored.To),
+			Enabled:   enabled,
+			CreatedAt: createdAt,
+			UpdatedAt: updatedAt,
+		})
+	}
+}
+
+func (s *Store) save() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.saveEntriesLocked(s.entries)
+}
+
+func (s *Store) saveEntriesLocked(entries []Entry) error {
+	if err := paths.Ensure(); err != nil {
+		logger.Error("Failed to create app data dir: %v", err)
+		return err
+	}
+
+	data, err := json.MarshalIndent(fileData{Replacements: entries}, "", "  ")
+	if err != nil {
+		logger.Error("Failed to marshal user dictionary: %v", err)
+		return err
+	}
+
+	dir := filepath.Dir(s.path)
+	temp, err := os.CreateTemp(dir, ".userdict-*.tmp")
+	if err != nil {
+		logger.Error("Failed to create user dictionary temp file: %v", err)
+		return err
+	}
+	tempPath := temp.Name()
+	defer os.Remove(tempPath)
+
+	if _, err := temp.Write(data); err != nil {
+		temp.Close()
+		logger.Error("Failed to write user dictionary temp file: %v", err)
+		return err
+	}
+	if err := temp.Chmod(0600); err != nil {
+		temp.Close()
+		logger.Error("Failed to set user dictionary permissions: %v", err)
+		return err
+	}
+	if err := temp.Close(); err != nil {
+		logger.Error("Failed to close user dictionary temp file: %v", err)
+		return err
+	}
+	if err := os.Rename(tempPath, s.path); err != nil {
+		logger.Error("Failed to save user dictionary: %v", err)
+		return err
+	}
+	return nil
+}
+
+func (s *Store) hasIDLocked(id int64) bool {
+	return hasID(s.entries, id)
+}
+
+func cloneEntries(entries []Entry) []Entry {
+	cloned := make([]Entry, len(entries))
+	copy(cloned, entries)
+	return cloned
+}
+
+func sortedEntries(entries []Entry) []Entry {
+	result := cloneEntries(entries)
+	sort.SliceStable(result, func(i, j int) bool {
+		if result[i].CreatedAt == result[j].CreatedAt {
+			return result[i].ID > result[j].ID
+		}
+		return result[i].CreatedAt > result[j].CreatedAt
+	})
+	return result
+}
+
+func normalizePageSize(pageSize int) int {
+	if pageSize <= 0 {
+		return defaultPageSize
+	}
+	if pageSize > maxPageSize {
+		return maxPageSize
+	}
+	return pageSize
+}
+
+func normalizePage(page int, totalPages int) int {
+	if totalPages <= 0 {
+		return 1
+	}
+	if page < 1 {
+		return 1
+	}
+	if page > totalPages {
+		return totalPages
+	}
+	return page
+}
+
+func hasID(entries []Entry, id int64) bool {
+	for _, entry := range entries {
+		if entry.ID == id {
+			return true
+		}
+	}
+	return false
+}
+
+func pairKey(from, to string) string {
+	return from + "\x00" + to
+}
diff --git a/privatevoice.src/internal/userdict/userdict_test.go b/privatevoice.src/internal/userdict/userdict_test.go
new file mode 100644
index 0000000..d6c08e3
--- /dev/null
+++ b/privatevoice.src/internal/userdict/userdict_test.go
@@ -0,0 +1,160 @@
+package userdict
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestAppendNewReplacementsAppendsEnabledRulesAndPersists0600(t *testing.T) {
+	store := testStore(t)
+
+	added, err := store.AppendNewReplacements([]Replacement{
+		{From: " 点 CSV ", To: " .csv "},
+		{From: "右奥特", To: "右 Alt"},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(added) != 2 {
+		t.Fatalf("expected 2 added entries, got %d", len(added))
+	}
+	if got := store.Apply("点 CSV 和 右奥特"); got != ".csv 和 右 Alt" {
+		t.Fatalf("unexpected applied text: %q", got)
+	}
+
+	info, err := os.Stat(store.path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := info.Mode().Perm(); got != 0600 {
+		t.Fatalf("expected userdict.json mode 0600, got %o", got)
+	}
+}
+
+func TestAppendNewReplacementsRejectsDuplicateAndConflictWithoutMutation(t *testing.T) {
+	store := testStore(t)
+	if _, err := store.AppendNewReplacements([]Replacement{{From: "麦克", To: "Mac"}}); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := store.AppendNewReplacements([]Replacement{{From: "麦克", To: "Mac"}}); err == nil {
+		t.Fatal("expected duplicate to fail")
+	}
+	if _, err := store.AppendNewReplacements([]Replacement{{From: "麦克", To: "Mike"}}); err == nil {
+		t.Fatal("expected conflict to fail")
+	}
+
+	entries := store.GetAll()
+	if len(entries) != 1 {
+		t.Fatalf("expected dictionary to remain unchanged, got %d entries", len(entries))
+	}
+	if entries[0].From != "麦克" || entries[0].To != "Mac" {
+		t.Fatalf("unexpected entry after failed imports: %+v", entries[0])
+	}
+}
+
+func TestAppendNewReplacementsWriteFailureDoesNotMutateMemory(t *testing.T) {
+	store := &Store{
+		path: filepath.Join(t.TempDir(), "missing", "userdict.json"),
+		entries: []Entry{
+			{ID: 1, From: "麦克", To: "Mac", Enabled: true, CreatedAt: 1, UpdatedAt: 1},
+		},
+	}
+
+	if _, err := store.AppendNewReplacements([]Replacement{{From: "点 CSV", To: ".csv"}}); err == nil {
+		t.Fatal("expected write failure")
+	}
+
+	entries := store.GetAll()
+	if len(entries) != 1 {
+		t.Fatalf("expected memory to remain unchanged, got %d entries", len(entries))
+	}
+	if got := store.Apply("麦克 点 CSV"); got != "Mac 点 CSV" {
+		t.Fatalf("unexpected applied text after failed write: %q", got)
+	}
+}
+
+func TestLoadOldFromToOnlyFormatDefaultsEnabled(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "userdict.json")
+	data, err := json.Marshal(map[string]any{
+		"replacements": []map[string]string{
+			{"from": "点 CSV", "to": ".csv"},
+		},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		t.Fatal(err)
+	}
+
+	store := &Store{path: path}
+	store.load()
+
+	entries := store.GetAll()
+	if len(entries) != 1 {
+		t.Fatalf("expected one loaded entry, got %d", len(entries))
+	}
+	if !entries[0].Enabled {
+		t.Fatal("expected old format entry to default enabled")
+	}
+	if got := store.Apply("点 CSV"); got != ".csv" {
+		t.Fatalf("unexpected applied text: %q", got)
+	}
+}
+
+func TestGetPageClampsAndSortsNewestFirst(t *testing.T) {
+	store := testStore(t)
+	for i := 0; i < 12; i++ {
+		store.entries = append(store.entries, Entry{
+			ID:        int64(i + 1),
+			From:      fmt.Sprintf("from %02d", i),
+			To:        fmt.Sprintf("to %02d", i),
+			Enabled:   true,
+			CreatedAt: int64(1000 + i),
+			UpdatedAt: int64(1000 + i),
+		})
+	}
+
+	page := store.GetPage(2, 5)
+	if page.Total != 12 || page.TotalPages != 3 || page.Page != 2 || page.PageSize != 5 {
+		t.Fatalf("unexpected page metadata: %+v", page)
+	}
+	if len(page.Entries) != 5 || page.Entries[0].From != "from 06" || page.Entries[4].From != "from 02" {
+		t.Fatalf("unexpected page entries: %+v", page.Entries)
+	}
+
+	last := store.GetPage(99, 5)
+	if last.Page != 3 || len(last.Entries) != 2 || last.Entries[0].From != "from 01" {
+		t.Fatalf("expected clamped last page, got %+v", last)
+	}
+
+	defaultSize := store.GetPage(1, 0)
+	if defaultSize.PageSize != defaultPageSize {
+		t.Fatalf("expected default page size %d, got %d", defaultPageSize, defaultSize.PageSize)
+	}
+
+	limitedSize := store.GetPage(1, maxPageSize+1)
+	if limitedSize.PageSize != maxPageSize {
+		t.Fatalf("expected max page size %d, got %d", maxPageSize, limitedSize.PageSize)
+	}
+}
+
+func TestGetPageHandlesEmptyDictionary(t *testing.T) {
+	store := testStore(t)
+
+	page := store.GetPage(99, 100)
+	if page.Total != 0 || page.TotalPages != 0 || page.Page != 1 || len(page.Entries) != 0 {
+		t.Fatalf("unexpected empty page: %+v", page)
+	}
+}
+
+func testStore(t *testing.T) *Store {
+	t.Helper()
+	return &Store{
+		path: filepath.Join(t.TempDir(), "userdict.json"),
+	}
+}
diff --git a/VoiceSnapGo/locale_other.go b/privatevoice.src/locale_other.go
similarity index 100%
rename from VoiceSnapGo/locale_other.go
rename to privatevoice.src/locale_other.go
diff --git a/VoiceSnapGo/locale_windows.go b/privatevoice.src/locale_windows.go
similarity index 100%
rename from VoiceSnapGo/locale_windows.go
rename to privatevoice.src/locale_windows.go
diff --git a/VoiceSnapGo/main.go b/privatevoice.src/main.go
similarity index 71%
rename from VoiceSnapGo/main.go
rename to privatevoice.src/main.go
index 2d5a7ca..c862759 100755
--- a/VoiceSnapGo/main.go
+++ b/privatevoice.src/main.go
@@ -9,13 +9,13 @@
 
 func main() {
 	if err := paths.MigrateFromLegacyExecutableDir(); err != nil {
-		os.Stderr.WriteString("VoiceSnap data migration failed: " + err.Error() + "\n")
+		os.Stderr.WriteString("PrivateVoice Input data migration failed: " + err.Error() + "\n")
 	}
 
 	// Initialize logger
 	logger.Init()
 	exePath, _ := os.Executable()
-	logger.Info("VoiceSnap starting... version=%s build=%s executable=%s dataDir=%s", appVersion, appBuild, exePath, paths.AppSupportDir())
+	logger.Info("PrivateVoice Input starting... version=%s build=%s executable=%s dataDir=%s", appVersion, appBuild, exePath, paths.AppSupportDir())
 
 	// Single instance check
 	lock, err := singleinstance.Acquire()
diff --git a/privatevoice.src/scripts/build-appstore-developerid-macos.sh b/privatevoice.src/scripts/build-appstore-developerid-macos.sh
new file mode 100755
index 0000000..3f92273
--- /dev/null
+++ b/privatevoice.src/scripts/build-appstore-developerid-macos.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env bash
+#
+# build-appstore-developerid-macos.sh - Build a distributable App Sandbox POC DMG.
+#
+# This builds the appstore-tagged sandbox variant, signs it with Developer ID,
+# submits the DMG for notarization, and staples the notarization ticket. Use this
+# for cross-machine Sandbox POC testing before moving to App Store Connect.
+#
+# Prerequisites:
+#   1. Install the Developer ID Application certificate in Keychain.
+#   2. Store notarization credentials, for example:
+#      xcrun notarytool store-credentials "privatevoice" \
+#        --apple-id "your@apple.id" --team-id "3HY4G7M2AL" \
+#        --password "app-specific-password"
+#
+# Usage:
+#   cd VoiceSnapGo
+#   ./scripts/build-appstore-developerid-macos.sh [arm64|x86_64|all]
+#
+set -euo pipefail
+
+APP_NAME="PrivateVoice Input"
+DEVELOPER_IDENTITY="${PRIVATEVOICE_DEVELOPER_IDENTITY:-Developer ID Application: Ningbo Zhuozhi Innovation Network Technology Co., Ltd. (3HY4G7M2AL)}"
+KEYCHAIN_PROFILE="${PRIVATEVOICE_NOTARY_PROFILE:-privatevoice}"
+SIGNING_KEYCHAIN="${PRIVATEVOICE_SIGNING_KEYCHAIN:-}"
+APPSTORE_MIN_SYSTEM_VERSION="${PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION:-13.4}"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist"
+ENTITLEMENTS="$PROJECT_DIR/build/darwin/entitlements-appstore.plist"
+GOMODCACHE="$(go env GOMODCACHE)"
+
+VERSION="$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "2.1.12")"
+BUILD_ID="$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)"
+
+step() {
+  echo ""
+  echo "==> $1"
+}
+
+fail() {
+  echo "ERROR: $1" >&2
+  exit 1
+}
+
+security_find_identities() {
+  if [[ -n "$SIGNING_KEYCHAIN" ]]; then
+    security find-identity -v -p codesigning "$SIGNING_KEYCHAIN"
+  else
+    security find-identity -v -p codesigning
+  fi
+}
+
+resolve_arch() {
+  local target="$1"
+  case "$target" in
+    arm64)
+      GOARCH="arm64"
+      SHERPA_ARCH="aarch64-apple-darwin"
+      OUT_ARCH="arm64"
+      ;;
+    x86_64|amd64)
+      GOARCH="amd64"
+      SHERPA_ARCH="x86_64-apple-darwin"
+      OUT_ARCH="x86_64"
+      ;;
+    *)
+      fail "Unknown architecture: $target (use arm64, x86_64, or all)"
+      ;;
+  esac
+
+  BUILD_DIR="$PROJECT_DIR/build/appstore-developerid/$OUT_ARCH"
+  APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
+  DMG_PATH="$BUILD_DIR/$APP_NAME-$VERSION-build$BUILD_ID-$OUT_ARCH-developerid-sandbox-poc.dmg"
+  SHERPA_LIB_DIR="$GOMODCACHE/github.com/k2-fsa/sherpa-onnx-go-macos@v1.12.24/lib/$SHERPA_ARCH"
+}
+
+sign_plain() {
+  local item="$1"
+  codesign --force \
+    --options runtime \
+    --timestamp \
+    --sign "$DEVELOPER_IDENTITY" \
+    "$item"
+}
+
+sign_sandboxed() {
+  local item="$1"
+  codesign --force \
+    --options runtime \
+    --timestamp \
+    --entitlements "$ENTITLEMENTS" \
+    --sign "$DEVELOPER_IDENTITY" \
+    "$item"
+}
+
+build_one_arch() {
+  local target="$1"
+  resolve_arch "$target"
+
+  step "Preflight checks ($OUT_ARCH)"
+  [[ -f "$INFO_PLIST" ]] || fail "Info.plist not found at $INFO_PLIST"
+  [[ -f "$ENTITLEMENTS" ]] || fail "App Store entitlements not found at $ENTITLEMENTS"
+  [[ -f "$PROJECT_DIR/build/darwin/icon.icns" ]] || fail "icon.icns not found"
+  [[ -d "$SHERPA_LIB_DIR" ]] || fail "sherpa-onnx dylibs not found at $SHERPA_LIB_DIR"
+  command -v xcrun >/dev/null || fail "xcrun not found"
+  security_find_identities | grep -Fq "$DEVELOPER_IDENTITY" ||
+    fail "Developer ID signing identity not found: $DEVELOPER_IDENTITY"
+
+  if [[ "${PRIVATEVOICE_SKIP_NOTARY_PROFILE_CHECK:-0}" != "1" ]]; then
+    xcrun notarytool history --keychain-profile "$KEYCHAIN_PROFILE" >/dev/null 2>&1 ||
+      fail "Notary keychain profile is not usable: $KEYCHAIN_PROFILE"
+  fi
+
+  step "Cleaning old Developer ID sandbox build"
+  rm -rf "$APP_BUNDLE" "$DMG_PATH"
+  mkdir -p "$BUILD_DIR"
+
+  step "Building frontend"
+  cd "$PROJECT_DIR/frontend"
+  if [[ ! -d node_modules ]]; then
+    npm ci --prefer-offline
+  fi
+  npm run build
+
+  step "Building Go binary ($OUT_ARCH, appstore tag)"
+  cd "$PROJECT_DIR"
+  CGO_ENABLED=1 GOOS=darwin GOARCH="$GOARCH" \
+    MACOSX_DEPLOYMENT_TARGET="$APPSTORE_MIN_SYSTEM_VERSION" \
+    CGO_CFLAGS="${CGO_CFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
+    CGO_LDFLAGS="${CGO_LDFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
+    go build -tags appstore -buildvcs=false -gcflags=all="-l" -ldflags="-s -w" \
+    -o "$BUILD_DIR/$APP_NAME"
+
+  step "Assembling $APP_NAME.app"
+  mkdir -p "$APP_BUNDLE/Contents/MacOS"
+  mkdir -p "$APP_BUNDLE/Contents/Frameworks"
+  mkdir -p "$APP_BUNDLE/Contents/Resources"
+
+  cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
+  /usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $APPSTORE_MIN_SYSTEM_VERSION" \
+    "$APP_BUNDLE/Contents/Info.plist"
+  cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
+  cp "$PROJECT_DIR/build/darwin/icon.icns" "$APP_BUNDLE/Contents/Resources/icon.icns"
+  cp "$SHERPA_LIB_DIR/libsherpa-onnx-c-api.dylib" "$APP_BUNDLE/Contents/Frameworks/"
+  cp "$SHERPA_LIB_DIR/libonnxruntime.1.23.2.dylib" "$APP_BUNDLE/Contents/Frameworks/"
+
+  step "Fixing rpaths"
+  install_name_tool -add_rpath @executable_path/../Frameworks \
+    "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true
+  install_name_tool -id @rpath/libsherpa-onnx-c-api.dylib \
+    "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
+  install_name_tool -id @rpath/libonnxruntime.1.23.2.dylib \
+    "$APP_BUNDLE/Contents/Frameworks/libonnxruntime.1.23.2.dylib"
+
+  step "Code signing with Developer ID"
+  sign_plain "$APP_BUNDLE/Contents/Frameworks/libonnxruntime.1.23.2.dylib"
+  sign_plain "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
+  sign_sandboxed "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
+  sign_sandboxed "$APP_BUNDLE"
+
+  step "Verifying signature and entitlements"
+  codesign -vvv --deep --strict "$APP_BUNDLE"
+  codesign -d --entitlements :- "$APP_BUNDLE"
+
+  step "Creating DMG"
+  local dmg_temp="$BUILD_DIR/dmg-staging"
+  rm -rf "$dmg_temp"
+  mkdir -p "$dmg_temp"
+  cp -R "$APP_BUNDLE" "$dmg_temp/"
+  ln -s /Applications "$dmg_temp/Applications"
+  hdiutil create -volname "$APP_NAME Sandbox POC" \
+    -srcfolder "$dmg_temp" \
+    -ov -format UDZO \
+    "$DMG_PATH"
+  rm -rf "$dmg_temp"
+
+  step "Signing DMG"
+  codesign --force --timestamp --sign "$DEVELOPER_IDENTITY" "$DMG_PATH"
+
+  step "Submitting DMG for notarization"
+  xcrun notarytool submit "$DMG_PATH" \
+    --keychain-profile "$KEYCHAIN_PROFILE" \
+    --wait
+
+  step "Stapling notarization ticket"
+  xcrun stapler staple "$DMG_PATH"
+  xcrun stapler validate "$DMG_PATH"
+  spctl -a -vvv -t open "$DMG_PATH"
+
+  step "Developer ID sandbox POC build complete"
+  echo "  App: $APP_BUNDLE"
+  echo "  DMG: $DMG_PATH"
+  echo "  Signing identity: $DEVELOPER_IDENTITY"
+  echo "  Notary profile: $KEYCHAIN_PROFILE"
+}
+
+TARGET_ARCH="${1:-$(uname -m)}"
+if [[ "$TARGET_ARCH" == "all" ]]; then
+  build_one_arch arm64
+  build_one_arch x86_64
+else
+  build_one_arch "$TARGET_ARCH"
+fi
diff --git a/privatevoice.src/scripts/build-appstore-macos.sh b/privatevoice.src/scripts/build-appstore-macos.sh
new file mode 100755
index 0000000..25d0476
--- /dev/null
+++ b/privatevoice.src/scripts/build-appstore-macos.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+#
+# build-appstore-macos.sh - Build a locally signed App Sandbox POC bundle.
+#
+# This is not the final Mac App Store upload pipeline. It intentionally uses a
+# local signing identity so we can verify sandbox behavior before wiring Apple
+# Distribution certificates and provisioning profiles.
+#
+# Usage:
+#   cd VoiceSnapGo
+#   ./scripts/build-appstore-macos.sh [arm64|x86_64]
+#
+set -euo pipefail
+
+APP_NAME="PrivateVoice Input"
+LOCAL_IDENTITY="${PRIVATEVOICE_LOCAL_SIGN_IDENTITY:-PrivateVoice Local Code Signing}"
+KEYCHAIN="${PRIVATEVOICE_LOCAL_KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}"
+APPSTORE_MIN_SYSTEM_VERSION="${PRIVATEVOICE_APPSTORE_MIN_SYSTEM_VERSION:-13.4}"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+INFO_PLIST="$PROJECT_DIR/build/darwin/Info.plist"
+ENTITLEMENTS="$PROJECT_DIR/build/darwin/entitlements-appstore.plist"
+GOMODCACHE="$(go env GOMODCACHE)"
+
+VERSION="$(defaults read "$INFO_PLIST" CFBundleShortVersionString 2>/dev/null || echo "2.1.12")"
+BUILD_ID="$(defaults read "$INFO_PLIST" CFBundleVersion 2>/dev/null || date +%Y%m%d.%H%M)"
+
+TARGET_ARCH="${1:-$(uname -m)}"
+
+step() {
+  echo ""
+  echo "==> $1"
+}
+
+fail() {
+  echo "ERROR: $1" >&2
+  exit 1
+}
+
+case "$TARGET_ARCH" in
+  arm64)
+    GOARCH="arm64"
+    SHERPA_ARCH="aarch64-apple-darwin"
+    OUT_ARCH="arm64"
+    ;;
+  x86_64|amd64)
+    GOARCH="amd64"
+    SHERPA_ARCH="x86_64-apple-darwin"
+    OUT_ARCH="x86_64"
+    ;;
+  *)
+    fail "Unknown architecture: $TARGET_ARCH (use arm64 or x86_64)"
+    ;;
+esac
+
+BUILD_DIR="$PROJECT_DIR/build/appstore-local/$OUT_ARCH"
+APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
+DMG_PATH="$BUILD_DIR/$APP_NAME-$VERSION-build$BUILD_ID-$OUT_ARCH-appstore-sandbox-poc.dmg"
+SHERPA_LIB_DIR="$GOMODCACHE/github.com/k2-fsa/sherpa-onnx-go-macos@v1.12.24/lib/$SHERPA_ARCH"
+
+sign_plain() {
+  local item="$1"
+  codesign --force \
+    --options runtime \
+    --timestamp=none \
+    --sign "$LOCAL_IDENTITY" \
+    "$item"
+}
+
+sign_sandboxed() {
+  local item="$1"
+  codesign --force \
+    --options runtime \
+    --timestamp=none \
+    --entitlements "$ENTITLEMENTS" \
+    --sign "$LOCAL_IDENTITY" \
+    "$item"
+}
+
+step "Preflight checks"
+[[ -f "$INFO_PLIST" ]] || fail "Info.plist not found at $INFO_PLIST"
+[[ -f "$ENTITLEMENTS" ]] || fail "App Store entitlements not found at $ENTITLEMENTS"
+[[ -f "$PROJECT_DIR/build/darwin/icon.icns" ]] || fail "icon.icns not found"
+[[ -d "$SHERPA_LIB_DIR" ]] || fail "sherpa-onnx dylibs not found at $SHERPA_LIB_DIR"
+security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LOCAL_IDENTITY" ||
+  fail "Local signing identity not found: $LOCAL_IDENTITY"
+
+step "Cleaning old App Sandbox POC build"
+rm -rf "$APP_BUNDLE" "$DMG_PATH"
+mkdir -p "$BUILD_DIR"
+
+step "Building frontend"
+cd "$PROJECT_DIR/frontend"
+if [[ ! -d node_modules ]]; then
+  npm ci --prefer-offline
+fi
+npm run build
+
+step "Building Go binary ($OUT_ARCH, appstore tag)"
+cd "$PROJECT_DIR"
+CGO_ENABLED=1 GOOS=darwin GOARCH="$GOARCH" \
+  MACOSX_DEPLOYMENT_TARGET="$APPSTORE_MIN_SYSTEM_VERSION" \
+  CGO_CFLAGS="${CGO_CFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
+  CGO_LDFLAGS="${CGO_LDFLAGS:-} -mmacosx-version-min=$APPSTORE_MIN_SYSTEM_VERSION" \
+  go build -tags appstore -buildvcs=false -gcflags=all="-l" -ldflags="-s -w" \
+  -o "$BUILD_DIR/$APP_NAME"
+
+step "Assembling $APP_NAME.app"
+mkdir -p "$APP_BUNDLE/Contents/MacOS"
+mkdir -p "$APP_BUNDLE/Contents/Frameworks"
+mkdir -p "$APP_BUNDLE/Contents/Resources"
+
+cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
+/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $APPSTORE_MIN_SYSTEM_VERSION" \
+  "$APP_BUNDLE/Contents/Info.plist"
+cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
+cp "$PROJECT_DIR/build/darwin/icon.icns" "$APP_BUNDLE/Contents/Resources/icon.icns"
+cp "$SHERPA_LIB_DIR/libsherpa-onnx-c-api.dylib" "$APP_BUNDLE/Contents/Frameworks/"
+cp "$SHERPA_LIB_DIR/libonnxruntime.1.23.2.dylib" "$APP_BUNDLE/Contents/Frameworks/"
+
+step "Fixing rpaths"
+install_name_tool -add_rpath @executable_path/../Frameworks \
+  "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true
+install_name_tool -id @rpath/libsherpa-onnx-c-api.dylib \
+  "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
+install_name_tool -id @rpath/libonnxruntime.1.23.2.dylib \
+  "$APP_BUNDLE/Contents/Frameworks/libonnxruntime.1.23.2.dylib"
+
+step "Code signing App Sandbox POC"
+sign_plain "$APP_BUNDLE/Contents/Frameworks/libonnxruntime.1.23.2.dylib"
+sign_plain "$APP_BUNDLE/Contents/Frameworks/libsherpa-onnx-c-api.dylib"
+sign_sandboxed "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
+sign_sandboxed "$APP_BUNDLE"
+
+step "Verifying signature and entitlements"
+codesign -vvv --deep --strict "$APP_BUNDLE"
+codesign -d --entitlements :- "$APP_BUNDLE"
+
+step "Creating POC DMG"
+DMG_TEMP="$BUILD_DIR/dmg-staging"
+rm -rf "$DMG_TEMP"
+mkdir -p "$DMG_TEMP"
+cp -R "$APP_BUNDLE" "$DMG_TEMP/"
+ln -s /Applications "$DMG_TEMP/Applications"
+hdiutil create -volname "$APP_NAME Sandbox POC" \
+  -srcfolder "$DMG_TEMP" \
+  -ov -format UDZO \
+  "$DMG_PATH"
+rm -rf "$DMG_TEMP"
+
+step "App Sandbox POC build complete"
+echo "  App: $APP_BUNDLE"
+echo "  DMG: $DMG_PATH"
+echo "  Signing identity: $LOCAL_IDENTITY"
diff --git a/VoiceSnapGo/scripts/build-local-macos.sh b/privatevoice.src/scripts/build-local-macos.sh
similarity index 89%
rename from VoiceSnapGo/scripts/build-local-macos.sh
rename to privatevoice.src/scripts/build-local-macos.sh
index 6e79d69..39b53c0 100755
--- a/VoiceSnapGo/scripts/build-local-macos.sh
+++ b/privatevoice.src/scripts/build-local-macos.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 #
-# build-local-macos.sh - Build a locally signed macOS app and DMG for VoiceSnap.
+# build-local-macos.sh - Build a locally signed macOS app and DMG for PrivateVoice Input.
 #
 # This script intentionally does not notarize. It creates or reuses a stable
 # local Code Signing identity so macOS TCC permissions, such as Accessibility,
@@ -12,9 +12,11 @@
 #
 set -euo pipefail
 
-APP_NAME="VoiceSnap"
-LOCAL_IDENTITY="${VOICESNAP_LOCAL_SIGN_IDENTITY:-VoiceSnap Local Code Signing}"
-KEYCHAIN="${VOICESNAP_LOCAL_KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}"
+APP_NAME="PrivateVoice Input"
+DEFAULT_LOCAL_IDENTITY="PrivateVoice Local Code Signing"
+LEGACY_LOCAL_IDENTITY="VoiceSnap Local Code Signing"
+LOCAL_IDENTITY="${PRIVATEVOICE_LOCAL_SIGN_IDENTITY:-${VOICESNAP_LOCAL_SIGN_IDENTITY:-}}"
+KEYCHAIN="${PRIVATEVOICE_LOCAL_KEYCHAIN:-${VOICESNAP_LOCAL_KEYCHAIN:-$HOME/Library/Keychains/login.keychain-db}}"
 
 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
 PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -78,6 +80,16 @@
 SHERPA_LIB_DIR="$GOMODCACHE/github.com/k2-fsa/sherpa-onnx-go-macos@v1.12.24/lib/$SHERPA_ARCH"
 
 ensure_local_identity() {
+  if [[ -z "$LOCAL_IDENTITY" ]]; then
+    if security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$DEFAULT_LOCAL_IDENTITY"; then
+      LOCAL_IDENTITY="$DEFAULT_LOCAL_IDENTITY"
+    elif security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LEGACY_LOCAL_IDENTITY"; then
+      LOCAL_IDENTITY="$LEGACY_LOCAL_IDENTITY"
+    else
+      LOCAL_IDENTITY="$DEFAULT_LOCAL_IDENTITY"
+    fi
+  fi
+
   if security find-identity -v -p codesigning "$KEYCHAIN" | grep -Fq "$LOCAL_IDENTITY"; then
     echo "Using existing local signing identity: $LOCAL_IDENTITY"
     return
@@ -103,7 +115,7 @@
 
 [dn]
 CN = $LOCAL_IDENTITY
-O = VoiceSnap Local
+O = PrivateVoice Local
 
 [v3_codesign]
 basicConstraints = critical, CA:true
diff --git a/VoiceSnapGo/scripts/build-release-macos.sh b/privatevoice.src/scripts/build-release-macos.sh
similarity index 97%
rename from VoiceSnapGo/scripts/build-release-macos.sh
rename to privatevoice.src/scripts/build-release-macos.sh
index 0e8d7da..8eefac1 100755
--- a/VoiceSnapGo/scripts/build-release-macos.sh
+++ b/privatevoice.src/scripts/build-release-macos.sh
@@ -1,11 +1,11 @@
 #!/usr/bin/env bash
 #
-# build-release-macos.sh — Build a signed & notarized macOS DMG for VoiceSnap
+# build-release-macos.sh — Build a signed & notarized macOS DMG for PrivateVoice Input
 #
 # Prerequisites (one-time):
 #   1. Install Developer ID certificate in Keychain
 #   2. Store notarization credentials:
-#      xcrun notarytool store-credentials "voicesnap" \
+#      xcrun notarytool store-credentials "privatevoice" \
 #        --apple-id "your@apple.id" --team-id "3HY4G7M2AL" \
 #        --password "app-specific-password"
 #
@@ -17,10 +17,10 @@
 
 # ── Configuration ─────────────────────────────────────────────────────────────
 IDENTITY="Developer ID Application: Ningbo Zhuozhi Innovation Network Technology Co., Ltd. (3HY4G7M2AL)"
-KEYCHAIN_PROFILE="voicesnap"
+KEYCHAIN_PROFILE="privatevoice"
 
-APP_NAME="VoiceSnap"
-BUNDLE_ID="com.voicesnap.app"
+APP_NAME="PrivateVoice Input"
+BUNDLE_ID="com.privatevoice.input"
 
 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
 PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
diff --git a/VoiceSnapGo/services/app_service.go b/privatevoice.src/services/app_service.go
similarity index 97%
rename from VoiceSnapGo/services/app_service.go
rename to privatevoice.src/services/app_service.go
index 432ac34..fa7551c 100755
--- a/VoiceSnapGo/services/app_service.go
+++ b/privatevoice.src/services/app_service.go
@@ -35,7 +35,7 @@
 
 // GetAppName returns the application name.
 func (s *AppService) GetAppName() string {
-	return "VoiceSnap"
+	return "PrivateVoice Input"
 }
 
 // OpenURL opens a URL in the default browser.
diff --git a/VoiceSnapGo/services/audio_service.go b/privatevoice.src/services/audio_service.go
similarity index 100%
rename from VoiceSnapGo/services/audio_service.go
rename to privatevoice.src/services/audio_service.go
diff --git a/VoiceSnapGo/services/config_service.go b/privatevoice.src/services/config_service.go
similarity index 100%
rename from VoiceSnapGo/services/config_service.go
rename to privatevoice.src/services/config_service.go
diff --git a/privatevoice.src/services/correction_csv_service.go b/privatevoice.src/services/correction_csv_service.go
new file mode 100644
index 0000000..576cbf0
--- /dev/null
+++ b/privatevoice.src/services/correction_csv_service.go
@@ -0,0 +1,222 @@
+package services
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+	"voicesnap/internal/correctioncsv"
+	"voicesnap/internal/history"
+	"voicesnap/internal/userdict"
+
+	"github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// CorrectionCSVService handles history correction CSV export and import preview.
+type CorrectionCSVService struct {
+	history  *history.Store
+	userdict *userdict.Store
+	app      *application.App
+	win      application.Window
+
+	mu       sync.Mutex
+	sequence int64
+	previews map[string]pendingCorrectionPreview
+}
+
+type pendingCorrectionPreview struct {
+	rules []userdict.Replacement
+}
+
+// CorrectionCSVPreviewResponse is returned to the frontend after CSV preview parsing.
+type CorrectionCSVPreviewResponse struct {
+	PreviewID        string                      `json:"previewId"`
+	FileName         string                      `json:"fileName"`
+	Canceled         bool                        `json:"canceled"`
+	Summary          correctioncsv.Summary       `json:"summary"`
+	Items            []correctioncsv.PreviewItem `json:"items"`
+	TotalSlots       int                         `json:"totalSlots"`
+	VisibleLimit     int                         `json:"visibleLimit"`
+	DetailsTruncated bool                        `json:"detailsTruncated"`
+}
+
+// CorrectionCSVExportResult describes a correction CSV export attempt.
+type CorrectionCSVExportResult struct {
+	Path      string `json:"path"`
+	Canceled  bool   `json:"canceled"`
+	NoEntries bool   `json:"noEntries"`
+}
+
+// CorrectionCSVImportResult describes a confirmed import write.
+type CorrectionCSVImportResult struct {
+	Added int `json:"added"`
+}
+
+func NewCorrectionCSVService(historyStore *history.Store, userDictStore *userdict.Store) *CorrectionCSVService {
+	return &CorrectionCSVService{
+		history:  historyStore,
+		userdict: userDictStore,
+		previews: map[string]pendingCorrectionPreview{},
+	}
+}
+
+// SetApp sets the Wails app and parent window used for native dialogs.
+func (s *CorrectionCSVService) SetApp(app *application.App, win application.Window) {
+	s.app = app
+	s.win = win
+}
+
+// ExportHistoryCorrectionCSVToFile asks the user where to save a correction CSV and writes unexported entries.
+func (s *CorrectionCSVService) ExportHistoryCorrectionCSVToFile(scopeValue string) (CorrectionCSVExportResult, error) {
+	if s.app == nil {
+		return CorrectionCSVExportResult{}, os.ErrInvalid
+	}
+
+	scope, err := history.ParseExportScope(scopeValue)
+	if err != nil {
+		return CorrectionCSVExportResult{}, err
+	}
+
+	entries := s.history.GetUnexported(scope, time.Now(), time.Local)
+	if len(entries) == 0 {
+		return CorrectionCSVExportResult{NoEntries: true}, nil
+	}
+
+	home, _ := os.UserHomeDir()
+	dialog := s.app.Dialog.SaveFile().
+		SetMessage("导出勘误 CSV").
+		SetButtonText("导出").
+		SetDirectory(filepath.Join(home, "Downloads")).
+		SetFilename("privatevoice-correction-history-"+time.Now().Format("20060102-1504")+".csv").
+		AddFilter("CSV Files", "*.csv").
+		CanCreateDirectories(true)
+
+	if s.win != nil {
+		dialog.AttachToWindow(s.win)
+	}
+
+	path, err := dialog.PromptForSingleSelection()
+	if err != nil {
+		return CorrectionCSVExportResult{}, err
+	}
+	if path == "" {
+		return CorrectionCSVExportResult{Canceled: true}, nil
+	}
+	if filepath.Ext(path) == "" {
+		path += ".csv"
+	}
+
+	data, err := correctioncsv.BuildHistoryCSV(entries, time.Local)
+	if err != nil {
+		return CorrectionCSVExportResult{}, err
+	}
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		return CorrectionCSVExportResult{}, err
+	}
+
+	timestamps := make([]int64, 0, len(entries))
+	for _, entry := range entries {
+		timestamps = append(timestamps, entry.Timestamp)
+	}
+	if _, err := s.history.MarkExported(timestamps, time.Now().UnixMilli()); err != nil {
+		return CorrectionCSVExportResult{}, err
+	}
+
+	return CorrectionCSVExportResult{Path: path}, nil
+}
+
+// PreviewCorrectionCSVFromFile asks the user to pick a CSV and returns a non-writing preview.
+func (s *CorrectionCSVService) PreviewCorrectionCSVFromFile() (CorrectionCSVPreviewResponse, error) {
+	if s.app == nil {
+		return CorrectionCSVPreviewResponse{}, os.ErrInvalid
+	}
+
+	home, _ := os.UserHomeDir()
+	dialog := s.app.Dialog.OpenFile().
+		SetMessage("导入勘误表").
+		SetButtonText("选择").
+		SetDirectory(filepath.Join(home, "Downloads")).
+		AddFilter("CSV Files", "*.csv")
+
+	if s.win != nil {
+		dialog.AttachToWindow(s.win)
+	}
+
+	path, err := dialog.PromptForSingleSelection()
+	if err != nil {
+		return CorrectionCSVPreviewResponse{}, err
+	}
+	if path == "" {
+		return CorrectionCSVPreviewResponse{Canceled: true}, nil
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return CorrectionCSVPreviewResponse{}, err
+	}
+
+	preview, err := correctioncsv.ParseAndPreviewCSV(data, s.userdict.GetAll(), correctioncsv.Options{})
+	if err != nil {
+		return CorrectionCSVPreviewResponse{}, err
+	}
+
+	previewID := s.storePreview(preview.NewRules)
+	return CorrectionCSVPreviewResponse{
+		PreviewID:        previewID,
+		FileName:         filepath.Base(path),
+		Summary:          preview.Summary,
+		Items:            preview.Items,
+		TotalSlots:       preview.TotalSlots,
+		VisibleLimit:     preview.VisibleLimit,
+		DetailsTruncated: preview.DetailsTruncated,
+	}, nil
+}
+
+// ConfirmCorrectionCSVImport appends only the previously previewed new rules.
+func (s *CorrectionCSVService) ConfirmCorrectionCSVImport(previewID string) (CorrectionCSVImportResult, error) {
+	rules, ok := s.takePreview(previewID)
+	if !ok {
+		return CorrectionCSVImportResult{}, fmt.Errorf("correction CSV preview expired")
+	}
+	if len(rules) == 0 {
+		return CorrectionCSVImportResult{Added: 0}, nil
+	}
+
+	added, err := s.userdict.AppendNewReplacements(rules)
+	if err != nil {
+		return CorrectionCSVImportResult{}, err
+	}
+	return CorrectionCSVImportResult{Added: len(added)}, nil
+}
+
+func (s *CorrectionCSVService) storePreview(rules []correctioncsv.Rule) string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.sequence++
+	id := fmt.Sprintf("%d-%d", time.Now().UnixNano(), s.sequence)
+	replacements := make([]userdict.Replacement, 0, len(rules))
+	for _, rule := range rules {
+		replacements = append(replacements, userdict.Replacement{
+			From: rule.From,
+			To:   rule.To,
+		})
+	}
+	s.previews[id] = pendingCorrectionPreview{
+		rules: replacements,
+	}
+	return id
+}
+
+func (s *CorrectionCSVService) takePreview(id string) ([]userdict.Replacement, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	preview, ok := s.previews[id]
+	if !ok {
+		return nil, false
+	}
+	delete(s.previews, id)
+	return preview.rules, true
+}
diff --git a/VoiceSnapGo/services/engine_service.go b/privatevoice.src/services/engine_service.go
similarity index 70%
rename from VoiceSnapGo/services/engine_service.go
rename to privatevoice.src/services/engine_service.go
index 74a39b4..305353d 100755
--- a/VoiceSnapGo/services/engine_service.go
+++ b/privatevoice.src/services/engine_service.go
@@ -1,6 +1,7 @@
 package services
 
 import (
+	"sync"
 	"voicesnap/internal/engine"
 	"voicesnap/internal/model"
 	"voicesnap/internal/paths"
@@ -12,10 +13,14 @@
 type EngineService struct {
 	app          *application.App
 	initCallback func()
+	mu           sync.RWMutex
+	status       string
+	hardwareInfo string
+	lastError    string
 }
 
 func NewEngineService() *EngineService {
-	return &EngineService{}
+	return &EngineService{status: "loading"}
 }
 
 // ModelExists returns true if the ASR model files are present.
@@ -23,6 +28,24 @@
 	return engine.ModelExists()
 }
 
+func (s *EngineService) GetStatus() map[string]interface{} {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return map[string]interface{}{
+		"status":       s.status,
+		"hardwareInfo": s.hardwareInfo,
+		"error":        s.lastError,
+	}
+}
+
+func (s *EngineService) SetStatus(status, hardwareInfo, lastError string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.status = status
+	s.hardwareInfo = hardwareInfo
+	s.lastError = lastError
+}
+
 // DownloadModel downloads the ASR model with progress events.
 func (s *EngineService) DownloadModel(primaryURL, fallbackURL string) error {
 	err := model.Download(primaryURL, fallbackURL, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
diff --git a/privatevoice.src/services/history_service.go b/privatevoice.src/services/history_service.go
new file mode 100755
index 0000000..11bfa80
--- /dev/null
+++ b/privatevoice.src/services/history_service.go
@@ -0,0 +1,124 @@
+package services
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"time"
+	"voicesnap/internal/history"
+)
+
+// HistoryService provides recognition history to the frontend.
+type HistoryService struct {
+	store *history.Store
+}
+
+// HistoryStatus summarizes full-history state for paginated views.
+type HistoryStatus struct {
+	RetentionDays            int    `json:"retentionDays"`
+	StoragePath              string `json:"storagePath"`
+	StorageSizeBytes         int64  `json:"storageSizeBytes"`
+	StorageExists            bool   `json:"storageExists"`
+	StorageSizeAvailable     bool   `json:"storageSizeAvailable"`
+	TotalCount               int    `json:"totalCount"`
+	ExportedCount            int    `json:"exportedCount"`
+	UnexportedTodayCount     int    `json:"unexportedTodayCount"`
+	UnexportedLast7DaysCount int    `json:"unexportedLast7DaysCount"`
+	UnexportedAllCount       int    `json:"unexportedAllCount"`
+}
+
+func NewHistoryService(store *history.Store) *HistoryService {
+	return &HistoryService{store: store}
+}
+
+// GetAll returns all history entries (newest first).
+func (s *HistoryService) GetAll() []history.Entry {
+	return s.store.GetAll()
+}
+
+// GetPage returns one page of history entries (newest first).
+func (s *HistoryService) GetPage(page int, pageSize int) history.PageResult {
+	return s.store.GetPage(page, pageSize)
+}
+
+// GetStatus returns full-history metadata used by the paginated UI.
+func (s *HistoryService) GetStatus() HistoryStatus {
+	path := s.store.Path()
+	counts := s.store.Counts(time.Now(), time.Local)
+	status := HistoryStatus{
+		RetentionDays:            s.store.GetRetentionDays(),
+		StoragePath:              path,
+		TotalCount:               counts.TotalCount,
+		ExportedCount:            counts.ExportedCount,
+		UnexportedTodayCount:     counts.UnexportedTodayCount,
+		UnexportedLast7DaysCount: counts.UnexportedLast7DaysCount,
+		UnexportedAllCount:       counts.UnexportedAllCount,
+	}
+
+	if info, err := os.Stat(path); err == nil {
+		status.StorageExists = true
+		status.StorageSizeAvailable = true
+		status.StorageSizeBytes = info.Size()
+	} else if os.IsPermission(err) {
+		status.StorageExists = true
+	}
+	return status
+}
+
+// Add adds a new recognition result to history.
+func (s *HistoryService) Add(text string) {
+	s.store.Add(text)
+}
+
+// Delete removes a single entry by timestamp.
+func (s *HistoryService) Delete(timestamp int64) {
+	s.store.Delete(timestamp)
+}
+
+// ClearAll removes all history entries.
+func (s *HistoryService) ClearAll() {
+	s.store.ClearAll()
+}
+
+// ClearExported removes all history entries that have already been exported.
+func (s *HistoryService) ClearExported() (history.ClearExportedResult, error) {
+	return s.store.ClearExported()
+}
+
+// GetRetentionDays returns the current retention period in days.
+func (s *HistoryService) GetRetentionDays() int {
+	return s.store.GetRetentionDays()
+}
+
+// SetRetentionDays sets how long to keep history (0 = forever).
+func (s *HistoryService) SetRetentionDays(days int) error {
+	return s.store.SetRetentionDays(days)
+}
+
+// GetStoragePath returns the full history.json path.
+func (s *HistoryService) GetStoragePath() string {
+	return s.store.Path()
+}
+
+// OpenStorageFolder opens the folder containing history.json.
+func (s *HistoryService) OpenStorageFolder() error {
+	dir := filepath.Dir(s.store.Path())
+	info, err := os.Stat(dir)
+	if err != nil {
+		return fmt.Errorf("history storage folder unavailable: %w", err)
+	}
+	if !info.IsDir() {
+		return fmt.Errorf("history storage path is not a folder: %s", dir)
+	}
+
+	switch runtime.GOOS {
+	case "darwin":
+		return exec.Command("open", dir).Start()
+	case "windows":
+		return exec.Command("rundll32", "url.dll,FileProtocolHandler", dir).Start()
+	default:
+		return exec.Command("xdg-open", dir).Start()
+	}
+}
diff --git a/VoiceSnapGo/services/hotkey_service.go b/privatevoice.src/services/hotkey_service.go
similarity index 100%
rename from VoiceSnapGo/services/hotkey_service.go
rename to privatevoice.src/services/hotkey_service.go
diff --git a/VoiceSnapGo/services/permission_service.go b/privatevoice.src/services/permission_service.go
similarity index 100%
rename from VoiceSnapGo/services/permission_service.go
rename to privatevoice.src/services/permission_service.go
diff --git a/VoiceSnapGo/services/updater_service.go b/privatevoice.src/services/updater_service.go
similarity index 100%
rename from VoiceSnapGo/services/updater_service.go
rename to privatevoice.src/services/updater_service.go
diff --git a/VoiceSnapGo/services/userdict_service.go b/privatevoice.src/services/userdict_service.go
similarity index 93%
rename from VoiceSnapGo/services/userdict_service.go
rename to privatevoice.src/services/userdict_service.go
index 073569f..1ea39a9 100644
--- a/VoiceSnapGo/services/userdict_service.go
+++ b/privatevoice.src/services/userdict_service.go
@@ -32,6 +32,11 @@
 	return s.store.GetAll()
 }
 
+// GetPage returns one page of replacement entries.
+func (s *UserDictService) GetPage(page int, pageSize int) userdict.PageResult {
+	return s.store.GetPage(page, pageSize)
+}
+
 // Add creates a replacement entry.
 func (s *UserDictService) Add(from, to string) (userdict.Entry, error) {
 	return s.store.Add(from, to)
diff --git a/privatevoice.src/testdata/round1/README.md b/privatevoice.src/testdata/round1/README.md
new file mode 100644
index 0000000..538e297
--- /dev/null
+++ b/privatevoice.src/testdata/round1/README.md
@@ -0,0 +1,18 @@
+# Round 1 Test Fixtures
+
+These fixtures are intentionally tiny fake model directories for deterministic model resolver and installer tests. They are not real ASR models and must not be used for real recognition quality checks.
+
+## Directories
+
+- `models-valid-legacy/sensevoice`: Legacy SenseVoice directory shape.
+- `models-valid-new/sensevoice-zh`: New `sensevoice-zh` directory shape.
+- `models-incomplete-missing-tokens/sensevoice-zh`: Missing `tokens.txt`.
+- `models-incomplete-missing-model/sensevoice-zh`: Missing `model.int8.onnx` and `model.onnx`.
+- `models-zero-byte/sensevoice-zh`: Contains a zero-byte required model file.
+- `state-invalid-json`: Contains malformed `state.json`.
+- `state-points-to-legacy`: Contains state pointing to `sensevoice`.
+- `state-points-to-broken`: Contains state pointing to an incomplete directory.
+
+## Real-Device QA
+
+Real ASR verification still requires a real SenseVoice model and a real microphone test on macOS. These fixtures only cover deterministic filesystem and state handling.
diff --git a/privatevoice.src/testdata/round1/models-incomplete-missing-model/sensevoice-zh/tokens.txt b/privatevoice.src/testdata/round1/models-incomplete-missing-model/sensevoice-zh/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-incomplete-missing-model/sensevoice-zh/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/models-incomplete-missing-tokens/sensevoice-zh/model.int8.onnx b/privatevoice.src/testdata/round1/models-incomplete-missing-tokens/sensevoice-zh/model.int8.onnx
new file mode 100644
index 0000000..55dfebd
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-incomplete-missing-tokens/sensevoice-zh/model.int8.onnx
@@ -0,0 +1 @@
+fake-model
diff --git a/privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/model.int8.onnx b/privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/model.int8.onnx
new file mode 100644
index 0000000..55dfebd
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/model.int8.onnx
@@ -0,0 +1 @@
+fake-model
diff --git a/privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/tokens.txt b/privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-valid-legacy/sensevoice/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/model.int8.onnx b/privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/model.int8.onnx
new file mode 100644
index 0000000..55dfebd
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/model.int8.onnx
@@ -0,0 +1 @@
+fake-model
diff --git a/privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/tokens.txt b/privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-valid-new/sensevoice-zh/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/models-zero-byte/sensevoice-zh/tokens.txt b/privatevoice.src/testdata/round1/models-zero-byte/sensevoice-zh/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/models-zero-byte/sensevoice-zh/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/state-invalid-json/state.json b/privatevoice.src/testdata/round1/state-invalid-json/state.json
new file mode 100644
index 0000000..3589e41
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-invalid-json/state.json
@@ -0,0 +1 @@
+{invalid json
diff --git a/privatevoice.src/testdata/round1/state-points-to-broken/broken/tokens.txt b/privatevoice.src/testdata/round1/state-points-to-broken/broken/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-broken/broken/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/model.int8.onnx b/privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/model.int8.onnx
new file mode 100644
index 0000000..55dfebd
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/model.int8.onnx
@@ -0,0 +1 @@
+fake-model
diff --git a/privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/tokens.txt b/privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-broken/sensevoice/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/state-points-to-broken/state.json b/privatevoice.src/testdata/round1/state-points-to-broken/state.json
new file mode 100644
index 0000000..e33aa13
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-broken/state.json
@@ -0,0 +1,12 @@
+{
+  "schemaVersion": 1,
+  "selectedModelId": "sensevoice-zh",
+  "installedModels": {
+    "sensevoice-zh": {
+      "installedAt": 1780120000000,
+      "version": "unknown",
+      "path": "broken",
+      "sourceDirKind": "new"
+    }
+  }
+}
diff --git a/privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/model.int8.onnx b/privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/model.int8.onnx
new file mode 100644
index 0000000..55dfebd
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/model.int8.onnx
@@ -0,0 +1 @@
+fake-model
diff --git a/privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/tokens.txt b/privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/tokens.txt
new file mode 100644
index 0000000..d5d31cf
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-legacy/sensevoice/tokens.txt
@@ -0,0 +1 @@
+tokens
diff --git a/privatevoice.src/testdata/round1/state-points-to-legacy/state.json b/privatevoice.src/testdata/round1/state-points-to-legacy/state.json
new file mode 100644
index 0000000..87846ab
--- /dev/null
+++ b/privatevoice.src/testdata/round1/state-points-to-legacy/state.json
@@ -0,0 +1,12 @@
+{
+  "schemaVersion": 1,
+  "selectedModelId": "sensevoice-zh",
+  "installedModels": {
+    "sensevoice-zh": {
+      "installedAt": 1780120000000,
+      "version": "unknown",
+      "path": "sensevoice",
+      "sourceDirKind": "legacy"
+    }
+  }
+}
diff --git a/VoiceSnapGo/voicesnap.rc b/privatevoice.src/voicesnap.rc
similarity index 100%
rename from VoiceSnapGo/voicesnap.rc
rename to privatevoice.src/voicesnap.rc
diff --git a/VoiceSnapGo/wails.json b/privatevoice.src/wails.json
similarity index 70%
rename from VoiceSnapGo/wails.json
rename to privatevoice.src/wails.json
index 8c91def..6e9da9f 100755
--- a/VoiceSnapGo/wails.json
+++ b/privatevoice.src/wails.json
@@ -1,13 +1,13 @@
 {
   "$schema": "https://wails.io/schemas/config.v3.json",
-  "name": "VoiceSnap",
-  "outputfilename": "VoiceSnap",
+  "name": "PrivateVoice Input",
+  "outputfilename": "PrivateVoice Input",
   "frontend:install": "npm install",
   "frontend:build": "npm run build",
   "frontend:dev:watcher": "npm run dev",
   "frontend:dev:serverUrl": "auto",
   "author": {
-    "name": "VoiceSnap Team",
+    "name": "PrivateVoice",
     "email": ""
   }
 }

--
Gitblit v1.9.3